# NOTIF-ADR-005 — 어느 notification API가 canonical인가 ## 상태 Accepted (2026-08-15). NTF-018 대응. ## 문제 같은 저장소에 notification 모델이 **두 개** 있다. | 세대 | 위치 | 규모 | | --- | --- | --- | | R0 legacy | `adapter:outbound:notification`의 router/provider seam | 삭제 예정 | | R1 | `dev.caskeleton.application.notification` (직속) | public type 100개 | | Platform | `dev.caskeleton.application.notification.platform..` | 신규 | `application-core/CLAUDE.md`는 R1을 "R1 canonical"이라 부르고, `docs/notification/migration-guide.md`는 R0 → platform 이행만 설명하며 R1의 처분을 전혀 다루지 않는다. `Channel`, plan, dispatch, receipt/evidence 모델이 두 namespace에 중복 존재하고 둘 사이에 production bridge도 import도 없다. **실패 모드는 "무엇이 깨지는가"가 아니라 "무엇을 써야 하는가"다.** 새 consumer가 어느 API를 쓸지 알 수 없고, 두 모델이 각자 진화하며, R0를 지운 뒤에도 R1 graph가 고아로 남거나 platform이 R1 정책을 우회하는 이중 canonical이 된다. ## 결정 **Platform이 canonical이다.** R1은 유지되지만 새 production consumer를 받지 않는다. 이유는 능력이 아니라 증거다. platform은 durable acceptance, fenced claim, event ledger, projection, reconciliation을 실제 PostgreSQL 레인으로 증명한다(NOTIF-ADR-001~003). R1은 fake로 증명된 R1 계약이며 스스로 그렇게 선언한다 — `application-core/CLAUDE.md`가 "R1 application contract proven with fakes. It does not claim PostgreSQL schema/locking, provider protocol, cryptographic verifier, or runtime wiring qualification"이라고 적어 둔 그대로다. ## Disposition R1 public type 100개의 처분은 네 가지 중 하나다. | 처분 | 의미 | 대상 | | --- | --- | --- | | `replace` | platform에 동등물이 있다. 새 consumer는 platform을 쓴다 | `Channel`, plan/dispatch/receipt/evidence 계열 | | `bridge` | 변환이 필요하다. 변환은 ACL 한 곳에만 둔다 | writer-cutover / receipt 적용 경로 | | `retain` | platform이 다루지 않는 관심사다. 그대로 둔다 | consent/quiescence verifier port | | `delete` | R0와 함께 사라진다 | R0 router가 쓰던 seam | 전수 분류표는 이 ADR이 아니라 `docs/notification/module-mapping.md`가 소유한다. ADR은 규칙을, mapping 문서는 목록을 소유한다 — 목록을 두 곳에 복제하면 드리프트하는 쪽이 늘어난다. ## 강제 두 namespace 사이의 production dependency는 **0건**이며, 이것은 문서가 아니라 ArchUnit 규칙이 지킨다 (`CleanArchitectureTest`의 `NOTIFICATION_R1_AND_PLATFORM_DO_NOT_DEPEND_ON_EACH_OTHER`). 변환이 필요해지면 `dev.caskeleton.application.notification.compatibility.r1` 한 패키지에만 두고, 그 패키지만 규칙에서 예외로 인정한다. 예외를 한 곳으로 모으는 것이 목적이다 — 두 모델이 서로를 아는 지점이 여러 곳이면 "어느 쪽이 canonical인가"라는 질문에 코드가 답하지 못한다. ## 결과 - 새 production consumer는 `..notification.platform..`만 쓴다. - R1 type은 남지만, 새 코드가 그것을 import하면 ArchUnit이 막는다. - R0 삭제는 이 ADR과 무관하게 진행된다. R1은 R0와 함께 사라지지 않는다. ## 하지 않은 것 R1 100개 type에 `@Deprecated(forRemoval = true)`를 붙이지 않았다. 제거 시점이 정해지지 않았고, `forRemoval`은 "이 릴리스 이후 사라진다"는 약속이라 시점 없이 붙이면 그 자체가 거짓 신호다. 경계는 ArchUnit이 강제하고, deprecation은 제거 계획이 생길 때 붙인다.