# Notification Production Capability Deep Design - 작성일: 2026-07-28 - 상태: 상세 설계 승인, 구현 계획 작성, 구현 미착수 - 기준: Java 21, Spring Boot 4.0.0, Gradle 멀티모듈 Clean Architecture - 대상 leaf: `adapter-outbound-notification` - 상위 문서: [Production Capability Platform Design](2026-07-26-production-capability-platform-design.md) - 비교 기준: [Redis Production Capability Deep Design](2026-07-26-redis-production-capability-design.md), [Fileserver Production Capability Deep Design](2026-07-26-fileserver-production-capability-design.md), [HTTP Client Production Capability Deep Design](2026-07-27-httpclient-production-capability-design.md) ## 0. 문서 상태와 구현 상태 이 문서는 Notification capability의 승인된 상세 설계다. 2026-07-28에 §36의 다섯 gate를 사용자가 승인했으며, 실제 구현 순서는 [Notification Production Capability Implementation Plan](../plans/2026-07-28-notification-production-capability.md)을 정본으로 사용한다. 구현 및 production readiness는 아직 주장하지 않는다. 2026-07-28 현재 구현된 범위: - `application-core`의 `Channel`, raw `Notification`, `NotificationPort`; - `(channel, route) -> providerId list` fan-out router; - channel 안의 중복 provider ID 및 configured route가 존재하지 않는 provider ID를 참조하는 경우의 construction 검증; - route 미설정 시 silent no-op 대신 `AdapterDisabledException`; - provider exception을 기록하되 호출자에게 전파하지 않는 global fail-open decorator; - PII인 recipient와 body를 dependency log에 넣지 않는 단위 테스트; - `google-email`, `slack-webhook` provider/client extension seam; - provider/client fake를 이용한 routing, fan-out, fail-open 단위 테스트. 아직 구현되지 않은 범위: - 실제 Google email 또는 Slack client; - versioned template, locale, typed parameter schema와 rendering; - best-effort와 durable delivery mode의 명시적 분리; - provider-neutral submission outcome과 recipient outcome; - durable notification intent/delivery/attempt/receipt store; - claim owner token, retry horizon, expiry, reconciliation과 unknown outcome; - consent, preference, quiet-hours와 business suppression; - hard bounce, complaint와 technical suppression; - provider별 quota, rate limit, retry, idempotency 및 receipt capability; - canonical binding/expected-state 설정과 zero-resource 비활성 계약; - Slack Web API와 Amazon SES v2 reference provider; - provider callback verification 및 deduplication; - production readiness lane과 real-provider evidence. 현재 코드는 R0 extension seam과 local routing skeleton이다. 테스트가 통과하더라도 email 또는 Slack notification이 실제로 전송된다는 증거가 아니며, durable/critical notification의 근거도 아니다. ## 1. 설계 판정 현재 Notification 구현의 가장 큰 문제는 provider가 없다는 사실만이 아니다. 다음 의미가 하나의 `void notify(...)` 호출에 섞여 있다. 1. 업무상 알림을 만들어도 되는가; 2. 어떤 template과 locale을 쓸 것인가; 3. inline으로 시도할 것인가 durable하게 저장할 것인가; 4. 어떤 provider에 몇 번 시도할 것인가; 5. provider가 요청을 받았는가; 6. recipient system까지 도착했는가; 7. 실패를 무시해도 되는가; 8. 응답을 잃었을 때 재전송해도 되는가. 현재 global fail-open은 provider가 던진 모든 예외를 삼키므로 호출자는 성공, 실패, indeterminate를 구분할 수 없다. 반대로 route list는 항상 fan-out으로 해석되어 ordered fallback과 single provider가 구분되지 않는다. raw recipient/subject/body는 template version, locale, parameter schema, idempotency, expiry, consent snapshot을 표현하지 못한다. 이번 설계의 목표는 범용 `send(channel, recipient, body)` SDK가 아니다. > feature-specific application policy가 생성한 versioned notification intent를, 검토된 > route와 template에 따라 bounded하게 계획하고, best-effort inline 또는 durable async > mode로 실행하며, provider submission과 recipient outcome을 분리해 추적·재시도·복구하는 > outbound capability 선택한 핵심 구조는 다음과 같다. 1. Business use case는 `PasswordResetNotificationRequestFactory` 같은 feature-specific application policy/factory와 명시적 outbound port를 사용한다. 2. `application-core`에는 framework-free notification intent, plan, append/store와 dispatch use case 계약만 둔다. 3. `adapter-outbound-notification`은 route/template catalog, rendering, provider attempt와 provider-specific reconciliation을 소유한다. 4. business consent, preference, quiet-hours, notification 필요성은 domain/application이 소유한다. 5. technical bounce/complaint suppression은 notification lifecycle의 기술 상태로 분리한다. 6. `BEST_EFFORT_INLINE`과 `DURABLE_ASYNC`를 별도 계약으로 두며 application code의 `NotificationKindPolicy`만 mode를 결정한다. 7. durable mode는 source business DB와 같은 transaction에서 recipient 1명의 intent를 append하고, 별도 dispatcher가 short claim transaction 뒤 DB transaction 밖에서 provider를 호출한다. 8. intent, provider delivery leg, physical attempt, provider receipt를 서로 다른 identity/state로 관리한다. 9. timeout이나 ACK loss 뒤의 결과는 일반 retryable failure가 아니라 `INDETERMINATE`로 모델링한다. 10. 외부 provider 호출과 local DB commit 사이의 exactly-once는 주장하지 않는다. 11. Slack 초기 R2 reference provider는 Web API `chat.postMessage`, email은 Amazon SES v2 API로 선택한다. 12. 기존 `slack-webhook`과 `google-email` seam은 legacy R0 best-effort compatibility로만 취급하며 durable/critical/receipt-required route에 binding하지 않는다. 13. binding이 없으면 provider client, scheduler, callback subscription, health probe를 만들지 않는다. 14. provider별 production readiness는 정확한 effective capability tuple과 real-provider evidence로 판정한다. ## 2. 기존 심화 설계에서 재사용할 패턴과 재사용하지 않을 패턴 ### 2.1 재사용할 공통 패턴 | 기존 설계 | Notification에 재사용할 결정 | | --- | --- | | Redis | semantic port, capability별 failure policy, typed activation, exact readiness card, bounded resource | | Fileserver | provider-neutral request/receipt, configured guarantee와 achieved guarantee 분리, `INDETERMINATE`, reconciliation 우선 | | HTTP Client | typed ID/catalog, expected-state binding, logical call과 physical attempt 분리, retry amplification budget, PII-safe telemetry | 세 문서에서 공통으로 채택한 다음 원칙도 그대로 적용한다. - application에 provider SDK나 transport 타입을 노출하지 않는다; - arbitrary provider/endpoint/credential을 caller가 선택하지 않는다; - code catalog가 허용한 operation/template/route만 config가 활성화한다; - config는 code에 검토된 상한을 강화할 수 있지만 완화할 수 없다; - no binding은 zero side effect다; - timeout과 response loss는 성공/실패 이분법으로 축소하지 않는다; - fake test만으로 production provider readiness를 주장하지 않는다; - metric tag에 high-cardinality 또는 PII 값을 쓰지 않는다; - legacy alias와 canonical 설정이 동시에 존재하면 precedence를 추론하지 않고 실패한다. ### 2.2 Notification에 복사하지 않을 capability-specific 패턴 - Redis key/hash slot, Lua/Function, topology semantics를 notification dedupe나 lock에 재사용하지 않는다. - Fileserver의 staging/rename/journal을 notification delivery journal에 그대로 투영하지 않는다. - HTTP method idempotency나 URI/DNS/pool 정책을 provider-neutral notification 의미로 노출하지 않는다. - application outbox의 현재 generic row/publisher를 곧바로 notification delivery store로 간주하지 않는다. - broker publish 성공을 recipient delivery로 간주하지 않는다. - Slack message timestamp나 SES message ID를 application-wide idempotency key로 사용하지 않는다. ### 2.3 Normative decision ledger 긴 문서에서 결정을 다시 추론하지 않도록 구현과 리뷰는 다음 위치를 정본으로 사용한다. | 결정 | 정본 | | --- | --- | | capability/readiness 용어 | §7 | | 모듈 소유권과 의존성 | §8, §31 | | application 계약과 typed 값 | §9–§10 | | mode와 state machine | §11–§12 | | planning/routing/fan-out/fallback | §13 | | template/rendering/localization | §14 | | provider attempt와 retry 의미 | §15 | | durable DB workflow와 concurrency | §16 | | Slack/Email reference provider | §18–§19 | | receipt/reconciliation/suppression | §20 | | 설정·activation·zero-resource | §21–§22 | | deadline/resource/amplification | §23 | | 보안·개인정보·보존 | §24 | | 관측성·health·lifecycle | §25–§27 | | 테스트·CI·evidence | §29 | | migration과 completion | §32–§34 | 예시 YAML, Java shape 또는 migration alias가 이 표의 정본보다 우선하지 않는다. ## 3. 증거 기반 현재 상태 ### 3.1 application contract가 delivery 의미를 표현하지 못한다 현재 application contract는 다음 세 타입뿐이다. ```text Channel = EMAIL | SLACK Notification = recipient + subject + body NotificationPort.notify(channel, route, notification) -> void ``` 이 계약에는 다음 필드가 없다. - intent ID와 idempotency/fingerprint; - feature/notification kind; - template ID/version과 locale; - typed template parameter; - delivery mode와 policy revision; - not-before, expiry, retry horizon; - tenant, correlation, causation; - recipient reference와 consent/preference evidence; - submission outcome 또는 receipt. `void` 반환과 global fail-open을 조합하면 caller는 provider가 실행되지 않은 경우도 성공한 호출과 구분할 수 없다. 이 shape는 non-critical telemetry-like best-effort compatibility 외에는 정확한 업무 계약이 될 수 없다. ### 3.2 route list가 fan-out 의미로 고정된다 `RoutingNotifier`는 route의 provider ID list를 순서대로 모두 호출한다. ```text app.notification.routes..=provider-a,provider-b ``` 이 list가 의미하는 바는 현재 무조건 `FAN_OUT_ALL`이다. 다음을 구분할 필드가 없다. - 정확히 하나만 호출하는 `SINGLE`; - definite failure 때만 다음 provider로 넘어가는 `ORDERED_FALLBACK`; - 모든 provider에 독립 delivery를 만드는 `FAN_OUT_ALL`. 각 provider는 호출 전에 `FailOpenNotificationProvider`로 감싸져 outcome을 잃는다. 따라서 router는 fallback 결정을 할 수도 없고 provider별 delivery 상태를 남길 수도 없다. ### 3.3 설정의 activation source가 서로 어긋난다 현재 bootstrap/sample YAML은 다음 selector를 노출한다. ```text app.notification.slack.provider = APP_NOTIFICATION_SLACK_PROVIDER app.notification.email.provider = APP_NOTIFICATION_EMAIL_PROVIDER ``` env registry와 optional contract test도 이 두 selector를 기준으로 한다. 그러나 provider configuration은 다음 legacy boolean을 조건으로 사용한다. ```text app.notification.slack-webhook.enabled=true app.notification.google-email.enabled=true ``` 실제 route binding은 default YAML에 없다. 즉 문서/환경 SSOT가 말하는 active provider와 bean activation이 같은 graph를 만들지 않는다. 이 상태에서 selector가 채워졌다는 사실은 provider가 생성되거나 route가 usable하다는 증거가 아니다. ### 3.4 provider는 실제 client가 아니다 `SlackClient`와 `GoogleEmailClient`는 extension interface이며 production 구현이 없다. `SlackWebhookProvider`와 `GoogleEmailProvider`는 이 client를 호출하는 wrapper다. build dependency에도 Slack SDK, AWS SDK, Gmail SDK 또는 SMTP client가 없다. 따라서 현재 provider ID는 다음과 같이 해석해야 한다. | provider ID | 현재 의미 | production 보장 | | --- | --- | --- | | `slack-webhook` | injected fake/client seam | 없음 | | `google-email` | injected fake/client seam | 없음 | ### 3.5 durable workflow가 없다 현재 `NotificationPort` 호출과 함께 저장되는 intent가 없고 dispatcher/claim/attempt journal도 없다. process crash, timeout, provider ACK loss, DB update 실패 뒤에 다음을 판별할 근거가 없다. - 전송을 시작하지 않았는가; - provider가 거부했는가; - provider는 받았지만 응답을 잃었는가; - provider message ID를 받았으나 local commit 전에 죽었는가; - 다시 보내면 중복이 되는가. 기존 generic application outbox는 event publication을 위한 mutable row와 generic publisher shape다. notification은 recipient별 fan-out, template snapshot, provider attempt, indeterminate/reconciliation, feedback event, encrypted PII 보존이 필요하므로 그 row를 그대로 재사용하지 않는다. ### 3.6 production consumer가 없다 repository의 production source에서 `NotificationPort`를 호출하는 feature use case가 없다. 현재 테스트는 routing skeleton의 local behavior만 증명한다. 이 설계는 sample feature를 억지로 consumer로 만들지 않고 먼저 reusable capability contract를 확정한다. ### 3.7 문서도 현재 코드와 일부 어긋난다 notification README의 module guidance와 실제 leaf의 `CLAUDE.md`, selector 설명과 legacy enabled condition, route activation 설명 사이에 drift가 있다. Phase 0에서 코드 변경 전 현재 truth를 한 표로 정리하고 서로 다른 activation source를 동시에 유지하지 않는다. 주요 근거 파일: - `src/application-core/src/main/java/dev/caskeleton/application/notification/Channel.java` - `src/application-core/src/main/java/dev/caskeleton/application/notification/Notification.java` - `src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationPort.java` - `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/core/RoutingNotifier.java` - `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/core/FailOpenNotificationProvider.java` - `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/core/NotificationProvider.java` - `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/email/google/GoogleEmailNotificationAdapterConfig.java` - `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/slack/webhook/SlackNotificationAdapterConfig.java` - `src/app-bootstrap/src/main/resources/application.yml` - `src/config/architecture/modules.json` ### 3.8 independent design review 2026-07-28에 architecture boundary, 기존 Redis/Fileserver/HTTP Client 설계 일관성, durability/receipt 세 관점으로 독립 read-only review와 correction re-review를 수행했다. - 최초 review의 mode policy leak, sibling compiler, recipient/provider-leg identity, transaction root, crypto/HMAC, exact card, SES/SNS topology, wire authorization/lease, receipt projection, admission park 지적을 본문에 반영했다; - correction re-review 결과 세 관점 모두 blocker 0, high 0이다; - 이는 design consistency evidence이며 구현/real-provider R2 evidence가 아니다. baseline verification: - `./gradlew :adapter:outbound:notification:test --rerun-tasks --console=plain` → 19 tests, failures/errors/skipped 0; - `./gradlew verifyCleanArchitectureDependencies --console=plain` → build success. ## 4. 범위와 명시적 비범위 ### 4.1 최소 R2 baseline에 포함 - `EMAIL`, `SLACK` channel; - feature-specific application request factory/policy와 outbound notification port; - versioned notification kind, route, template, locale와 typed parameter; - `BEST_EFFORT_INLINE`, `DURABLE_ASYNC` mode; - `SINGLE` provider R2와 `FAN_OUT_ALL`, `ORDERED_FALLBACK` R1 kernel; - one-recipient logical intent, N provider leg, M physical attempt model; - provider-neutral attempt outcome과 indeterminate state; - source DB transaction과 함께 저장되는 durable intent; - PostgreSQL/JPA 기반 claim, attempt journal, retry, expiry와 reconciliation; - checked-in local template rendering; - Slack Web API `chat.postMessage`; - Amazon SES v2 `SendEmail`; - Slack conversation post reference와 SES message ID 저장; - SES bounce/complaint/delivery/delivery-delay feedback intake. rendering failure는 provider-stored template optional card에서만 지원; - technical suppression과 application-owned consent/preference 분리; - canonical provider binding과 zero-resource disabled behavior; - deadline, concurrency, queue, retry와 fan-out 상한; - PII direct-AEAD encryption/redaction/retention; - startup/readiness/metrics/traces/runbook; - fake/local protocol test와 explicit real-provider qualification lane. ### 4.2 R2 뒤에 열어둘 optional capability - Slack incoming webhook compatibility provider; - Gmail API provider; - SMTP provider; - provider-stored SES template; - SMS, push, mobile/web inbox; - cross-region active/active dispatcher; - broker wake-up/partitioning; - user-facing notification preference center; - provider message update/delete; - Slack event-based conversation reconciliation; - marketing analytics/open/click tracking; - multi-recipient batch provider API. optional provider는 동일한 capability name 아래 자동 호환으로 간주하지 않는다. 각 provider card가 정확한 submission, idempotency, receipt, sandbox, quota, reconciliation 보장을 선언하고 요구 profile을 통과해야 한다. ### 4.3 이번 범위에서 제외 - notification 내용을 결정하는 domain business rule; - controller/filter/settings/mapper의 notification policy; - arbitrary raw email/Slack body 전송 SDK; - caller-supplied Slack webhook URL/channel ID 또는 email provider credential; - provider SDK type를 application에 반환하는 API; - external send와 DB commit의 distributed transaction; - exactly-once delivery 또는 exactly-once user visibility; - email inbox 도착, 열람 또는 Slack 사용자 읽음 보장; - provider별 마케팅 캠페인 orchestration; - production leaf에서 sample WorkLog 개념 사용; - notification leaf가 persistence/messaging/httpclient sibling leaf를 직접 의존하는 구조; - 일반-purpose cron/job framework; - business unsubscribe를 bounce suppression table로 대체하는 구조. ## 5. HARD invariants 다음 조건은 구현 편의를 위해 낮출 수 없다. 1. `domain-core`에 Spring, JPA, Slack/AWS/Gmail/SMTP, JSON, HTTP 타입을 넣지 않는다. 2. controller는 notification provider, repository, persistence entity를 직접 사용하지 않는다. 3. inbound DTO를 application notification command나 template parameter로 재사용하지 않는다. 4. notification adapter가 consent, preference, quiet-hours 또는 “누구에게 알려야 하는가”를 결정하지 않는다. 5. business use case가 provider ID, webhook URL, channel ID, AWS region, credential을 선택하지 않는다. 6. provider SDK request/response/exception을 application contract에 노출하지 않는다. 7. critical/durable route를 caller flag로 best-effort에 downgrade하지 않는다. 8. `void` + exception swallow를 durable 또는 critical success로 표현하지 않는다. 9. provider accepted와 recipient delivered/read를 같은 상태로 표현하지 않는다. 10. timeout, connection loss, ACK loss를 definite-not-sent로 간주하지 않는다. 11. `INDETERMINATE` attempt를 근거 없이 blind retry하지 않는다. 12. fallback은 `submissionCertainty=DEFINITELY_NOT_APPLIED`에서만 진행한다. unknown duplicate tolerance/escalation은 별도 application intent로 표현한다. 13. 외부 send와 local DB update 사이 exactly-once를 주장하지 않는다. 14. hidden SDK retry가 physical-attempt budget 밖에서 실행되지 않게 한다. 15. route/provider/template revision을 진행 중 intent에 조용히 재해석하지 않는다. 16. raw recipient, body, subject, template parameter, token, provider message ID를 metric tag로 사용하지 않는다. 17. secret/webhook URL/access token을 log, exception message, receipt 또는 callback payload snapshot에 남기지 않는다. 18. durable queue의 recipient/content를 plaintext로 무기한 저장하지 않는다. 19. provider feedback의 technical suppression을 business consent의 정본으로 사용하지 않는다. 20. route binding이 없으면 client, scheduler, thread, connection, probe, callback subscription을 만들지 않는다. 21. notification adapter가 persistence, messaging, inbound 또는 HTTP client adapter에 project dependency를 추가하지 않는다. 22. real-provider evidence 없이 provider R2를 주장하지 않는다. 23. Slack `ts`를 user delivery/read receipt로 표현하지 않는다. 24. SES `MessageId`와 `DELIVERY` event를 inbox/read receipt로 표현하지 않는다. 25. callback은 signature/authenticity, expected account/topic/workspace와 replay/deduplication을 검증하기 전 application command로 승격하지 않는다. 26. retry, target, fallback과 provider call의 총 amplification 상한이 없는 route를 활성화하지 않는다. 27. provider quota, backpressure 또는 DB backlog가 무한 queue/worker 생성으로 이어지지 않는다. 28. stale claim owner가 새 owner의 attempt를 finalize하지 못하게 한다. 29. 같은 business operation의 반복 요청이 새 intent인지 retry인지 stable identity 없이 추론되지 않게 한다. 30. notification capability가 business transaction commit 전에 외부 provider를 먼저 호출하지 않는다. ## 6. 대안 검토 ### 6.1 현재 `void NotificationPort` 확장 장점: - 변경량이 가장 작다; - 현재 router/fan-out code를 유지할 수 있다. 탈락 이유: - outcome, durable append, attempt, receipt를 표현할 수 없다; - raw recipient/body가 template와 policy boundary를 우회한다; - global fail-open을 feature별 failure policy로 바꿀 수 없다. 판정: R0 legacy compatibility에만 유지한다. ### 6.2 provider가 DB/outbox에 직접 기록 장점: - notification leaf 안에서 durable workflow가 한곳에 보인다. 탈락 이유: - registry가 notification -> persistence project edge를 허용하지 않는다; - application transaction boundary가 adapter에 역전된다; - provider implementation이 business workflow와 storage orchestration을 소유하게 된다. 판정: 채택하지 않는다. ### 6.3 message broker를 durability의 정본으로 사용 장점: - worker scale-out과 wake-up이 쉽다; - retry/topic partition tooling을 활용할 수 있다. 탈락 이유: - business DB commit과 broker publish 사이 dual-write가 생긴다; - provider fan-out/attempt/receipt/PII retention을 broker event 하나로 해결하지 못한다; - notification leaf -> messaging edge도 registry에 없다. 판정: R2 baseline은 same-source DB intent가 정본이다. 이후 broker에는 opaque intent ID만 outbox로 발행해 wake-up hint로 사용할 수 있다. DB claim/state가 계속 정본이다. ### 6.4 기존 generic application outbox row를 그대로 재사용 장점: - 테이블과 scheduler 수가 적다; - 기존 polling/publish 흐름을 재사용할 수 있다. 탈락 이유: - 현재 outbox에는 recipient별 delivery/attempt/receipt identity가 없다; - provider response loss와 reconciliation 상태가 없다; - encrypted payload, consent snapshot, route/template revision 보존 계약이 없다; - generic publisher의 성공/실패와 notification recipient outcome이 다르다. 판정: scheduler/claim pattern은 참고할 수 있지만 notification 전용 aggregate/table과 port를 만든다. 공통화는 두 capability의 invariant가 검증된 뒤 별도 설계로 진행한다. ### 6.5 provider 자체 retry와 idempotency에 전적으로 의존 장점: - application coordinator가 단순하다. 탈락 이유: - Slack과 SES의 baseline send API에는 운영상 의존할 문서화된 native idempotency key가 없다; - SDK hidden retry는 physical calls와 duplicate risk를 가린다; - provider accepted 뒤 local commit 실패를 해결하지 못한다. 판정: coordinator가 logical retry와 budget을 소유한다. SDK retry는 disable하거나 모든 wire attempt가 같은 journal/budget에 계수된다는 evidence가 있을 때만 허용한다. ### 6.6 Slack Incoming Webhook을 baseline으로 사용 장점: - payload와 인증이 단순하다; - 작은 고정 채널 알림에 적합하다. 탈락 이유: - URL이 destination과 secret을 함께 결합한다; - 요청에서 목적지를 바꿀 수 없다; - 성공 응답에 message `ts`가 없어 reconciliation이 약하다; - update/delete와 dynamic route 요구가 제한된다. 판정: `chat.postMessage`를 R2 reference로, incoming webhook은 고정 목적지 legacy compatibility provider로 둔다. ### 6.7 Gmail API를 generic email baseline으로 사용 장점: - Gmail/Workspace mailbox identity에 자연스럽다; - Gmail message resource와 mailbox 기능을 활용할 수 있다. 탈락 이유: - per-user quota와 OAuth/domain-wide delegation 운영 복잡도가 baseline에 결합된다; - generic transactional email feedback/bounce lifecycle의 정본이 아니다; - 현재 `google-email` 이름이 Gmail API인지 SMTP인지도 명확하지 않다. 판정: Amazon SES v2를 초기 R2 reference로 선택한다. Gmail API는 별도 exact provider card가 필요한 optional provider다. ## 7. capability와 evidence 용어 ### 7.1 readiness level | Level | 의미 | 허용 표현 | | --- | --- | --- | | R0 | interface, fake, skeleton 또는 legacy seam | “extension seam”, “설계/골격” | | R1 | local deterministic behavior와 contract test | “local behavior verified” | | R2 | 선택된 real provider profile의 운영 필수 보장과 failure evidence | “provider/profile R2” | | R3 | production-like scale/failover/rotation/운영훈련 evidence | “해당 topology/profile R3” | readiness는 module 전체의 단일 숫자가 아니다. ```text NotificationCapabilityCard = card ID/revision + provider binding ID/revision + channel + application policy mode + route strategy + template/render/serialization revision + submission semantics + pre-send correlation/native idempotency + reconciliation lookup mode + receipt transport/projections + credential source/account/region/workspace profile + tested evidence revision/maturity ``` 예: ```text aws-ses-v2-durable-single-local-sns-v1 / aws-ses-primary@v3 / EMAIL / DURABLE_ASYNC / SINGLE / LOCAL_RENDERED@v4 + canonical-email-v2 / API_ACCEPTED_MESSAGE_ID / ATTEMPT_TAG_NO_NATIVE_IDEMPOTENCY / PRE_SEND_CORRELATION_EVENT_LOOKUP / SNS_HTTPS@v1 / account+ap-northeast-2+WEB_IDENTITY / evidence-2026-07-28 / R1 ``` 다른 provider, region, template mode, callback topology 또는 credential mode로 일반화하지 않는다. ### 7.2 normative state wording | 표현 | 정확한 의미 | | --- | --- | | `APPENDED` | local durable intent transaction이 commit됨 | | `WIRE_AUTHORIZED` | eligibility 재검사를 통과하고 physical provider call 권한을 durable commit함 | | `PROVIDER_ACCEPTED` | provider API가 요청 수락을 응답함 | | `POSTED_TO_CONVERSATION` | Slack conversation에 message reference가 생성됨 | | `DELIVERED_TO_RECIPIENT_MTA` | email recipient 측 MTA가 수락했다는 provider feedback | | `BOUNCED` | provider가 bounce feedback을 보고함 | | `COMPLAINED` | provider가 complaint feedback을 보고함 | | `TERMINAL_INDETERMINATE` | reconcile horizon 뒤에도 provider side effect 여부를 확정할 수 없음 | `DELIVERED`, `SUCCESS`, `SENT` 같은 단독 표현은 provider/card 문맥 없이 terminal 상태 이름으로 사용하지 않는다. ## 8. 모듈 소유권과 dependency direction 현재 `src/config/architecture/modules.json`에 따르면 notification leaf의 허용 production dependency는 다음뿐이다. ```text adapter-outbound-notification -> domain-core -> application-core -> shared-contract -> adapter-outbound-support ``` 이 registry를 유지한 상태에서 소유권을 다음처럼 나눈다. | 책임 | 소유 모듈 | 금지 | | --- | --- | --- | | notification eligibility와 business invariant | `domain-core` 또는 feature application | adapter/config에서 결정 | | feature-specific request factory/policy와 use case | `application-core` | transport/provider DTO | | framework-free intent/plan/store/dispatch 계약 | `application-core` | Spring/JPA/SDK 타입 | | route/template catalog, renderer | `adapter-outbound-notification` | business consent | | provider request/response mapping | `adapter-outbound-notification` | persistence entity | | durable notification table/repository adapter | `adapter-outbound-persistence-jpa` | provider SDK | | raw callback auth/transport mapping | 현재 `adapter-inbound-web` | provider send implementation | | scheduler, worker bean, provider/store composition | `app-bootstrap` | business policy | | optional broker wake-up | messaging/outbox 관련 owner leaf | DB state 대체 | | sample WorkLog notification consumer | `sample-portfolio` | production leaf로 역의존 | ### 8.1 application orchestration application service는 다음 port를 조합할 수 있다. ```text Feature use case -> FeatureNotificationRequestFactory + NotificationKindPolicy -> InlineNotificationAttemptPort or NotificationIntentAppendPort NotificationDispatchUseCase -> NotificationDeliveryStorePort -> NotificationProviderAttemptPort -> NotificationReceipt/ReconciliationPort ``` application policy/factory에는 `Port` 이름을 붙이지 않는다. interface의 최종 분할은 구현 계획에서 package cohesion을 검증하되, 하나의 giant `NotificationPort`로 plan/store/send/receipt를 다시 합치지 않는다. ### 8.2 callback ownership SES/SNS, Slack event 또는 provider webhook의 raw HTTP signature 검증은 inbound adapter 책임이다. 현재 registry는 inbound web -> notification adapter edge를 허용하지 않으므로 inbound adapter는 provider SDK event object를 넘기지 않는다. ```text HTTP callback -> inbound signature/account/topic/workspace verification -> framework-free NormalizedNotificationReceiptCommand -> application receipt use case -> persistence receipt/delivery state port ``` provider callback 종류가 커지고 inbound lifecycle이 독립 배포/의존성을 요구하면 `adapter:inbound:notification` leaf 추가를 registry migration으로 별도 제안한다. outbound leaf에 controller/listener를 넣지 않는다. ## 9. application-facing 계약 ### 9.1 feature-specific port가 우선이다 business code가 generic channel/body를 직접 조립하지 않도록 feature별 application policy/factory를 둔다. 개념 예: ```java public final class PasswordResetNotificationRequestFactory { NotificationIntentDraft create(PasswordResetNotice notice); } ``` `PasswordResetNotice`에는 business 의미와 이미 검증된 opaque recipient reference만 있고 Slack, SES, HTML, Block Kit, webhook URL은 없다. application-owned factory와 `NotificationKindPolicy`가 notification kind/route/template/mode/admission class를 closed catalog에서 고른다. `PasswordResetNotificationRequestFactory`는 outbound port가 아니며 이름에 `Port`를 붙이지 않는다. 실제 외부 side effect와 저장은 application-core가 선언한 `InlineNotificationAttemptPort`, `NotificationIntentAppendPort`, `NotificationDeliveryStorePort` 같은 outbound port 뒤에 둔다. generic foundation은 feature application policy를 구현하기 위한 내부 capability이며, 모든 use case가 raw template ID와 parameter map을 자유롭게 호출하는 public utility로 제공하지 않는다. ### 9.2 intent command의 최소 의미 framework-free command는 개념적으로 다음 값을 갖는다. ```text NotificationIntentDraft intentId notificationKind channel routeId templateRef(id, version) locale recipientRef typedParameters mode idempotencyScope + sourceOperationId tenant/correlation/causation notBefore expiresAt policyRevision admissionClass ``` 정확한 Java record 분할은 다음 원칙을 따른다. - 모든 ID는 bounded value object다; - `Map`와 raw JSON string은 사용하지 않는다; - inbound request DTO를 생성자 인자로 받지 않는다; - recipient는 email address/Slack channel을 한 raw string union으로 만들지 않는다; - address/channel lookup이 필요한 경우 opaque `RecipientReference`와 application resolver port를 사용한다; - `Clock`/time policy는 testable application dependency이며 adapter가 expiry를 임의 결정하지 않는다; - mode와 admission class는 application의 `NotificationKindPolicy`가 고정한다. - 최소 R2에서 intent 하나는 logical recipient를 정확히 한 명만 갖는다. bulk는 별도 `RecipientDelivery` dimension을 설계하기 전까지 허용하지 않는다. ### 9.3 request result request 결과는 delivery 성공을 뜻하지 않는다. ```text NotificationRequestResult = InlineCompleted(bounded TargetAttemptOutcome list) | AppendedDurably(intentReference) | DuplicateExistingIntent(intentReference) | RejectedByBusinessPolicy(reasonCode) | RejectedInvalidRequest(reasonCode) | CapabilityUnavailable(reasonCode) ``` `InlineCompleted`는 `SINGLE`뿐 아니라 bounded `FAN_OUT_ALL`의 부분 성공/실패를 target ordinal별로 표현한다. `TargetAttemptOutcome`은 §15.3의 직교 outcome을 사용한다. 각 결과는 bounded reason code와 opaque intent/reference를 가질 수 있다. provider ID, raw address, SDK error 또는 persistence entity를 반환하지 않는다. ### 9.4 business policy snapshot과 recheck enqueue 전 application은 적어도 다음을 판단한다. - notification이 business적으로 필요한가; - recipient가 누구인가; - legal/consent/preference가 허용하는가; - quiet hours/not-before가 적용되는가; - expiry 이후 가치가 남는가; - 같은 source operation에서 이미 요청했는가. 시간이 긴 durable marketing notification은 dispatch 직전 consent/preference 재확인이 필요할 수 있다. 이 여부와 recheck port는 notification kind policy가 고정한다. ```text ConsentCheckMode = SNAPSHOT_AT_APPEND RECHECK_BEFORE_EACH_DELIVERY ``` security/password-reset처럼 법적 근거와 urgency가 다른 종류를 marketing default로 묶지 않는다. ## 10. identity, fingerprint와 revision ### 10.1 identity 계층 | ID | 범위 | 용도 | | --- | --- | --- | | `NotificationIntentId` | logical business notification | append dedupe, 조회, correlation | | `NotificationDeliveryId` | 한 provider leg/technical target | fan-out/fallback 상태 | | `NotificationAttemptId` | 한 physical provider call | journal, budget, latency | | `NotificationReceiptEventId` | normalized provider feedback | callback dedupe | | `NotificationKindId` | business 의미 | policy/catalog lookup | | `NotificationRouteId` | logical technical route | binding lookup | | `NotificationTemplateId` + version | immutable content contract | rendering/replay | | provider message reference | provider-local opaque reference | reconcile/feedback | provider message reference는 `(provider, account/workspace, providerMessageId)`처럼 provider namespace와 함께 저장하며 application aggregate ID로 사용하지 않는다. 최소 R2에서 `NotificationDeliveryId`는 recipient가 아니라 한 provider leg의 identity다. intent의 logical recipient는 정확히 한 명이고, `SINGLE/FAN_OUT_ALL/ORDERED_FALLBACK`이 여러 provider leg를 만들 수 있다. 향후 bulk notification은 `Intent -> RecipientDelivery -> ProviderLeg -> Attempt` 계층을 별도 card로 도입해야 한다. ### 10.2 source operation과 idempotency caller가 자유로운 idempotency string을 만드는 대신 feature가 stable source operation ID와 closed scope를 제공한다. ```text fingerprint = HMAC-SHA-256( purpose = intent-fingerprint, hmacKeyVersion, length-prefixed( tenant, notificationKind, sourceOperationId, recipientCanonicalDigest, semanticParameterDigest, policyRevision ) ) ``` 원칙: - delimiter concatenation을 사용하지 않는다; - raw address/content를 fingerprint column에 넣지 않는다; - secret이 아닌 plain SHA만으로 low-entropy email을 역추측할 수 있게 하지 않는다; - retry마다 새 random intent ID만 생성해 dedupe를 우회하지 않는다; - fingerprint version과 HMAC key version을 저장한다; - 같은 source operation에서 의도적으로 여러 알림이 필요하면 bounded occurrence ID를 semantic input으로 명시한다. HMAC은 purpose별 key와 version을 사용한다. lookup은 `current + bounded retiring keys`의 digest를 계산한다. rolling rotation에서 old writer가 남아 있는 동안 새 owner write는 current와 모든 retiring digest alias를 같은 transaction에 insert한다. 따라서 old/new writer가 경쟁해도 공통 retiring alias unique constraint가 한 owner만 허용한다. old writer drain 뒤에는 current alias만 쓰고, match된 retiring digest는 같은 transaction에서 current-key alias로 승격한다. alias table은 `(scope, purpose, key_version, digest)`와 `(owner_type, owner_id, purpose, key_version)`를 각각 unique로 두며 하나의 alias가 서로 다른 semantic owner를 가리키면 startup/runtime conflict로 막는다. old HMAC key는 suppression, source dedupe, provider-event dedupe, orphan receipt, message-reference lookup과 tombstone이 모두 만료되었거나 current-key alias/re-HMAC migration을 마친 뒤에만 retire한다. 무기한 suppression은 recipient ciphertext를 지우기 전에 current key로 re-HMAC해야 한다. email canonicalization은 local part를 보존하고 domain의 case/IDNA normalization만 exact version으로 정의한다. Gmail식 dot 제거 또는 plus suffix 제거를 generic email에 적용하지 않는다. ### 10.3 frozen plan revision append된 intent에는 다음 immutable snapshot/digest를 보존한다. - notification kind policy revision; - route plan revision; - template ID/version/checksum; - renderer/canonical serialization/escaping revision; - locale/fallback decision; - target count와 target opaque reference; - delivery mode, strategy, attempt/fallback limit; - consent check mode와 expiry; - rendering parameter schema version. provider credential 값이나 full physical endpoint는 snapshot에 저장하지 않는다. 그러나 진행 중 intent가 새 config로 자동 재해석되지 않도록 모든 live/retained intent가 참조하는 plan, provider binding, template, renderer, canonical serialization과 escaping revision을 유지한다. 단순 N/N-1 규칙으로 N-2 backlog를 제거하지 않는다. 삭제하려는 revision에 live/retained intent가 있으면 startup 또는 rollout guard가 차단한다. ## 11. delivery mode ### 11.1 `BEST_EFFORT_INLINE` 정확한 계약: - business transaction commit 뒤 또는 transaction이 없는 명시적 boundary에서 호출한다; - process crash recovery가 없다; - durable retry/receipt를 보장하지 않는다; - provider attempt가 실패해도 feature 정책에 따라 business 결과를 유지할 수 있다; - provider attempt outcome은 관측 가능하게 반환한다; - route가 없으면 fail-fast하며 silent no-op하지 않는다; - critical/durable kind에는 binding할 수 없다. 현재 `FailOpenNotificationProvider`처럼 exception을 삼킨 뒤 `void`로 끝내지 않는다. best-effort 결과는 `InlineCompleted(bounded TargetAttemptOutcome list)`이며 각 target outcome은 §15.3의 submission certainty, retry disposition, fault scope를 그대로 보존한다. best-effort caller가 실패를 business 응답에 반영하지 않더라도 metric/log/audit outcome은 잃지 않는다. 이 mode는 “실패를 무시한다”가 아니라 “delivery를 durable하게 추적하지 않는다는 명시적 선택”이다. ### 11.2 `DURABLE_ASYNC` 정확한 계약: - source business write와 같은 transaction에서 intent append가 성공해야 한다; - append 실패 시 critical feature policy에 따라 business transaction도 실패한다; - append method가 자체 `REQUIRES_NEW` transaction으로 원자성을 깨지 않는다; - commit 뒤 dispatcher가 claim한다; - external send는 DB transaction 밖에서 수행한다; - provider별 attempt와 outcome을 durable하게 기록한다; - retry/expiry/reconciliation/receipt/retention 정책을 가진다; - backlog와 terminal outcome을 query/operate할 수 있다. `APPENDED_DURABLY`는 provider accepted 또는 recipient outcome을 뜻하지 않는다. ### 11.3 mode 선택 권한 mode는 application code의 `NotificationKindPolicy`만 정한다. ```text PASSWORD_RESET_EMAIL -> DURABLE_ASYNC SECURITY_ALERT_SLACK -> DURABLE_ASYNC LOW_VALUE_DEV_HINT_SLACK -> BEST_EFFORT_INLINE ``` inbound request의 `durable=false`, query parameter, route catalog, settings 또는 arbitrary application boolean으로 mode를 선택하거나 바꾸지 않는다. config의 `expected-mode`는 application policy와 일치하는지 검증하는 assertion일 뿐이며 mismatch는 startup failure다. config가 best-effort를 durable로 “강화”하는 것도 transaction sequencing과 business failure 의미를 바꾸므로 금지한다. mode 변경은 application policy revision과 해당 feature use case의 transaction sequence를 함께 변경하고 검증해야 한다. ### 11.4 transaction sequence와 use case capability 현재 `TransactionPort.inWrite`는 `PROPAGATION_REQUIRED`이므로 이미 열린 outer transaction에 참여할 수 있다. 따라서 단순히 `inWrite`가 반환했다는 사실을 physical commit 완료로 간주하면 안 된다. synchronous `InlineCompleted`를 유지하는 최소 R2는 기존 application `TransactionPort`에 `inRootWrite` 계약을 추가한다. 이 port의 persistence implementation은 시작 전에 ambient physical transaction이 없음을 검사하고, 있으면 business write나 provider call 전에 typed `NestedRootTransactionRejectedException`으로 fail-fast한다. ambient transaction이 없을 때만 root REQUIRED transaction을 열고 physical commit이 끝난 뒤 반환한다. ```java CommittedBusinessResult committed = tx.inRootWrite( () -> { BusinessResult saved = repository.save(command); return CommittedBusinessResult.of(saved, factory.create(saved)); }); // physical root commit이 성공한 뒤에만 실행한다. InlineCompleted inline = inlineAttemptUseCase.handle(committed.inlineDraft()); ``` durable kind는 기존 `TransactionPort.inWrite` 안에서 business write와 `NotificationIntentAppendPort.append`를 함께 실행해 caller의 ambient transaction에도 의도적으로 참여한다. append adapter는 `REQUIRES_NEW`를 사용하지 않는다. root transaction rollback/commit failure 또는 ambient transaction rejection이면 best-effort provider call은 0이어야 한다. `outer inWrite -> best-effort feature use case -> outer rollback` 통합 테스트는 provider call 0과 root-boundary rejection을 증명한다. after-commit registration 방식을 향후 도입하면 synchronous `InlineCompleted`를 그대로 재사용하지 않고 별도 `ScheduledAfterCommit` 계약/card로 설계한다. `inRootWrite`의 `TransactionMode`는 여전히 `WRITE`, propagation은 `REQUIRED`, isolation은 `READ_COMMITTED`다. `NEVER` mode/propagation을 추가하지 않고 adapter가 transaction 시작 전 actual ambient transaction precondition을 검사한다. 구현 시 application-core와 persistence-jpa의 `CLAUDE.md`/README, fake port와 transaction fitness/integration test를 함께 갱신해야 한다. dispatcher는 `CommandUseCase`를 구현하고 다음 fitness contract를 선언한다. ```java @UseCaseCapability( transactionMode = TransactionMode.WRITE, idempotency = Idempotency.IDEMPOTENT, repositoryAccess = RepositoryAccess.WRITE_REPOSITORY, externalOutboundAllowed = true) ``` claim과 finalize는 각각 짧은 `TransactionPort.inWrite` 안에서 실행하고 provider call은 그 사이에 transaction 밖에서 실행한다. application에는 `*Port`를 구현하는 policy class를 만들지 않고, inbound use case/application policy/outbound port를 타입과 이름으로 구분한다. ### 11.5 admission class application `NotificationKindPolicy`는 closed `NotificationAdmissionClass`도 선택한다. ```text SECURITY_CRITICAL TRANSACTIONAL BULK_LOW_VALUE ``` 이 값은 intent에 저장되고 claim ordering/admission partition의 입력이 된다. config는 각 class의 concurrency를 낮출 수만 있으며 kind의 class를 바꾸지 못한다. ordering은 `admission class + bounded aging + next_action_at + notification_id`로 deterministic하게 정의하고 낮은 class도 maximum starvation window 안에 기회를 얻어야 한다. ## 12. durable state model ### 12.1 state를 한 column에 합치지 않는다 최소 R2에서 intent 하나는 logical recipient 한 명을 갖고 여러 provider leg와 늦은 feedback을 가질 수 있으므로 다음을 분리한다. ```text NotificationIntent exactly 1 logical recipient 1 -> N NotificationDeliveryLeg 1 -> M immutable NotificationAttempt 0 -> K immutable NotificationReceiptEvent ``` `NotificationDelivery`라는 기존 개념명은 provider leg를 뜻한다. 구현에서는 혼동을 피하기 위해 `NotificationDeliveryLeg`를 우선 사용한다. intent summary는 leg state와 receipt fact에서 파생하는 view/projection이다. provider별 부분 성공, fallback 대기 또는 bounce를 하나의 `SENT` boolean으로 덮지 않는다. ### 12.2 intent state 권장 intent control state: ```text APPENDED ACTIVE COMPLETED PARTIALLY_COMPLETED TERMINAL_FAILED CANCELLED EXPIRED ``` `COMPLETED`는 이름만으로 최종 recipient 성공을 뜻하지 않으며, kind policy가 요구한 submission objective를 만족했다는 뜻이다. 예를 들어 Slack은 `POSTED_TO_CONVERSATION`, transactional email은 `PROVIDER_ACCEPTED` 또는 receipt policy에 따른 `DELIVERED_TO_RECIPIENT_MTA`가 될 수 있다. 서로 다른 기준을 같은 dashboard에서 비교할 때 capability card를 함께 표시한다. 후속 bounce/complaint는 immutable adverse fact/projection으로 함께 노출하며 accepted submission fact를 지우지 않는다. ### 12.3 delivery control state 권장 provider leg state: ```text BLOCKED QUEUED CLAIMED ATTEMPT_RESERVED WIRE_AUTHORIZED PROVIDER_ACCEPTED RETRY_WAIT PARKED_BINDING RECONCILE_WAIT RECONCILING PERMANENTLY_REJECTED SUPPRESSED POLICY_REJECTED TERMINAL_INDETERMINATE CANCELLED EXPIRED ``` `ORDERED_FALLBACK`은 첫 leg만 `QUEUED`, 나머지는 `BLOCKED`로 생성한다. `INDETERMINATE`라는 모호한 transient/terminal 단일 state는 쓰지 않는다. reconcile 가능한 unknown은 `RECONCILE_WAIT/RECONCILING`, horizon이 끝난 unknown은 `TERMINAL_INDETERMINATE`다. receipt는 단일 mutually-exclusive state가 아니라 immutable fact와 직교 projection으로 둔다. ```text SubmissionProjection = UNKNOWN | ACCEPTED | DEFINITELY_REJECTED RecipientTransportProjection = UNKNOWN | DELAYED | MTA_ACCEPTED | BOUNCED | FAILED_AFTER_ACCEPT AbuseProjection = NONE | COMPLAINED ConversationPresenceProjection = NOT_APPLICABLE | POSTED | UNKNOWN ``` provider/card에 없는 projection을 기대하지 않는다. reducer는 verified fact의 도착 순서와 provider timestamp 순서가 달라도 같은 fact set이면 같은 projection을 만드는 order-independent 함수다. complaint는 MTA accepted/bounced와 공존할 수 있고 submission accepted fact를 지우지 않는다. ### 12.4 핵심 transition ```text BLOCKED -> QUEUED [바로 앞 fallback leg가 definite-not-applied로 terminal 되는 같은 transaction] QUEUED / RETRY_WAIT -> CLAIMED(ownerToken, leaseUntil) -> ATTEMPT_RESERVED(attemptExecutionToken) -> WIRE_AUTHORIZED -> PROVIDER_ACCEPTED | RETRY_WAIT | PARKED_BINDING | PERMANENTLY_REJECTED | RECONCILE_WAIT | TERMINAL_INDETERMINATE RECONCILE_WAIT -> RECONCILING -> PROVIDER_ACCEPTED | DEFINITELY_NOT_APPLIED -> RETRY_WAIT | PERMANENTLY_REJECTED | STILL_UNKNOWN -> RECONCILE_WAIT | TERMINAL_INDETERMINATE PROVIDER_ACCEPTED + immutable receipt facts -> orthogonal projections PARKED_BINDING -> QUEUED | EXPIRED | POLICY_REJECTED [audited resume transaction] ``` `WIRE_AUTHORIZED` commit이 provider I/O의 local linearization point다. SDK/client 호출은 이 commit의 성공을 확인한 뒤에만 시작한다. 이 transaction은 expiry, cancellation request, application policy recheck 결과, technical suppression, 모든 route/provider/account admission gate의 expected generation이 `ACTIVE`인지와 attempt budget을 다시 검사한다. cancellation, gate park와 wire authorization 중 먼저 commit된 transition이 이긴다. 외부 consent/preferences store와 이 DB를 원자화할 수 없으므로 recheck 직후 revoke와 실제 send 사이의 race는 제거할 수 없다. 이 한계를 receipt나 exactly-once 표현으로 감추지 않는다. ### 12.5 crash classification | crash/실패 위치 | 복구 판정 | | --- | --- | | claim 뒤 attempt reserve 전 | wire authorization이 없으므로 lease 만료 후 안전하게 re-claim | | `ATTEMPT_RESERVED` 뒤 | wire authorization이 없으므로 안전하게 requeue | | `WIRE_AUTHORIZED` commit 뒤 provider call 전 process kill | maybe-send window로 취급 | | `WIRE_AUTHORIZED` 뒤 response 전 | reconcile 가능하면 `RECONCILE_WAIT`, 아니면 terminal unknown | | provider accepted response 뒤 local projection update 전 | immutable attempt result를 terminal-once 기록한 뒤 projection 재적용 | | local accepted commit 뒤 | accepted 유지, duplicate callback idempotent 적용 | | receipt 저장 뒤 delivery projection update 전 | 같은 transaction이면 함께 rollback; 아니면 inbox 재적용 | delivery claim owner token과 immutable attempt execution token을 분리한다. lease를 잃은 worker도 정확히 받은 provider response를 `(delivery_id, attempt_ordinal)`의 open attempt에 terminal-once로 append할 수 있다. delivery projection은 현재 owner/version CAS로 merge하고, 이미 더 강한 accepted/receipt fact를 stale result로 낮추지 않는다. `WIRE_AUTHORIZED` attempt는 `attempt_deadline + transport drain/finalize grace`가 지나기 전에는 reaper가 retry나 fallback을 활성화하지 않는다. 그 뒤에도 provider card가 definite-not-applied를 증명하지 않으면 reconcile/terminal unknown으로만 이동한다. DB linearization point와 실제 socket write를 원자화할 수 없으므로 exactly-once를 주장하지 않는다. `TERMINAL_INDETERMINATE`는 자동 send/reconcile budget의 종료이지 과거 fact를 지우는 봉인이 아니다. 나중에 도착한 verified receipt 또는 정확한 late provider response는 accepted projection으로 단조롭게 해소할 수 있지만, 이를 이유로 새 physical send를 자동 시작하지 않는다. ### 12.6 fallback atomicity predecessor leg의 attempt 결과가 `submissionCertainty=DEFINITELY_NOT_APPLIED`로 terminal 되는 transaction에서만 정확히 다음 `BLOCKED` leg 하나를 `QUEUED`로 바꾼다. `(notification_id, strategy_group)`당 active fallback leg 최대 1개를 partial unique constraint 또는 동등한 invariant로 강제한다. accepted, bounce, complaint와 terminal indeterminate는 자동 fallback 사유가 아니다. cross-channel escalation은 별도 application intent다. `PARK_BINDING`은 initial kernel에서 fallback을 활성화하지 않는다. parked primary는 active fallback leg로 남아 secondary를 막는다. future exact fallback card가 park 시 chain advance를 원하면 predecessor를 definite-not-applied terminal로 닫고 다음 leg 하나를 같은 transaction에서 활성화하는 별도 reviewed policy/state를 추가해야 한다. ## 13. planning, routing, fan-out과 fallback ### 13.1 application intent와 adapter plan 분리 application은 logical `NotificationRouteId`를 선택한다. adapter의 immutable catalog/compiler가 notification-local route/template/provider capability만 검증해 frozen plan을 만든다. ```text NotificationDeliveryPlan routeId revision channel strategy templateRef/checksum target descriptors required capability attempt/fallback/total-call limits per-attempt deadline retry horizon receipt expectation ``` plan은 provider-neutral application value로 돌아오되 target은 bounded opaque reference다. provider SDK request나 secret/endpoint를 plan에 넣지 않는다. cross-module readiness를 이 compiler 하나에 넣지 않는다. | 검증 소유자 | 제공하는 framework-free descriptor/책임 | | --- | --- | | `adapter-outbound-notification` | route/template/provider local compile 결과와 `NotificationProviderCapabilityDescriptor` | | `adapter-outbound-persistence-jpa` | schema/store/key support, live/retained revision을 담은 `NotificationStoreCapabilityDescriptor` | | `adapter-inbound-web` | callback transport/auth/topology의 `NotificationReceiptIngressDescriptor` | | `application-core` | kind policy와 세 descriptor를 비교하는 pure `NotificationCapabilityCompatibilityValidator` | | `app-bootstrap` | canonical settings를 provider-neutral send/receipt runtime profile로 분할하고 구현체를 조합 | send와 inbound receipt가 공유하는 account/region/configuration-set/topic identity는 bootstrap의 canonical binding 한 곳에서 파생한다. outbound에는 `NotificationProviderRuntimeProfile`, inbound에는 `NotificationReceiptIngressProfile`이라는 최소 slice만 전달하고 두 adapter는 서로 의존하지 않는다. application validator는 secret/SDK/settings type이 없는 descriptor만 받는다. `ApplicationContext` 탐색, bean-name reflection, sibling adapter 직접 호출이나 adapter에서 use case orchestration을 하는 방식은 금지한다. ### 13.2 route strategy ```text RouteStrategy = SINGLE FAN_OUT_ALL ORDERED_FALLBACK ``` - `SINGLE`: exactly one target이 compile되어야 한다. - `FAN_OUT_ALL`: target별 독립 provider leg row를 만들며 partial outcome을 보존한다. - `ORDERED_FALLBACK`: 앞 target이 authoritative definite-not-accepted일 때만 다음 target을 활성화한다. provider list만 써놓고 strategy를 추론하지 않는다. 빈 list, duplicate target, channel mismatch, capability mismatch, cycle 또는 상한 초과는 startup에서 실패한다. ### 13.3 cross-channel fan-out email과 Slack을 모두 보내는 것은 대체로 business escalation/communication policy다. ```text feature application: SECURITY_ALERT_EMAIL intent SECURITY_ALERT_SLACK intent ``` 하나의 outbound adapter route가 임의로 channel을 바꾸거나 email failure 뒤 Slack으로 넘어가지 않는다. cross-channel fallback/escalation은 consent, urgency, duplicate tolerance가 다르므로 application orchestration이 소유한다. ### 13.4 fallback 안전 조건 다음 outcome만 기본 fallback activation을 허용한다. - local validation/rendering에서 provider call 전 definite failure; - admission/quota 정책이 provider call 전 definite rejection을 증명; - provider가 contract상 request를 수락하지 않았음을 명시; - authoritative reconciliation이 not-applied를 반환. 즉 공통 조건은 `submissionCertainty=DEFINITELY_NOT_APPLIED`다. retry 여부나 fault scope만 보고 fallback하지 않는다. 다음은 fallback을 기본 차단한다. - timeout; - connection reset after possible write; - malformed success response; - provider accepted 뒤 local persistence 실패; - provider/card에 reconciliation이 없는 unknown outcome. duplicate가 business적으로 허용되는 escalation은 `allowIndeterminateEscalation` 같은 global boolean이 아니라 검토된 notification-kind policy와 별도 intent로 표현한다. ### 13.5 amplification budget 각 route는 다음 상한을 모두 고정한다. ```text recipientsPerIntent = exactly 1 maxTargetsPerRecipient maxPhysicalAttemptsPerDelivery maxFallbackActivations maxReconcileCalls maxTotalProviderCallsPerIntent maxElapsedRetryHorizon ``` config는 code maximum을 낮출 수만 있다. provider leg 수 × retry × fallback × reconcile의 최악값이 `maxTotalProviderCallsPerIntent`를 넘으면 startup compiler가 거부한다. ## 14. template, rendering과 localization ### 14.1 checked-in immutable template가 baseline이다 R2 baseline은 versioned local template asset을 repository에 둔다. ```text templates/ password-reset/ v3/ ko-KR/ email-subject.txt email-text.txt email-html.html en/ ... schema.json 또는 code descriptor ``` Slack은 JSON string template에 arbitrary substitution하는 방식보다 typed Block Kit model builder를 사용한다. provider-stored SES template는 optional card이며 local rendering과 다른 version/lifecycle 계약을 갖는다. ### 14.2 immutable version - 같은 `(templateId, version, locale, asset)` content를 in-place 수정하지 않는다; - build-time manifest에 SHA-256 checksum, schema version, supported locale과 byte limit을 기록한다; - 변경은 새 version이다; - active intent가 참조한 version은 retention/retry/receipt window 동안 제거하지 않는다; - rollout에서 모든 live/retained intent가 참조하는 asset과 renderer/canonical serialization/escaping revision의 load/checksum을 startup validation한다. ### 14.3 typed parameter 최선은 notification kind별 typed record/factory다. ```java record PasswordResetTemplateParameters( DisplayName displayName, ResetLinkReference resetLink, ExpiryMinutes expiryMinutes) {} ``` 공통 engine boundary가 필요하면 closed scalar/value set만 허용한다. ```text TemplateValue = SafeText TrustedAbsoluteLinkReference LocalDateValue LocalDateTimeValue IntegerValue MoneyValue ``` raw HTML, arbitrary JSON subtree, provider block object, unbounded collection은 기본 parameter가 아니다. template schema는 unknown/missing parameter를 거부하고 unused parameter도 drift로 검출한다. ### 14.4 escaping과 injection - email HTML text와 attribute/URL context를 구분해 escape한다; - email header subject/from/reply-to에는 CR/LF와 control character를 허용하지 않는다; - Slack mrkdwn/plain_text context를 구분한다; - raw ``, `<@user>`, link target 삽입은 별도 allowlisted value type만 허용한다; - untrusted URL은 application이 검증한 opaque link reference에서 adapter가 resolve한다; - template engine의 reflection, arbitrary method/property access, file/network include를 비활성화한다; - output byte/block/element/depth 제한을 provider limit보다 보수적으로 둔다. ### 14.5 locale locale fallback은 JVM default나 host locale을 사용하지 않는다. ```text requested exact locale -> configured language fallback -> notification-kind default locale -> startup-validated default asset ``` 선택된 locale/fallback result는 plan snapshot에 저장한다. timezone이 필요한 값은 business policy가 명시한 zone을 사용하며 server default timezone을 사용하지 않는다. ### 14.6 rendering 시점 durable baseline은 encrypted typed parameters와 frozen template reference를 저장하고 dispatch 직전에 render한다. 장점: - rendered body의 장기 저장을 피한다; - provider별 payload limit/format을 attempt 시점에 적용한다; - key rotation과 redaction surface를 줄인다. 단, asset revision은 frozen이어야 하며 render result digest를 attempt에 남겨 같은 plan의 drift를 검출한다. legal/audit상 exact rendered content 보존이 필요한 kind는 별도 encrypted retention class와 승인을 요구한다. ### 14.7 attachment attachment와 대용량 inline image는 최소 R2 범위가 아니다. 도입 시 fileserver/object-storage opaque reference, malware scan, size/content-type, recipient authorization, provider upload lifecycle을 별도 설계한다. arbitrary byte array나 local path를 notification command에 넣지 않는다. ## 15. provider attempt contract ### 15.1 internal provider SPI provider SPI는 adapter-internal type이며 개념적으로 다음 책임을 갖는다. ```text descriptor() prepare(renderedMessage, target, attemptContext) sendOneAuthorizedAttempt(preparedRequest, attemptExecutionToken, deadline) reconcile(lookupReference, lookupMode, deadline) [optional] ``` `prepare`는 provider validation/size mapping을 수행하되 network side effect를 만들지 않는다. `sendOneAuthorizedAttempt` 한 번은 coordinator 관점의 한 authorized attempt다. 이름이나 구현으로 wire-level exactly-once를 암시하지 않는다. correlation identity를 생성 시점과 의미에 따라 분리한다. ```text AttemptCorrelationId // send 전 생성, opaque/non-PII ProviderClientOperationKey // provider가 native key를 지원할 때만 ProviderMessageReference // accepted response/event 뒤에만 획득 ReconciliationLookupMode // PRE_SEND_CORRELATION | CLIENT_OPERATION_KEY // | MESSAGE_REFERENCE | UNSUPPORTED ``` response-loss에서 아직 없는 `ProviderMessageReference`로 reconcile할 수 있다고 가정하지 않는다. ### 15.2 provider descriptor ```text NotificationProviderDescriptor providerId channel submissionSemantics nativeIdempotencyCapability reconciliationCapability receiptCapability destinationCapability templateCapability hiddenRetryMode maxPayload/recipient constraints supportedCredentialMode preSendCorrelationCapability reconciliationLookupMode ``` descriptor는 marketing label이 아니라 readiness/runtime compiler 입력이다. ### 15.3 attempt outcome provider exception을 그대로 던지거나 모든 exception을 transient로 취급하지 않는다. transmission certainty, retry/운영 조치와 fault scope를 직교 축으로 유지한다. ```text SubmissionCertainty = DEFINITELY_NOT_APPLIED | PROVIDER_ACCEPTED | INDETERMINATE RetryDisposition = RETRY_AT | PARK_BINDING | TERMINAL | NOT_APPLICABLE FaultScope = DELIVERY | ROUTE_REVISION | PROVIDER_BINDING | ACCOUNT ProviderAttemptOutcome( submissionCertainty, retryDisposition, faultScope, stableReasonCode, retryNotBefore?, attemptCorrelationId, providerMessageReference? ) ``` raw response body, raw address, token, SDK exception object는 application으로 나가지 않는다. fallback은 submission certainty만, retry/parking은 retry disposition과 fault scope만 사용한다. 하나의 `permanent` 값으로 invalid recipient와 account credential failure를 합치지 않는다. ### 15.4 error classification | failure | 기본 분류 | | --- | --- | | invalid recipient/content, wire call 전 | `DEFINITELY_NOT_APPLIED + TERMINAL + DELIVERY` | | local template/renderer revision bug | `DEFINITELY_NOT_APPLIED + PARK_BINDING + ROUTE_REVISION` | | local admission/rate-limit 거부, wire call 전 | `DEFINITELY_NOT_APPLIED + RETRY_AT + PROVIDER_BINDING` | | provider explicit throttling이 non-acceptance를 보장 | `DEFINITELY_NOT_APPLIED + RETRY_AT + PROVIDER_BINDING` | | provider auth/scope/config/account rejection이 non-acceptance를 보장 | `DEFINITELY_NOT_APPLIED + PARK_BINDING + PROVIDER_BINDING/ACCOUNT` | | timeout/connection loss after possible write | `INDETERMINATE + NOT_APPLICABLE + DELIVERY` | | success status but response decode/contract 실패 | `INDETERMINATE + NOT_APPLICABLE + DELIVERY` | | provider accepted response | `PROVIDER_ACCEPTED + NOT_APPLICABLE + DELIVERY` | HTTP status 하나만으로 transmission certainty를 일반화하지 않는다. 각 provider card에 exact response/error mapping table과 protocol test를 둔다. `PARK_BINDING`은 `FaultScope`에 대응하는 shared admission gate를 닫고 readiness를 내리며 backlog를 terminal 유실시키지 않는다. ```text NotificationAdmissionGate = (scopeType, scopeRevision) + state = ACTIVE | PARKED + generation + boundedReasonCode/faultScope + parkedAt/resumedAt ``` attempt finalize transaction은 exact outcome fact를 append하고, gate를 expected generation의 `ACTIVE -> PARKED`로 CAS하며, 현재 leg를 `PARKED_BINDING`으로 바꾼다. concurrent park는 idempotent하게 같은/higher generation을 관측한다. 다른 node의 eligible scan과 `WIRE_AUTHORIZED` transaction은 route/provider/account gate가 모두 ACTIVE일 때만 진행하므로 restart/multi-instance에서도 park가 유지되고 hot-loop하지 않는다. gate park보다 먼저 `WIRE_AUTHORIZED`를 commit한 attempt는 이미 권한을 얻었으므로 bounded completion/indeterminate protocol을 따른다. park는 새 wire authorization을 막지만 이미 시작한 provider side effect를 recall한다고 주장하지 않는다. route revision 수정 또는 credential/account 복구 후 audited resume use case만 readiness/config를 재검증하고 generation을 증가시켜 ACTIVE로 바꾼다. 같은 transaction/bounded batch에서 parked leg의 expiry, cancellation, policy/suppression과 attempt budget을 다시 판단해 `QUEUED`, `EXPIRED` 또는 `POLICY_REJECTED`로 이동한다. initial R2는 park를 fallback activation으로 해석하지 않는다. ### 15.5 retry ownership coordinator가 다음을 소유한다. - attempt authorization; - attempt ordinal과 total count; - absolute attempt deadline; - retry horizon/expiry; - full-jitter backoff; - bounded provider `Retry-After`; - provider/card별 definite/indeterminate 분류; - fallback activation; - reconciliation budget. SDK default retry는 baseline에서 끈다. SDK를 끌 수 없으면 callback/interceptor로 모든 physical wire attempt가 attempt journal과 total budget에 계수됨을 증명할 때만 provider card를 승인한다. `AttemptCorrelationId`와 지원되는 `ProviderClientOperationKey`는 같은 attempt 동안 안정적으로 유지한다. `ProviderMessageReference`는 응답/event가 준 뒤에만 저장한다. provider의 documented idempotency retention보다 local retry horizon이 길면 그 조합은 safe-retry capability가 아니다. ### 15.6 cancellation deadline/cancellation은 local wait를 멈추는 신호이지 provider side effect rollback 증거가 아니다. wire call 시작 뒤 cancellation되면 card가 definite-not-sent를 증명하지 않는 한 `TERMINAL_INDETERMINATE` 또는 reconcile path다. thread interrupt만으로 “전송되지 않음”을 주장하지 않는다. ## 16. durable persistence와 worker protocol ### 16.1 기준 topology 최소 R2는 business source-of-truth와 notification journal이 같은 PostgreSQL transaction manager에 참여할 수 있다는 가정에 기반한다. ```text business application transaction -> business state write -> NotificationIntentAppendPort -> intent + frozen deliveries insert -> commit dispatcher -> claim in short DB transaction -> commit claim -> reserve attempt and commit WIRE_AUTHORIZED after final eligibility recheck -> render/provider call outside DB transaction -> append attempt result terminal-once -> merge delivery projection in short token/version-guarded transaction ``` business DB와 journal DB가 다르면 이 원자성은 성립하지 않는다. 그 경우 generic outbox -> broker -> inbound consumer/inbox topology를 별도 설계하고 현재 R2 baseline이라고 부르지 않는다. `NotificationIntentAppendPort` 구현은 caller의 REQUIRED transaction에 참여하고 `REQUIRES_NEW`를 사용하지 않는다. `NotificationDispatchUseCase`의 capability/transaction shape는 §11.4를 정본으로 한다. ### 16.2 `notification_intent` 개념 column: ```text notification_id tenant_scope_digest notification_kind channel route_id mode admission_class source_operation_digest source_operation_hmac_key_version idempotency_key_digest/key_version intent_fingerprint intent_fingerprint_key_version policy_revision route_plan_revision template_id/version/checksum renderer/serialization/escaping_revision locale recipient_ciphertext/nonce/algorithm/key_ref/key_version parameter_ciphertext/nonce/algorithm/key_ref/key_version not_before expires_at retention_class created_at summary_state/version ``` 원칙: - immutable ciphertext와 crypto metadata를 우선한다; - summary state는 delivery에서 검증 가능한 projection이다; - same idempotency digest + same fingerprint는 기존 intent를 반환한다; - same idempotency digest + different fingerprint는 permanent mismatch다; - recipient/content plaintext index를 만들지 않는다. ### 16.3 `notification_delivery_leg` ```text delivery_id notification_id target_ordinal strategy_group/strategy_ordinal opaque_target_ref provider_binding_revision state submission_projection recipient_transport_projection abuse_projection conversation_presence_projection claim_owner_token claim_lease_until row_version next_action_at attempt_count reconcile_count attempt_correlation_digest/key_version provider_client_operation_key_digest/key_version [optional] provider_message_reference_ciphertext/nonce/key_ref/key_version [optional] provider_message_reference_digest/hmac_key_version [optional] last_reason_code accepted_at terminal_at ``` 이 row는 recipient row가 아니라 provider leg다. fan-out target마다 별도 row를 만든다. fallback target은 처음부터 frozen하되 첫 target만 `QUEUED`, 나머지는 `BLOCKED`로 둔다. `PARKED_BINDING`은 `next_action_at=null`이며 gate가 audited resume되기 전 eligible scan에 나타나지 않는다. ### 16.4 `notification_attempt` append 중심의 physical evidence: ```text attempt_id delivery_id attempt_ordinal attempt_execution_token reserved_at wire_authorized_at attempt_deadline transport_finalize_grace_until completed_at render_hmac/hmac_key_version provider_binding_revision credential_generation authorized_admission_gate_generations transmission_phase submission_certainty retry_disposition fault_scope stable_reason_code attempt_correlation_id provider_client_operation_key_digest/key_version [optional] provider_message_reference_ciphertext/digest/key_versions [optional] ``` raw provider payload/error response는 저장하지 않는다. credential value가 아니라 bounded generation/reference만 기록한다. execution token별 exact provider response/result fact는 최대 하나만 기록한다. reaper의 deadline-expired/unknown observation은 별도 immutable fact이며 exact response slot을 선점하지 않는다. ### 16.5 `notification_receipt_event` ```text receipt_event_id provider provider_account_scope outer_transport_message_id_digest/hmac_key_version provider_event_id_digest/hmac_key_version semantic_event_fingerprint/hmac_key_version attempt_correlation_digest/hmac_key_version [optional] provider_message_reference_digest/hmac_key_version [optional] normalized_event_type provider_occurred_at server_received_at verification_key_revision state = ORPHAN | APPLIED | DUPLICATE | CONFLICT | QUARANTINED encrypted_short_lived_evidence [optional] retention_deadline ``` callback이 provider accepted DB update보다 먼저 도착할 수 있으므로 매칭되지 않은 verified receipt를 버리지 않는다. `ORPHAN` inbox에 bounded하게 저장하고 later attach한다. ### 16.6 technical suppression table email hard bounce/complaint 등 provider lifecycle로 생긴 suppression은 별도 table/port로 둔다. ```text channel recipient_hmac/key_version scope reason source_provider effective_at expires_at/null evidence_ref version ``` business unsubscribe/consent와 합치지 않는다. dispatch 전에 application business eligibility와 technical suppression을 각각 평가한다. ### 16.7 `notification_admission_gate` multi-instance park/resume의 정본은 process memory나 health cache가 아니라 같은 PostgreSQL의 shared table이다. ```text scope_type = ROUTE_REVISION | PROVIDER_BINDING | ACCOUNT scope_revision state = ACTIVE | PARKED generation fault_scope bounded_reason_code parked_at resumed_at row_version ``` `(scope_type, scope_revision)`이 PK다. provider leg는 frozen route/provider/account scope를 통해 필요한 gate를 결정한다. claim eligibility query는 모든 관련 gate가 ACTIVE인 row만 고르고, `WIRE_AUTHORIZED` CAS는 읽은 gate generation이 그대로 ACTIVE인지 다시 검증한다. ### 16.7.1 route writer fence와 legacy permit rolling cutover 중 legacy synchronous send와 canonical intent admission이 같은 route를 동시에 받지 않도록, 같은 PostgreSQL에 route writer fence와 bounded legacy permit을 둔다. process-local boolean이나 배포 순서만으로 single-writer를 주장하지 않는다. `notification_route_writer_fence`: ```text route_revision PK owner = LEGACY | CANONICAL state = ACTIVE | DRAINING generation row_version last_operation_token [optional denormalized FK] draining_started_at [optional] switched_at [optional] ``` `notification_writer_operation`: ```text operation_token PK operation_sequence UNIQUE [shared cutover sequence, DB-assigned after route fence/global lock] action = INITIALIZE_LEGACY | INITIALIZE_CANONICAL_FRESH | BEGIN_DRAIN | TERMINALIZE_EXPIRED_PERMITS | COMPLETE_SWITCH | ABORT_DRAIN route_set_digest request_input_digest [server-canonical, never caller-supplied] fresh_installation_provenance_token [INITIALIZE_CANONICAL_FRESH only] actor_digest reason_code recorded_at [post-lock clock_timestamp(); observation only, not physical commit time] ``` `notification_writer_operation_route`: ```text operation_token FK route_revision expected_owner [server-derived; optional only for the two INITIALIZE actions] expected_generation [optional only for the two INITIALIZE actions] result_owner result_state result_generation drain_begin_operation_token [required for TERMINALIZE/COMPLETE/ABORT] reviewed_old_node_count [BEGIN_DRAIN only] reviewed_old_node_set_digest [BEGIN_DRAIN only] reviewed_inventory_manifest_digest [BEGIN_DRAIN only] transport_proof_requirement = HARD_BOUND_PROVEN | QUIESCENCE_REQUIRED transport_proof_registry_digest quiescence_attestation_token [optional except required COMPLETE for unproven transport] blocking_permit_set_digest [paired with attestation] affected_permit_count [TERMINALIZE_EXPIRED_PERMITS only] affected_permit_set_digest [TERMINALIZE_EXPIRED_PERMITS only] requested_batch_bound [TERMINALIZE_EXPIRED_PERMITS only] PK (operation_token, route_revision) ``` `notification_writer_transport_proof_registry`: ```text route_revision transport_profile_revision admission_role = ACTIVE | RETIRING transport_proof_class = HARD_BOUND_PROVEN | QUIESCENCE_REQUIRED transport_proof_evidence_revision route_registry_digest initialization_operation_token created_at PK (route_revision, transport_profile_revision) FK (initialization_operation_token, route_revision) -> notification_writer_operation_route(operation_token, route_revision) ``` `notification_route_writer_permit`: ```text permit_token PK route_revision FK owner = LEGACY fence_generation transport_profile_revision transport_proof_class = HARD_BOUND_PROVEN | QUIESCENCE_REQUIRED transport_proof_evidence_revision state = ACTIVE | RELEASED | EXPIRED_PROVEN | TIMED_OUT_UNPROVEN holder_instance_digest acquired_at wire_deadline_at expires_at released_at [optional] terminalized_at [optional] terminalization_operation_token [required for EXPIRED_PROVEN | TIMED_OUT_UNPROVEN] row_version ``` `notification_writer_quiescence_attestation`: ```text attestation_token PK attestation_sequence UNIQUE [same cutover sequence, assigned after route fence lock] route_revision draining_fence_generation drain_begin_operation_token canonical_signed_payload canonical_signed_payload_digest signature_algorithm = ED25519 detached_signature issuer_identity_digest issuer_key_revision issuer_public_key_spki issuer_public_key_digest trust_snapshot_canonical_payload trust_snapshot_digest acceptance_window_profile_revision allowed_clock_skew_ms acceptance_margin_ms issued_at expires_at server_verified = true server_verified_at server_verifier_revision environment_identity_digest database_system_identifier_digest database_identity_digest pre_artifact_digest deployment_revision_digest transport_profile_set_digest transport_proof_registry_digest blocking_permit_count blocking_permit_set_digest permit_holder_count permit_holder_set_digest consumer_inventory_identity_digest consumer_inventory_snapshot_digest consumer_count = 0 old_node_count old_node_set_digest [server-derived from the frozen BEGIN inventory] old_nodes_quiesced_and_irreversibly_fenced = true old_node_fence_evidence_set_digest provider_call_ledger_identity_digest provider_call_ledger_snapshot_digest provider_call_ledger_open_count = 0 quiescence_evidence_manifest_digest evidence_digest actor_digest reason_code observed_at ``` attestation은 일반 switch request body의 boolean이 아니다. authenticated `POST /api/admin/notifications/routes/{routeId}/writer-quiescence-attestations`가 `notification:cutover-attest` permission의 method-security-proxied application operation을 호출한다. request는 reviewed drain generation, opaque attestation token과 독립 deployment inventory issuer가 서명한 quiescence evidence manifest를 제공할 뿐 old-node/permit/zero-fact digest를 권위 있게 주장하지 못한다. manifest는 exact environment/DB identity, route/drain generation, PRE artifact/deployment revision, BEGIN에서 동결한 complete bridge-node inventory, 각 node의 retired/quiesced fact와 재시작을 막는 deployment-generation tombstone 및 legacy credential/egress의 irreversible revocation, production consumer inventory identity/snapshot/count 0, provider-call ledger identity/snapshot/open-count 0, 발급/만료 시각과 issuer/trust snapshot, acceptance-window profile revision, bounded `allowed_clock_skew_ms`와 minimum `acceptance_margin_ms`를 함께 서명한다. server는 reviewed trust catalog로 Ed25519를 검증한 뒤 actor, post-lock DB `clock_timestamp()`인 `server_verified_at`이 `issued_at - allowed_clock_skew <= server_verified_at <= expires_at - acceptance_margin`인 bounded acceptance window, persisted transport-proof registry, locked permit set/count/digest와 permit의 distinct `holder_instance_digest` set을 derive한다. 검증한 canonical payload bytes, detached signature, issuer identity/key, bounded canonical public-key SPKI bytes/digest, canonical trust-snapshot payload/digest, issued/expiry/server-verified metadata를 summary와 같은 immutable root row에 함께 보존한다. frozen BEGIN inventory와 manifest node set은 exact equality여야 하고 permit holder set은 그 inventory의 subset이어야 한다. omitted permit holder, extra/omitted inventory node, caller-only digest, unknown issuer/key/environment/DB/artifact/consumer-inventory/provider-ledger identity 또는 snapshot은 mutation 없이 거부한다. PRE composition/readiness는 registry와 compiled cutover catalog의 exact equality를 먼저 검증한다. attestation 기록 시 ACTIVE permit가 남은 상태, nonzero/false fact, acceptance window 밖의 authorization, token mismatch는 root transaction에서 mutation 없이 거부하며 success response는 physical commit 뒤에만 쓴다. `notification_writer_drain_inventory_manifest`는 BEGIN에서 검증한 signed inventory manifest의 retained header다. ```text drain_begin_operation_token route_revision expected_fence_generation canonical_signed_payload canonical_signed_payload_digest signature_algorithm = ED25519 detached_signature issuer_identity_digest issuer_key_revision issuer_public_key_spki issuer_public_key_digest trust_snapshot_canonical_payload trust_snapshot_digest acceptance_window_profile_revision allowed_clock_skew_ms acceptance_margin_ms issued_at expires_at server_verified = true server_verified_at server_verifier_revision environment_identity_digest database_system_identifier_digest database_identity_digest pre_artifact_digest deployment_revision_digest consumer_inventory_identity_digest consumer_inventory_snapshot_digest provider_call_ledger_identity_digest provider_call_ledger_snapshot_digest old_node_count old_node_set_digest inventory_manifest_digest PK (drain_begin_operation_token, route_revision) FK (drain_begin_operation_token, route_revision) -> notification_writer_operation_route(operation_token, route_revision) ``` `notification_writer_drain_node_inventory`는 BEGIN에서 server가 검증한 complete old-writer inventory를 digest만이 아니라 row set으로 동결한다. ```text drain_begin_operation_token route_revision node_instance_digest transport_profile_revision deployment_revision_digest credential_generation_digest inventory_manifest_digest inventory_issuer_key_revision PK (drain_begin_operation_token, route_revision, node_instance_digest) FK (drain_begin_operation_token, route_revision) -> notification_writer_drain_inventory_manifest(drain_begin_operation_token, route_revision) ``` BEGIN request는 caller-written node digest 대신 short-lived signed inventory manifest와 opaque token을 전달한다. 별도 trusted deployment inventory issuer가 exact environment/DB/route, PRE artifact/deployment revision, complete bridge-node set, consumer inventory identity/snapshot과 provider-call ledger identity/snapshot을 서명한다. application verifier가 Ed25519 signature/key revision/trust snapshot/acceptance window와 compiled PRE deployment identity를 검증하고 canonical row set/count/digest를 만든다. BEGIN child, exact 한 retained manifest header와 모든 inventory row는 fence CAS와 같은 root transaction에서 insert되며 UPDATE/DELETE가 금지된다. old-node set이 0개여도 count 0과 canonical empty-set digest를 가진 signed header 한 건은 반드시 보존한다. 따라서 node row 0개는 허용하지만 BEGIN header 0개는 허용하지 않는다. manifest에 없지만 permit history에 나타나는 holder, manifest의 duplicate/unknown node/profile, 서명·환경·DB·artifact/consumer-inventory/provider-ledger identity 또는 snapshot mismatch는 BEGIN 또는 attestation을 fail closed한다. inventory manifest도 signed acceptance-window profile/skew/margin을 같은 방식으로 검증하며, negative/out-of-policy bound, unknown profile 또는 minimum remaining validity 미달은 mutation 없이 거부한다. “quiesced”는 순간적인 process count 0이 아니다. quiescence manifest의 각 node는 deployment control-plane이 그 exact instance/deployment generation의 재시작을 금지한 tombstone과 old transport credential generation 또는 egress identity의 irreversible revocation을 함께 가져야 한다. canonical credential을 공유해 독립적으로 폐기할 수 없거나 revocation을 되돌릴 수 있거나 paused process가 기존 credential/connection으로 다시 provider I/O를 시작할 수 있으면 issuer는 서명할 수 없고 route는 `QUIESCENCE_REQUIRED/NOT_QUALIFIED`로 DRAINING에 남는다. `notification_writer_quiescence_node_evidence`는 verified manifest에서 parse한 per-node irreversible fence를 보존한다. ```text attestation_token route_revision drain_begin_operation_token node_instance_digest deployment_generation_tombstone_digest legacy_credential_or_egress_revocation_digest node_evidence_digest PK (attestation_token, route_revision, node_instance_digest) FK (attestation_token, route_revision) -> attestation FK (drain_begin_operation_token, route_revision, node_instance_digest) -> notification_writer_drain_node_inventory ``` 이 row set의 node key는 BEGIN inventory와 exact equality이고 canonical sorted digest는 attestation의 `old_node_fence_evidence_set_digest`와 같아야 한다. attestation root transaction만 insert하며 UPDATE/DELETE를 금지한다. overall manifest digest만 저장하고 per-node revocation coverage를 버리지 않는다. 두 manifest는 raw JSON serialization을 서명하지 않는다. domain-separated `writer-inventory-manifest-v1` / `writer-quiescence-manifest-v1` length-prefixed canonical field encoding과 sorted bounded node/fact row set을 Ed25519로 서명하며, verifier는 unknown/duplicate field, non-canonical order/encoding, oversized set과 algorithm/key downgrade를 거부한다. node, environment와 ledger identity는 opaque digest이고 PII/credential을 포함하지 않는다. issuer key ID나 request가 동봉한 임의 public key는 trust anchor가 아니다. reviewed artifact의 closed trust catalog가 허용 key ID, bounded canonical Ed25519 SPKI bytes/digest, trust-snapshot digest, current/retiring issuance window와 historical-verification `ALLOW|REVOKED` 판정을 고정한다. write verifier는 retained SPKI bytes의 digest와 catalog material을 대조한 뒤 그 key로 signature를 검증한다. startup/COMPLETE verifier도 retained SPKI bytes로 signature를 다시 검증하고 현재 closed catalog가 exact issuer/key/trust-snapshot digest를 historical `ALLOW`로 승인하는지 별도로 확인한다. 둘 중 하나라도 실패하거나 catalog가 `REVOKED`면 fail closed한다. inventory/quiescence issuer는 application 운영 주체와 분리된 external infrastructure authority다. production artifact, container, database와 environment에는 issuer private key를 두지 않는다. deterministic local issuer는 test fixture와 `LOCAL_TEST` evidence grade에서만 허용하고 production cutover/readiness는 거부한다. inventory/attestation의 `issued_at..expires_at`은 evidence를 처음 수락할 수 있는 창이지, 이미 수락한 irreversible fact의 임대 시간이 아니다. Java write verifier가 그 창 안에서 서명과 trust snapshot을 검증하고 immutable header/row set을 root-commit한 뒤에는 inventory snapshot과, attestation의 deployment-generation tombstone, legacy credential/egress irreversible revocation, consumer inventory 0과 provider-call ledger 0 snapshot은 시간이 지나도 당시의 불변 사실로 남는다. `COMPLETE_SWITCH`는 stored canonical payload/signature를 Java에서 다시 Ed25519 검증하고 BEGIN inventory와 per-node irreversible fence exact equality, DRAINING이라 새 permit을 만들 수 없는 상태와 ACTIVE permit 0, selected registry/ledger identity 및 snapshot equality를 같은 root transaction에서 lock/recompute한다. attestation의 현재 만료 여부를 다시 묻지 않으며, constraint timing을 바꾸는 `SET CONSTRAINTS`로 우회할 correctness dependency도 존재하지 않는다. CAS, operation append와 결과는 한 physical commit으로 원자화하고 acknowledgement 뒤에만 success를 반환한다. `notification_fresh_installation_provenance`는 empty database가 “legacy가 존재한 적 없는 fresh provisioning”임을 입증하는 별도 immutable authority다. 순간적인 zero snapshot만으로는 이 authority를 만들 수 없다. external infrastructure issuer는 먼저 exact database resource와 birth certificate를 대상으로 영구적이고 비가역적인 control-plane `no-legacy-authority fence`를 다음 순서로 완성해야 한다. 1. 모든 reviewed legacy deployment generation deny/tombstone, 해당 DB와 provider credential의 legacy-scoped 신규 발급 disable과 기존 legacy credential revoke, legacy DB ingress와 provider egress의 established-flow 차단을 먼저 irreversible enforcement revision으로 commit하고 read-after-write한다. 2. 그 enforcement가 활성화된 뒤 legacy identity의 existing DB session과 provider connection/flow를 강제 종료한다. application workload/business consumer/legacy node inventory, DB open session, provider open flow와 provider-call ledger entry/open count가 모두 0인 post-enforcement manifest를 관측한다. 각 source evidence는 fence token과 enforcement revision을 참조하고 그 read-back보다 같거나 뒤인 causal revision/time을 가져야 한다. provider ledger snapshot cut은 모든 flow termination acknowledgement보다 뒤여야 하고 accepted/pending/indeterminate count가 모두 0이어야 한다. provider가 그 authoritative settled cut을 증명하지 못하면 fresh authorization을 발급하지 않는다. 3. issuer control-plane ledger가 위 enforcement와 post-enforcement zero/termination manifest를 하나의 permanent fence token/revision/digest로 seal-commit하고 `committed_at`/`irreversible=true`를 read-after-write한다. 그 뒤에만 DB-birth authorization을 서명한다. zero 관측 뒤 deny를 활성화하거나, enforcement와 zero manifest 사이의 causal binding이 없는 snapshot을 사후 조합하는 것은 금지한다. credential revoke만으로 cached authority를 회수했다고 추론하지 않으며 fence를 해제하거나 같은 resource에 legacy authority를 다시 발급하는 operation은 존재하지 않는다. 재시도가 필요하면 새 database resource와 새 birth certificate를 사용한다. 따라서 authorization 발급과 DB provisioning commit 사이에 old application workload나 legacy node가 시작·재개해 DB/provider authority를 다시 얻거나 cached session/connection을 재사용할 수 없고, paused old client가 resume해도 provider I/O는 0이다. ```text provenance_token PK fresh_initialization_operation_token UNIQUE canonical_signed_payload canonical_signed_payload_digest signature_algorithm = ED25519 detached_signature issuer_identity_digest issuer_key_revision issuer_public_key_spki issuer_public_key_digest trust_snapshot_canonical_payload trust_snapshot_digest acceptance_window_profile_revision allowed_clock_skew_ms acceptance_margin_ms issued_at expires_at server_verified = true server_verified_at server_verifier_revision database_resource_canonical_payload database_resource_identity_digest database_birth_certificate_canonical_payload database_birth_certificate_digest database_system_identifier_digest database_identity_digest schema_identity_digest environment_identity_digest final_artifact_digest canonical_route_set_digest application_workload_inventory_count = 0 application_workload_inventory_digest business_consumer_inventory_count = 0 business_consumer_inventory_digest legacy_node_inventory_count = 0 legacy_node_inventory_digest provider_call_ledger_identity_digest provider_call_ledger_snapshot_digest provider_call_ledger_snapshot_cut_revision provider_call_ledger_snapshot_cut_at provider_call_ledger_entry_count = 0 provider_call_ledger_open_count = 0 provider_call_ledger_indeterminate_count = 0 no_legacy_authority_fence_token no_legacy_authority_fence_revision no_legacy_authority_fence_canonical_payload no_legacy_authority_fence_digest no_legacy_authority_fence_committed_at no_legacy_authority_fence_read_back_at no_legacy_authority_fence_irreversible = true no_legacy_authority_enforcement_revision no_legacy_authority_enforcement_digest no_legacy_authority_enforcement_activated_at no_legacy_authority_enforcement_read_back_at post_enforcement_zero_manifest_canonical_payload post_enforcement_zero_manifest_digest post_enforcement_zero_observation_revision post_enforcement_zero_observed_at legacy_deployment_generation_deny_set_digest legacy_deployment_generation_tombstone_set_digest legacy_database_credential_issuance_disabled = true legacy_database_credential_revocation_set_digest legacy_database_credential_revocation_complete = true legacy_database_session_inventory_digest legacy_database_session_open_count = 0 legacy_database_session_termination_evidence_digest legacy_database_ingress_denied = true legacy_database_ingress_denial_policy_digest legacy_database_ingress_blocks_established_flows = true legacy_provider_credential_issuance_disabled = true legacy_provider_credential_revocation_set_digest legacy_provider_credential_revocation_complete = true legacy_provider_connection_flow_inventory_digest legacy_provider_connection_flow_open_count = 0 legacy_provider_connection_flow_termination_evidence_digest provider_egress_denied = true provider_egress_denial_policy_digest provider_egress_blocks_established_flows = true authorization_digest ``` `notification_writer_finalization_discriminator`는 FINAL database의 closed state를 보존한다. ```text singleton_key = NOTIFICATION_FINALIZATION state = AWAITING_SIGNED_FRESH_PROVISIONING | FRESH_PROVISIONED | UPGRADE_VALIDATED fresh_provenance_token [FRESH_PROVISIONED only] validated_upgrade_history_digest [UPGRADE_VALIDATED only] state_operation_token row_version ``` V8은 schema/history 생성 전의 provenance를 요구하거나 생성하지 않는다. migration은 먼저 schema shape를 additive하게 만든 뒤 다음 두 입력만 분류한다. - complete upgrade history와 exact canonical fence set이 있으면 전체 retained history를 structural validation하고 그대로 보존한 뒤 discriminator를 `UPGRADE_VALIDATED`와 validated history digest로 원자 기록한다; - notification control/data-plane table과 fence/journal/provenance가 완전히 비었으면 canonical fence나 initialization history를 seed하지 않고 discriminator만 `AWAITING_SIGNED_FRESH_PROVISIONING`으로 둔다. V8 재실행은 이 singleton과 나머지 empty state만 idempotent하게 허용한다. fence가 없는데 다른 notification row가 하나라도 있거나 partial/extra fence, incomplete operation history, provenance 선행 삽입 또는 discriminator와 store shape mismatch가 있으면 migration은 fail closed한다. V8 SQL은 payload/signature의 non-null·bounded length, digest/count/FK, nonnegative reviewed skew/margin과 `no_legacy_authority_enforcement_activated_at <= no_legacy_authority_enforcement_read_back_at <= provider_call_ledger_snapshot_cut_at <= post_enforcement_zero_observed_at <= no_legacy_authority_fence_committed_at <= no_legacy_authority_fence_read_back_at <= issued_at`, `issued_at - allowed_clock_skew <= server_verified_at <= expires_at - acceptance_margin` 같은 structural integrity만 검증한다. SQL이 Ed25519 또는 trust validity를 검증했다고 주장하지 않는다. 별도 post-migration deployment operation `notificationFreshProvisioning`만 `AWAITING_SIGNED_FRESH_PROVISIONING`을 끝낼 수 있다. normal runtime startup이 아니라 격리된 provisioning job이 위 fence를 먼저 commit한 independent infrastructure issuer의 short-lived signed DB-birth authorization을 받는다. domain-separated `notification-fresh-provisioning-v1` canonical payload는 exact database resource와 birth certificate, environment/DB-system/database/schema identity, FINAL artifact digest, canonical route set, application workload/business consumer/legacy node inventory 각각의 count 0과 canonical empty-set digest, provider-call ledger identity/snapshot/cut revision/time과 entry/open/indeterminate count 0, nonce, deterministic operation token, acceptance-window profile revision, bounded allowed clock skew와 minimum remaining validity margin을 묶는다. 또한 no-legacy-authority fence의 token/revision/canonical payload/digest/committed-at/read-back-at/`irreversible=true`, legacy deployment-generation deny/tombstone set, DB/provider credential issuance-disable와 revocation set, legacy DB-session termination/open-count 0과 established-flow-blocking ingress policy, legacy provider connection/flow termination/open-count 0과 established-flow-blocking provider-egress deny policy를 모두 묶는다. irreversible enforcement revision/digest/activated-at/read-back-at과, 그 revision 뒤의 zero/termination manifest canonical payload/digest/observation revision/observed-at도 묶는다. 위 필드는 signed payload와 retained provenance 양쪽에 exact value로 보존하며 digest만 남기고 원본 authority를 버리지 않는다. Java verifier는 각 inventory/session/flow/ledger source evidence가 exact fence token과 enforcement revision의 causal descendant인지, 위 timestamp 순서와 final fence revision/token read-back이 일치하는지 검증해 pre-fence zero snapshot, sign-before-seal과 cross-revision 조합을 거부한다. 여기서 credential disable/revoke와 ingress/egress deny의 namespace는 signed legacy deployment-generation set이다. reviewed provisioner와 이후 canonical runtime identity를 legacy authority로 분류하거나 그 credential 발급을 암묵적으로 허용/차단하지 않는다. provisioning은 하나의 physical connection과 하나의 provisioner root transaction에서 반드시 다음 순서로 실행한다. 1. exact `SECURITY DEFINER` snapshot/read-lock function `notification_fresh_provisioning_snapshot_and_lock`이 finalization discriminator, notification control/data-plane emptiness, DB resource/system/database/schema identity와 existing same-token result를 lock하고 bounded typed snapshot 및 DB-computed semantic digest를 반환한다. 이 function은 mutation하지 않으며 lock은 physical commit/rollback까지 유지된다. 2. Java `NotificationFreshProvisioningAuthorizationVerifierPort` 구현이 같은 transaction을 열린 채 domain-separated canonical payload, Ed25519 signature, retained issuer SPKI bytes/digest, artifact closed trust snapshot, issuer/key historical policy, birth certificate와 committed irreversible fence evidence를 검증하고, 1단계 snapshot과 signed semantic value의 exact equality를 확인한다. 3. exact `SECURITY DEFINER` apply function `notification_fresh_provisioning_apply`가 같은 connection/transaction에서만 호출된다. apply는 1단계 lock ownership과 snapshot digest, new-mutation branch의 AWAITING discriminator와 store emptiness, DB identity, operation token 및 canonical payload semantic digest를 DB-owned value로 다시 계산·비교한다. 이어 새 `clock_timestamp()` 값을 한 번 읽어 `issued_at - allowed_clock_skew <= apply_now <= expires_at - acceptance_margin`을 다시 검증하고, 그 exact `apply_now`를 provenance의 `server_verified_at`과 `INITIALIZE_CANONICAL_FRESH` operation header의 `recorded_at`에 함께 저장한다. 그 뒤에만 retained provenance, 모든 route child, reviewed initial `ACTIVE/CANONICAL` fence를 insert하고 discriminator를 `FRESH_PROVISIONED`로 전이한다. Java 검증 뒤 process가 pause되어 acceptance window를 벗어나면 3단계 fresh DB-time 검사가 DML 전에 실패하고 root transaction 전체가 rollback되어 mutation은 0이다. apply statement가 성공한 시점이 DB-birth authorization 수락의 linearization point이고 row visibility/durability는 physical commit에서 생긴다. apply 뒤 commit이 지연되어 window가 지나더라도 issuer가 서명 전에 commit한 birth/fence fact가 영구·비가역이고 그 사이 legacy authority가 부활할 수 없으므로 safety는 유지된다. commit failure는 mutation과 success response가 0이며 success는 physical commit acknowledgement 뒤에만 반환한다. rollback 뒤 retry는 두 함수와 Java 검증을 처음부터 다시 거친다. 같은 token/input replay는 stored result를 반환하고 token/input/identity/payload-digest mismatch는 mutation 없이 실패한다. same-token committed replay는 새 authorization acceptance가 아닌 read-only result recovery branch다. snapshot function이 exact `FRESH_PROVISIONED` discriminator/provenance/init/fence equality를 lock/recompute하고 Java가 stored signature/trust/semantic fact와 original `server_verified_at` acceptance를 다시 검증한 경우에만 apply가 mutation 없이 stored result를 반환한다. 이 branch는 current wall-clock expiry를 다시 적용하지 않는다. 기존 result가 없는 첫 apply, partial/mixed result 또는 다른 input/token은 반드시 new-mutation branch를 타거나 실패하므로 Java 검증 뒤 expiry pause를 우회하지 못한다. SQL은 lock, identity, state, exact-set/digest, time-window와 atomic write의 structural authority일 뿐 Ed25519/trust authority가 아니다. cryptographic authority는 Java verifier port에 있다. provisioner principal은 위 두 함수의 `EXECUTE`만 가지며 notification table의 generic `SELECT|INSERT|UPDATE|DELETE`, sequence `USAGE`, DDL과 다른 function `EXECUTE`는 모두 0이다. provisioner credential을 탈취한 주체가 Java 검증을 건너뛰고 structurally well-formed지만 forged/invalid payload로 apply를 직접 호출해 row를 commit하더라도 그 row 자체는 readiness authority가 아니다. 아래 mandatory FINAL read use case의 Java Ed25519/trust/semantic 재검증이 성공하기 전에는 readiness, canonical admission, worker와 provider I/O가 모두 0이고 mismatch는 dark/`NOT_QUALIFIED`다. 반대로 valid signed authorization을 직접 apply하더라도 서명 전에 commit된 irreversible fence fact와 payload가 이미 결합되어 있으며 apply의 lock/state/DB identity/payload digest/fresh DB-time 검사를 우회할 수 없다. out-of-band DB tampering이나 constraint bypass까지 관측되면 복구 가능한 authorization으로 추론하지 않고 fail-closed availability/integrity incident로 격리한다. AWAITING 동안 normal startup/readiness는 dark이며 canonical admission, claim, worker와 provider I/O가 모두 0이다. provisioning commit 뒤를 포함한 모든 FINAL startup/readiness read path는 `NotificationFinalizationEvidenceReadUseCase`(application read use case) `-> NotificationFinalizationRetainedEvidenceQueryPort` `-> PostgreSQL read-only persistence adapter` `-> NotificationFinalizationEvidenceVerifierPort`의 seam만 사용한다. query adapter는 한 read-only consistent transaction의 bounded snapshot을 application-owned immutable projection으로 반환하며 persistence entity나 Spring Data type을 application으로 유출하지 않는다. row/set이 reviewed bound를 넘으면 truncate하지 않고 fail closed한다. 마지막 단계는 application use case가 반환된 projection을 verifier port로 넘기는 orchestration이며 persistence adapter가 verifier implementation에 의존하거나 직접 호출한다는 뜻이 아니다. FRESH projection은 discriminator, full provenance, exact `INITIALIZE_CANONICAL_FRESH` header/route child와 canonical fence set을 읽는다. UPGRADE projection은 discriminator와 full operation header/route child, transport-proof registry, permit, fence, signed BEGIN inventory header/node child, selected·superseded·unselected를 포함한 모든 retained attestation header와 그 permit/holder/node/revocation/consumer/provider-ledger child row를 읽는다. Java verifier port는 branch별 canonical payload/signature, bounded issuer SPKI bytes/digest, retained trust-snapshot payload/digest와 current closed catalog의 historical `ALLOW|REVOKED` 판정을 다시 검증하고, DB identity, birth/fence facts, discriminator, operation/registry/permit/inventory/attestation 및 route/fence semantic exact equality를 재계산한다. BEGIN-less/orphan attestation, closing operation 뒤의 attestation, child set 누락·초과, unselected/superseded row의 signature/trust/ semantic mismatch도 fail closed한다. 이 전체 검증이 성공한 경우에만 readiness를 연다. query/read 또는 Java 재검증 오류, forged direct-apply row와 retained-row corruption은 모두 dark/`NOT_QUALIFIED`, provider I/O 0인 availability/integrity incident이며 SQL structural success나 provisioning job success를 readiness로 승격하지 않는다. app-bootstrap은 composition과 use case 호출만 담당하고 repository, persistence entity, JDBC, query adapter 또는 verifier 구현을 직접 사용하지 않는다. FINAL cleanup은 transitional write controller/command/function/grant만 삭제한다. retained provenance/operation/registry/permit/ inventory/attestation/fence row, 위 read use case/query port/read-only adapter/verifier port와 구현은 startup/readiness evidence를 위해 계속 보존한다. issuer는 application 운영 주체와 분리된 external infrastructure authority이며 production artifact, container, database와 environment에는 private key를 두지 않는다. deterministic local issuer는 test fixture에서만 허용하고 evidence grade를 `LOCAL_TEST`로 낮추며 production provisioning/readiness는 이를 거부한다. authorization이 동봉한 임의 key나 unreviewed environment key는 trust anchor가 아니다. PRE bridge 배포에서 legacy fence를 자동 seed하지 않는다. bridge admission을 열기 전에 인증된 human operator가 batch `INITIALIZE_LEGACY`를 호출한다. canonical binding과 post-migration fresh provisioning의 route key SSOT인 canonical route catalog에 PRE-only legacy alias/transport proof를 더한 compiled cutover route catalog와 별도 reviewed runtime target-generation config를 결합한 bounded ordered `(route revision, reviewed predecessor generation)` set과 그 set의 digest, opaque operation token, actor digest와 bounded reason code를 받는다. fence/operation/permit을 포함한 control table과 intent/delivery/attempt/receipt/suppression/gate/alias를 포함한 data-plane journal이 모두 빈 경우에만 모든 route의 `ACTIVE/LEGACY@initial` fence, operation header와 route result rows, route별 current+retiring transport-proof registry snapshot을 같은 root transaction에서 insert한다. persisted registry의 각 route는 active admission profile이 정확히 하나이고 모든 row는 같은 route registry digest와 initialization child FK를 가진다. 이 table은 UPDATE/DELETE가 금지된 retained audit history이며 permit acquire는 proof class/evidence revision을 여기서 row에 동결한다. direct SQL, application startup hook, V7 schema migration은 이 초기화를 수행하지 않는다. 일부 route만 초기화하는 sequential operation도 금지한다. 서로 다른 token의 동시 batch 초기화는 정확히 한 건만 이기며, 같은 token과 같은 route set/input의 replay는 저장된 전체 결과를 반환하고 token 재사용이나 route-set mismatch는 fail closed한다. commit failure는 mutation/성공 응답 0이고, commit-success/result-loss retry는 operation journal의 저장 결과로 복구한다. init 전 absent/partial/extra fence key set에서는 모든 provider I/O가 0이다. batch init 뒤 route-by-route rollout에서는 exact catalog key set 안에서 `ACTIVE/LEGACY@predecessor`, `DRAINING/LEGACY@predecessor`, `ACTIVE/CANONICAL@target`의 closed state만 혼재할 수 있다. legacy node는 첫 상태 route만, canonical PRE node는 마지막 상태 route만 열고 DRAINING/owner mismatch route는 모두 닫는다. unrelated owner/state/generation이나 catalog key mismatch는 전체 composition/readiness를 fail closed한다. route revision set과 그 digest는 request authority가 아니다. proxied initializer가 retained canonical catalog와 PRE-only cutover decorator, reviewed target config에서 server-side로 derive하고, request는 그 exact key set에 대한 reviewed predecessor generation map, reason과 opaque token만 제공한다. missing/extra route key 또는 caller가 주장한 별도 digest는 거부한다. initialization 뒤 PRE artifact는 compiled decorator와 persisted registry의 route/profile/admission-role/proof-class/evidence-revision/digest exact equality를 startup과 readiness에서 계속 검증한다. cutover 중 registry 변경은 in-place update로 허용하지 않는다. 새 profile/evidence revision이 필요하면 기존 sandbox/rollout을 폐기하고 별도 설계된 registry-version 절차 없이는 진행하지 않는다. canonical admission은 business write + intent append와 같은 caller transaction에서 fence row를 `SELECT ... FOR SHARE` 또는 BEGIN_DRAIN의 update lock과 충돌하는 동등한 tested primitive로 잠그고, `ACTIVE + CANONICAL + expected generation`을 확인한다. lock은 caller physical commit/ rollback까지 유지한다. mismatch면 business state와 intent append를 함께 rollback한다. 따라서 이미 guard를 통과한 canonical transaction이 commit되기 전에 `BEGIN_DRAIN`이 완료되어 반대 owner를 열 수 없다. legacy bridge는 provider I/O 전에 ambient transaction을 거부하는 root transaction으로 fence를 lock하고 `ACTIVE + LEGACY + expected generation`을 확인한 뒤 opaque permit을 insert한다. physical commit 전에는 provider를 호출하지 않는다. acquire result는 DB-time `acquired_at`, `wire_deadline_at`, `expires_at`을 반환한다. wrapper/client는 commit acknowledgement 전부터 잰 monotonic elapsed budget과 이 DB interval의 보수적인 minimum을 사용하고, `wire_deadline_at` 이후에는 network I/O를 시작할 수 없다. provider call 뒤 release도 token/version predicate를 사용하는 별도 root transaction이다. release 실패나 process crash는 ACTIVE permit을 남기며, switch가 이를 definite-drained로 오판하지 않는다. ownership switch는 sleep이나 provider I/O를 한 transaction/use case 안에 넣지 않고 다음 audited operation으로 나눈다. closed transition matrix는 다음뿐이다. request는 target owner를 받지 않고 action이 server-side result를 결정한다. ```text BEGIN_DRAIN: ACTIVE/LEGACY@g -> DRAINING/LEGACY@g TERMINALIZE_EXPIRED_PERMITS: DRAINING/LEGACY@g -> DRAINING/LEGACY@g (fence unchanged) COMPLETE_SWITCH: DRAINING/LEGACY@g -> ACTIVE/CANONICAL@g+1 ABORT_DRAIN: DRAINING/LEGACY@g -> ACTIVE/LEGACY@g+1 ``` `ACTIVE/CANONICAL`에서 모든 switch/terminalizer action은 mutation 0으로 실패한다. reverse owner transition, `DRAINING/CANONICAL`과 caller-selected target owner는 존재하지 않는다. 1. `BEGIN_DRAIN`: exact `ACTIVE/LEGACY@g`에서 fence를 `DRAINING/LEGACY@g`으로 root-commit한다. 같은 row update lock은 in-flight canonical guard의 share lock과 legacy acquire lock 모두와 충돌한다. 이 transaction은 trusted deployment inventory issuer가 서명한 exact environment/DB/route/ PRE-artifact complete old-writer manifest를 server-side 검증하고, canonical node row set과 count/set/manifest digest를 BEGIN child와 `notification_writer_drain_node_inventory`에 동결한다. caller가 old-node digest만 보내거나, persisted permit holder가 manifest에서 누락되거나, manifest에 unknown/duplicate node/profile이 있으면 mutation 0이다. commit 뒤에는 새 canonical append와 legacy permit acquire가 원자적으로 거부된다; 2. read-only application operations query로 DB time 기준 ACTIVE permit 수/최장 expiry를 bounded poll한다. query/COMPLETE는 permit state를 변경하지 않는다. 만료 ACTIVE가 있으면 PRE 전용 authenticated `POST /api/admin/notifications/routes/{routeId}/writer-permits/terminalize-expired`가 `notification:cutover-terminalize` permission의 method-security-proxied application operation을 호출한다. exact `DRAINING/LEGACY@drain-generation` fence와 immutable persisted registry를 lock하고 모든 historical fence generation에서 DB-time상 만료된 ACTIVE를 bounded batch로 고른 뒤 exact token/row-version CAS한다. hard-bound evidence가 동결된 profile만 `EXPIRED_PROVEN`, 현재 R0처럼 hard bound가 없는 profile은 `TIMED_OUT_UNPROVEN`으로 옮긴다. globally unique operation token, actor/reason, affected tuple set/count/digest와 route result를 같은 root transaction의 operation journal에 기록하고 physical commit 뒤에만 성공 응답을 쓴다. idempotency lookup은 expired-set selection보다 먼저 수행한다. same token과 같은 caller input `(route, drain generation, batch bound, actor, reason)`의 replay는 현재 ACTIVE set이 달라졌거나 비었어도 저장된 affected result를 반환한다. affected set은 caller input이 아니라 derived result이고, server-canonical `request_input_digest`와 stored `requested_batch_bound`로 token의 caller input mismatch만 실패한다. digest는 request에서 받지 않고 action/header/route child의 persisted canonical input fields로 재계산한다. 다른 token은 남은 bounded batch를 처리할 수 있다. affected digest는 terminalization token을 참조하는 immutable post-CAS tuple `(permit token, fence generation, profile, proof class, evidence revision, result state, terminalized_at, row version)`의 sorted set으로 정의한다. 이 operation은 provider I/O를 하지 않으며 시간 경과를 drained evidence로 만들지 않는다; 3. `COMPLETE_SWITCH`: exact `DRAINING/LEGACY@g`, expected generation과 route의 모든 fence generation에 걸친 ACTIVE permit 0을 같은 root transaction에서 검증한다. 그 뒤 PRE evidence는 다음 discriminated union 중 정확히 하나여야 한다. - `PRE_QUIESCENCE_EVIDENCE`: persisted current+retiring registry row가 하나라도 `QUIESCENCE_REQUIRED`다. exact BEGIN signed inventory header/row set과 exact 한 selected quiescence attestation header/per-node evidence가 필수다. COMPLETE는 stored inventory와 attestation canonical payload/signature/trust snapshot을 Java에서 다시 Ed25519 검증하고, 모든 `TIMED_OUT_UNPROVEN` tuple, distinct permit holder, inventory node, per-node deployment-generation tombstone와 legacy credential/egress irreversible revocation, consumer-inventory identity/snapshot/count 0, provider-call-ledger identity/snapshot/open-count 0의 exact equality를 lock/recompute한다. attestation이 수락되어 immutable root transaction으로 기록된 뒤 expiry가 지나도 이 branch의 irreversible fact는 무효가 되지 않는다. - `PRE_HARD_BOUND_EVIDENCE`: persisted current+retiring registry row가 모두 `HARD_BOUND_PROVEN`이다. exact BEGIN signed inventory header/row set과 registry/evidence revision을 Java에서 재검증하고, 모든 permit이 `RELEASED|EXPIRED_PROVEN`이며 reviewed wire-deadline/cancellation contract를 충족해야 한다. selected attestation token과 current drain에 귀속된 attestation row는 없어야 한다. 두 branch가 모두 맞거나 둘 다 아니거나, BEGIN inventory header가 없거나, QUIESCENCE branch의 attestation이 없거나 HARD_BOUND branch에 attestation이 있으면 mutation 없이 실패한다. old-node set 0도 signed BEGIN header 한 건이 필수다. attestation은 ACTIVE를 override하지 않으며 `TIMED_OUT_UNPROVEN`이 0건이어도 QUIESCENCE branch에는 필요하다. authoritative registry는 initialization과 같은 root transaction에서 동결한 `notification_writer_transport_proof_registry`이고 PRE runtime은 compiled catalog와 exact equality를 별도로 강제한다. token, blocking-set digest와 drain BEGIN token을 COMPLETE operation route row에 기록한 뒤에만 owner를 `CANONICAL`, generation을 `g+1`, state를 `ACTIVE`로 CAS한다. fence CAS와 operation append는 같은 physical commit이고 acknowledgement 뒤에만 success를 반환한다. `expires_at`이 지났다는 이유로 ACTIVE/TIMED_OUT_UNPROVEN row나 human gate를 query에서 암묵적으로 제외하지 않는다; 4. 취소가 필요하면 exact `DRAINING/LEGACY@g`에서 `ABORT_DRAIN`을 audited root transaction으로 수행해 `ACTIVE/LEGACY@g+1`의 새 generation을 발급한다. 기존 generation permit을 재활성화하거나 provider call을 replay하지 않는다. 모든 operation은 fence CAS와 같은 root transaction에서 append-only operation header/route journal에 exact route set/action, route별 expected owner/generation과 결과 owner/state/generation, globally unique opaque operation token, authenticated actor digest와 bounded reason code를 기록한다. 모든 operation의 `recorded_at`, permit `terminalized_at`과 attestation `observed_at`은 관련 global/route fence lock을 얻은 뒤 PostgreSQL `clock_timestamp()`으로 채운다. transaction 시작 시각인 `CURRENT_TIMESTAMP`/ `transaction_timestamp()`는 금지한다. DB-assigned `operation_sequence`와 attestation의 `attestation_sequence`는 같은 sequence에서 batch initialization 또는 해당 route fence lock을 획득한 뒤 발급하므로 같은 route의 committed cutover event total order다. rollback gap과 다른 route 사이 gap은 허용하지만 duplicate/order reversal은 허용하지 않는다. initialization은 여러 route child를, 이후 switch는 exact 한 route child를 갖는다. fence의 optional `last_operation_token`은 각 mutation과 같은 transaction에서 해당 header로 갱신하는 조회/검증 포인터일 뿐 audit/idempotency SSOT가 아니다. 같은 token과 동일 route set/input의 replay는 오래된 operation이어도 journal의 committed result를 반환하며, token을 다른 route set/action/input에 재사용하면 fail closed한다. commit failure/commit-result loss는 추측으로 성공 보고하지 않으며 direct SQL cutover는 이 계약을 우회하므로 금지한다. header `route_set_digest`는 sorted exact child route set에서, `request_input_digest`는 action-specific persisted header/child input에서 server-side로 계산한다. orphan header/child, empty child set, header/child action 불일치와 두 digest mismatch는 runtime과 V8 모두 거부한다. 두 digest는 domain-separated, versioned `writer-operation-route-set-v1` / `writer-operation-input-v1` length-prefixed SHA-256 canonicalization을 사용하고 raw PII/secret을 입력에 넣지 않는다. `TERMINALIZE_EXPIRED_PERMITS`는 fence를 mutate하지 않으므로 `last_operation_token`을 갱신하지 않고, affected permit의 `terminalization_operation_token`만 journal header를 참조한다. terminal permit은 composite `(terminalization_operation_token, route_revision)` FK로 exact operation child를 참조한다. 그 child는 action/route/drain generation과 unchanged `DRAINING/LEGACY` result를 기록하고, affected count/digest는 그 token을 참조하는 post-CAS permit tuple set과 exact equality다. permit expiry는 live provider call이 끝났다는 증거가 아니다. legacy transport가 permit root-commit에서 DB-time으로 동결한 absolute `wire_deadline_at`, `wire_deadline_at + finalize margin <= expires_at`, deadline 뒤 network-start 거부, connection close/cancellation이 wire deadline까지 확정되는 client contract와 process pause/resume을 integration test로 증명한 경우에만 `EXPIRED_PROVEN`을 drained 판단에 포함한다. 특히 acquire commit 직후 process가 permit expiry 이후까지 pause되었다가 resume하면 provider call은 0이어야 하고, deadline 직전 resume한 call도 그 absolute deadline까지 종료되어야 한다. 이 증거가 하나라도 없으면 profile은 `QUIESCENCE_REQUIRED`다. 현재 legacy seam처럼 그 hard bound가 없으면 authenticated operator가 production consumer 0, old-node 완전 quiesce와 provider-call ledger 0의 signed durable evidence를 append-only attestation으로 root-commit해야 한다. `COMPLETE_SWITCH` command가 exact attestation token을 제공하지 않거나, route/generation/profile-set/blocking-permit-set/signed fact가 맞지 않으면 DB CAS 자체가 실패한다. permit release/timeout terminal transition으로 snapshot이 달라지면 새 attestation이 필요하다. evidence acceptance TTL만 지나거나 runbook checkbox만 확인해 호출이 끝났다고 추정하지 않는다. permit state와 동결 proof class는 교차 불변식이다. `EXPIRED_PROVEN`은 `HARD_BOUND_PROVEN`에만, `TIMED_OUT_UNPROVEN`은 `QUIESCENCE_REQUIRED`에만 허용한다. `ACTIVE|RELEASED`는 양쪽 proof class에 허용되지만 COMPLETE/V8은 persisted registry와 tuple equality를 다시 검증한다. legacy/operator code가 제거된 final artifact는 fresh database에서도 canonical fence를 얻어야 하지만 “notification table이 비었다”는 조건만으로 fresh를 추론하지 않는다. cleanup 전용 additive V8은 database를 canonical-ready로 직접 seed하지 않고 다음 structural classification만 수행한다. - notification control/data-plane, fence, journal과 provenance가 완전히 비면 exact 한 `AWAITING_SIGNED_FRESH_PROVISIONING` discriminator를 남긴다. 이 state에서는 fence와 `INITIALIZE_CANONICAL_FRESH` history가 0이다; - complete PRE upgrade history와 reviewed canonical route-revision key set의 `ACTIVE/CANONICAL@g_final` fence가 있으면 history를 검증·보존하고 exact 한 `UPGRADE_VALIDATED` discriminator와 `validated_upgrade_history_digest`를 남긴다. fence가 없는데 notification row가 하나라도 있거나 partial/extra fence/history, 선행 provenance, 두 classification의 혼합 또는 discriminator mismatch가 있으면 V8은 실패한다. empty store를 upgrade나 fresh canonical state로 추론하지 않는다. V8 뒤 `notificationFreshProvisioning`이 issuer가 먼저 commit한 irreversible no-legacy-authority fence를 포함한 signed DB-birth authorization을 위 snapshot/read-lock -> Java verifier -> apply protocol로 검증하고 한 physical transaction에서 provenance, `INITIALIZE_CANONICAL_FRESH` 전체 route history와 initial canonical fence를 만든 경우에만 `FRESH_PROVISIONED`가 된다. FINAL startup/readiness evidence는 다음 closed union 중 정확히 하나다. - `FINAL_FRESH`: discriminator가 `FRESH_PROVISIONED`이고 exact 한 signed fresh provenance, provenance token을 참조하는 exact 한 `INITIALIZE_CANONICAL_FRESH` header/전체 route child, reviewed initial `ACTIVE/CANONICAL` fence set이 있다. provenance의 DB resource/birth certificate, zero inventories/provider ledger와 committed irreversible no-legacy-authority-fence field가 canonical signed payload와 exact equality여야 한다. upgrade-history discriminator와 `INITIALIZE_LEGACY` history는 없어야 한다; - `FINAL_UPGRADE`: discriminator가 `UPGRADE_VALIDATED`이고 V8이 동결한 exact `validated_upgrade_history_digest`, audited `INITIALIZE_LEGACY`에서 route별 `ACTIVE/CANONICAL@g_final`로 끝난 complete history와 fence set이 있다. fresh provenance와 `INITIALIZE_CANONICAL_FRESH` history는 없어야 한다. 둘 다 맞거나 둘 다 아니거나 반대 branch의 marker/history가 섞이면 startup/readiness는 dark다. `AWAITING_SIGNED_FRESH_PROVISIONING`도 정상 migration completion state일 수 있지만 runtime canonical admission/claim/provider I/O는 0이고 provisioning 전에는 ready가 아니다. 위 application read use case -> retained-evidence query port -> persistence read-only adapter -> verifier port seam이 한 bounded consistent snapshot에서 이 closed union을 판정한다. FRESH branch는 discriminator/provenance/init child/fence를 모두 읽고 provisioning write와 이후 모든 startup에서 retained provenance의 payload/signature/SPKI/trust snapshot, DB birth/fence fact와 semantic exact equality를 Java로 재검증한다. UPGRADE branch는 discriminator와 full operation/registry/permit/inventory row, 모든 selected·superseded·unselected attestation header/child 및 fence를 읽고 retained BEGIN inventory와 모든 quiescence attestation의 payload/signature/SPKI/trust snapshot과 semantic exact equality를 Java로 재검증한다. V8과 SQL constraint는 canonical bytes/digest/count/FK/time shape만 검증하며 cryptographic validity의 authority가 아니다. upgrade database에서는 reviewed route-revision key set exact equality를 요구하되 generation은 route별 audited `g_final`일 수 있다. 모든 fence의 `ACTIVE/CANONICAL`, 각 fence와 최신 `last_operation_token -> COMPLETE_SWITCH` route result의 owner/state/generation 일치를 검증한다. upgrade의 transport-proof authority는 삭제될 PRE catalog나 attestation self-assertion이 아니라 retained immutable `notification_writer_transport_proof_registry`다. registry route key set은 fence/canonical key set과 exact equality이고 route마다 ACTIVE profile이 정확히 하나여야 한다. 모든 registry row는 같은 route digest와 initialization-operation child FK를 가져야 하며, 모든 permit의 frozen profile/proof-class/evidence-revision, attestation registry digest와 COMPLETE child의 proof-requirement/registry digest가 이 snapshot과 exact equality여야 한다. permit이 0인 `QUIESCENCE_REQUIRED` route도 이 retained row 때문에 누락되지 않는다. ACTIVE permit은 항상 실패하고 proof-class/state 교차 불변식 위반도 실패한다. 각 route의 마지막 COMPLETE는 PRE evidence closed union을 replay한다. - `PRE_QUIESCENCE_EVIDENCE`는 signed BEGIN inventory header와 exact inventory row set, selected attestation의 exact BEGIN FK/profile registry/blocking permit set/distinct holder set/old-node set, per-node deployment-generation tombstone와 legacy credential/egress irreversible revocation, consumer-inventory identity/snapshot/count 0, provider-ledger identity/snapshot/open-count 0, COMPLETE child의 selected token/blocking-set digest를 요구한다. `BEGIN.operation_sequence < attestation.attestation_sequence < COMPLETE.operation_sequence`여야 한다. `issued_at - allowed_clock_skew <= server_verified_at <= expires_at - acceptance_margin`은 attestation을 처음 기록한 acceptance가 유효했음을 보존한다. cleanup 현재 시각이 `expires_at` 뒤여도 immutable tombstone/revocation과 zero-ledger snapshot은 유효하다. - `PRE_HARD_BOUND_EVIDENCE`는 signed BEGIN inventory header와 exact inventory row set, 모든 current+retiring registry row의 `HARD_BOUND_PROVEN`, 모든 permit의 `RELEASED|EXPIRED_PROVEN`과 reviewed deadline/cancellation evidence revision을 요구한다. 마지막 drain-BEGIN에 selected attestation이나 attestation row가 있으면 실패한다. 두 PRE branch가 모두 맞거나 둘 다 아니면 실패하고 old-node row가 0개인 BEGIN도 signed header 한 건을 요구한다. Java startup verifier는 stored canonical payload/signature/trust snapshot과 semantic header/child row exact equality를 재검증한다. unknown issuer/key, trust snapshot mismatch, payload/signature 불일치, reversible fence 또는 ledger identity/snapshot mismatch는 upgrade를 중단한다. `EXPIRED_PROVEN|TIMED_OUT_UNPROVEN` permit은 exact route의 `TERMINALIZE_EXPIRED_PERMITS` child를 composite FK로 참조해야 한다. V8은 action/route/drain BEGIN FK/generation, unchanged DRAINING/LEGACY result, `expires_at <= terminalized_at`, `BEGIN.operation_sequence < terminalizer.operation_sequence < first_closing_ABORT_or_COMPLETE.operation_sequence`와 child affected count/digest를 그 token을 참조하는 immutable post-CAS permit tuple set에서 재계산한다. `recorded_at`/`terminalized_at`은 post-lock `clock_timestamp()` shape와 expiry sanity를 보조 검증할 뿐 causal SSOT가 아니다. wrong action/route/set, orphan token, expiry 전 또는 drain close 뒤 terminalization은 실패한다. fence, permit, signed inventory/attestation header와 child row, per-node quiescence fence evidence, fresh provenance, finalization discriminator, proof registry와 operation history를 모두 byte-for-byte 보존한다. upgrade에서는 V8이 operation과 attestation이 공유하는 DB sequence를 route별로 replay한다. 허용 operation history는 `INITIALIZE_LEGACY -> (BEGIN -> TERMINALIZE* -> ABORT)* -> BEGIN -> TERMINALIZE* -> COMPLETE`이고 COMPLETE는 해당 route의 마지막 mutation이어야 한다. 각 transition의 expected/result owner/state/generation과 drain-BEGIN FK가 closed matrix와 일치해야 하며 terminalizer는 DRAINING self-transition일 뿐이다. CANONICAL 뒤 BEGIN/TERMINALIZE/ABORT/두 번째 COMPLETE, sequence duplicate/collision/reversal, missing predecessor와 journal replay 결과/fence/ latest-mutation-pointer 불일치는 모두 실패한다. replay 전에 모든 header를 child와 양방향 대조한다. 두 INITIALIZE header는 reviewed canonical route set과 정확히 같은 child set을, 모든 non-init header는 exact 한 route child를 가져야 한다. header `route_set_digest`는 sorted child route set과, `request_input_digest`는 action-specific persisted input과 재계산 equality여야 한다. orphan/extra/empty header 또는 child는 모두 실패한다. immutable attestation은 permit row-version 변화 뒤 재발급되거나 ABORT로 선택되지 않을 수 있다. same drain-BEGIN FK를 가지며 `BEGIN.operation_sequence < attestation.attestation_sequence < first_closing_ABORT_or_COMPLETE.operation_sequence`이고 signed payload/header/child 구조가 유효한 superseded/unselected row만 audit history로 보존·허용한다. latest COMPLETE의 QUIESCENCE branch가 선택한 token만 exact set을 full 검증하고 HARD_BOUND branch의 latest drain에는 attestation을 금지한다. BEGIN 없는 forged row, ABORT/COMPLETE 뒤의 sequence, missing selected row와 selected token/digest mismatch는 실패한다. LEGACY/DRAINING, ACTIVE permit, missing/extra route, latest-operation mismatch, missing/mismatched signed inventory/attestation, BEGIN-less forged history, discriminator branch mismatch 또는 missing-fence-with-any-nonempty-journal은 fail closed한다. app-bootstrap startup도 compiled canonical route set과 persisted fence set/expected generation exact equality를 검증한다. ### 16.8 database constraint와 index 다음은 migration과 PostgreSQL integration test가 강제할 최소 invariant다. - 모든 table은 opaque PK를 갖고 child row는 parent에 FK를 둔다. intent hard delete는 live delivery/attempt/receipt가 있으면 금지하고 retention worker가 명시된 purge order를 따른다; - intent source dedupe alias는 `(tenant_scope, purpose, hmac_key_version, digest)` unique이며 stable semantic owner 하나만 가리킨다; - `notification_delivery_leg(notification_id, target_ordinal)` unique; - `notification_attempt(delivery_id, attempt_ordinal)` unique; - `notification_attempt(attempt_execution_token)` unique와 exact provider-result fact 최대 1개; - delivery당 open attempt 최대 1개 partial unique; - `(notification_id, strategy_group)`당 `QUEUED/CLAIMED/ATTEMPT_RESERVED/WIRE_AUTHORIZED/ RETRY_WAIT/PARKED_BINDING/RECONCILE_WAIT/RECONCILING` fallback leg 최대 1개 partial unique; - admission gate PK/CAS와 leg park transition은 같은 finalize transaction에서 갱신한다; - route writer fence는 route당 한 row이고 `(route_revision, generation, owner, state, row_version)` predicate로 CAS한다. `BEGIN_DRAIN` 뒤 새 legacy permit은 0이어야 한다; - writer operation token은 header에서 globally unique고 route child PK는 `(operation_token, route_revision)`이다. batch 초기화와 모든 single-route ownership CAS는 append-only header/route rows를 같은 root transaction에 기록하고, same-token/same-input replay는 저장된 전체 committed result를 반환하며 token/route-set/input mismatch는 mutation 없이 거부한다. operation과 attestation은 같은 DB sequence를 관련 fence/global lock 뒤 발급받고 두 table 사이 collision까지 V8이 거부해 route별 committed causal total order를 제공한다. 모든 cutover timestamp는 lock 뒤 `clock_timestamp()`으로 기록하고 `CURRENT_TIMESTAMP`/`transaction_timestamp()`를 causal authority로 사용하지 않는다; - BEGIN child의 reviewed complete node count/set/manifest digest는 같은 transaction의 exact 한 immutable signed inventory header와 drain-node inventory row set에 exact equality다. node 0도 header 한 건과 canonical empty-set digest가 필수다. inventory와 attestation header는 bounded canonical payload/signature/issuer identity/key/bounded canonical public-key SPKI bytes/digest/ canonical trust-snapshot payload/digest, signed acceptance-window profile/skew/margin과 issued/expiry/server-verified metadata, environment/DB/artifact, consumer-inventory identity/snapshot과 provider-ledger identity/snapshot을 보존한다. SQL은 non-null/length/digest/count/FK와 nonnegative reviewed skew/margin, `issued_at - allowed_clock_skew <= server_verified_at <= expires_at - acceptance_margin`을 강제하고 Java write/startup verifier가 Ed25519와 trust catalog를 재검증한다. permit의 distinct holder set이 inventory의 subset이 아니거나 attestation manifest node set이 inventory와 exact equality가 아니면 실패한다; - transport-proof registry는 batch initialization의 operation child와 같은 root transaction에서 exact route/current+retiring profile set으로만 insert한다. route마다 ACTIVE admission profile은 정확히 하나이고 registry digest는 모든 route child row에서 일치해야 한다. UPDATE/DELETE는 DB constraint/trigger와 adapter surface 모두에서 금지한다. permit의 frozen profile/proof class/evidence revision, attestation와 COMPLETE child의 registry digest는 이 retained snapshot과 exact equality여야 한다; - writer permit token은 globally unique다. active permit lookup은 `(route_revision, fence_generation, owner, state, expires_at)` index를 사용하고 release/expiry는 exact token + row version으로 한 번만 전이한다. hard-bound가 없으면 timeout은 `TIMED_OUT_UNPROVEN`이고 자동 drained terminal이 아니다. `EXPIRED_PROVEN => HARD_BOUND_PROVEN`, `TIMED_OUT_UNPROVEN => QUIESCENCE_REQUIRED`를 DB CHECK로 강제하며 반대 조합을 insert/update할 수 없다. timeout terminal state는 `(terminalization_operation_token, route_revision)` composite FK, non-null terminalized_at과 `expires_at <= terminalized_at`을 요구하고 ACTIVE/RELEASED는 terminalization fields를 금지한다. `COMPLETE_SWITCH`와 operations snapshot은 같은 locked fence route의 LEGACY ACTIVE/TIMED_OUT_UNPROVEN permit을 모든 generation에 걸쳐 본다. ACTIVE는 항상 0이어야 하고, attestation은 exact TIMED_OUT_UNPROVEN set을 덮는다. unproven transport는 그 set이 0이어도 exact signed quiescence attestation이 필수다; - quiescence attestation token은 globally unique하고 immutable하다. exact route/drain generation/BEGIN FK, bounded transport-profile set digest, blocking permit count/set digest, distinct holder set, frozen old-node set, exact per-node tombstone/revocation row-set digest, signed consumer/provider-ledger zero-fact evidence와 retained canonical signature header를 가지며 `COMPLETE_SWITCH` operation child가 token과 same blocking-set digest를 FK/constraint로 참조한다. acceptance window 밖의 새 evidence, mismatched/reused evidence, caller-authored inventory digest 또는 multi-profile/node set 일부만 덮는 evidence는 mutation 없이 거부한다. root-committed attestation은 per-node irreversible tombstone/revocation과 provider-ledger zero snapshot의 durable proof이므로 이후 wall clock expiry로 무효화하지 않는다; - COMPLETE는 exact 한 PRE evidence branch만 허용한다. `PRE_QUIESCENCE_EVIDENCE`는 BEGIN inventory+selected signed attestation+per-node irreversible evidence+ACTIVE permit 0이고, `PRE_HARD_BOUND_EVIDENCE`는 BEGIN inventory+all-hard-bound registry+safe terminal permit+ACTIVE permit 0이며 current drain attestation은 0이다. SQL은 structural exact-set/FK/CHECK를 강제하고 Java transition verifier가 retained Ed25519 payload를 재검증한다. correctness는 constraint execution timing에 의존하지 않는다; - V8은 complete upgrade를 `UPGRADE_VALIDATED`로 보존하거나 완전히 empty store를 `AWAITING_SIGNED_FRESH_PROVISIONING`으로 남길 뿐 canonical fence를 seed하지 않는다. any nonempty missing-fence/partial-history store는 실패한다. 별도 `notificationFreshProvisioning`만 independent issuer가 먼저 commit한 irreversible no-legacy-authority fence를 포함한 signed DB-birth authorization을 Java로 검증한다. 같은 provisioner transaction의 exact snapshot/read-lock과 apply function 사이에서 검증하며 apply의 fresh DB time이 window 안일 때만 provenance, exact 한 `INITIALIZE_CANONICAL_FRESH` batch, 모든 reviewed canonical fence와 `FRESH_PROVISIONED` discriminator를 만든다. FINAL은 `FINAL_FRESH(provenance)`와 `FINAL_UPGRADE(validated history discriminator)`의 closed union이다; - database role topology는 정확히 `notification_migrator`, `notification_runtime`, `notification_provisioner` 세 개다. `notification_migrator`는 notification schema object와 모든 `SECURITY DEFINER` function을 소유하고 Flyway에서만 사용하는 dedicated LOGIN migration-only principal이며 일반 application/provisioning datasource가 아니다. `notification_runtime`은 non-owner다. PRE에서는 exact initializer/switch/permit/ terminalizer/attestation function `EXECUTE`를 method-security로 보호된 application use case path를 통해서만 사용하고, retained evidence bounded projection `SELECT`와 canonical fence read/lock에 필요한 최소 권한만 가진다. FINAL migration은 runtime의 transitional function `EXECUTE`, retained cutover audit/control `INSERT|UPDATE|DELETE`와 cutover sequence `USAGE`를 명시적으로 revoke한다. PRE와 FINAL 모두 normal runtime의 active-release intent/delivery/attempt/receipt 등 operational journal에 필요한 exact DML/SELECT와 그 전용 sequence 권한은 별도 least-privilege grant로 유지한다; - `notification_provisioner`는 `notification_fresh_provisioning_snapshot_and_lock`과 `notification_fresh_provisioning_apply` 두 function의 `EXECUTE`만 가진다. retained audit/control/operational journal generic `SELECT|INSERT|UPDATE|DELETE`, 모든 sequence `USAGE`, DDL, transitional function과 그 밖의 function `EXECUTE`, role membership은 0이다. normal runtime은 이 두 fresh function의 `EXECUTE`를 갖지 않는다; - 모든 `SECURITY DEFINER` function은 migration-only `notification_migrator`가 소유하고 `SET search_path = pg_catalog`, fully-qualified object name, bounded typed input/output, dynamic SQL 0을 강제하며 `PUBLIC EXECUTE`를 revoke한다. 특히 fresh 두 function은 같은 physical provisioner transaction/connection의 lock protocol을 강제하고 apply가 state, identity, snapshot/payload semantic digest와 fresh `clock_timestamp()` acceptance window를 재검산한다. SQL은 cryptographic validity를 주장하지 않으며 Ed25519/trust 검증은 두 function 사이의 Java verifier port가 담당한다; - provider receipt는 `(provider_binding_revision, event_digest_key_version, provider_event_id_digest)`와 `(provider_binding_revision, semantic_digest_key_version, semantic_event_fingerprint)`로 outer retry와 의미상 중복을 각각 차단한다; - provider message reference/correlation lookup은 binding revision과 HMAC key version까지 scope에 포함하고 하나의 open delivery/attempt와만 매칭한다; - eligible scan index는 최소 `(state, next_action_at, admission_class, notification_id)`, stale lease scan은 `(state, claim_lease_until)`, orphan attach는 provider binding과 correlation/message-reference digest를 선두로 둔다; - constraint conflict를 catch-and-ignore로 처리하지 않고 typed idempotent/conflict 결과로 mapping한다. ### 16.9 claim protocol claim query는 eligible state, `next_action_at`, expiry, 모든 admission gate ACTIVE와 bounded batch를 사용하고 PostgreSQL `FOR UPDATE SKIP LOCKED` 또는 동등한 tested primitive를 사용할 수 있다. claim/finalize update의 필수 predicate: ```text WHERE delivery_id = ? AND claim_owner_token = ? AND state = expected_state AND row_version = expected_version ``` 영향 row가 정확히 1이 아니면 stale owner/conflict다. stale worker는 provider result를 새 owner의 state 위에 덮지 못한다. 다만 정확한 provider response는 claim owner와 독립된 immutable `attempt_execution_token`으로 해당 open attempt에 terminal-once append할 수 있고, projection merge만 현재 owner/version CAS를 사용한다. claim transaction 안에서 provider call/render-heavy work를 하지 않는다. `WIRE_AUTHORIZED` transaction은 gate를 `(scope_type, scope_revision)` canonical order로 lock/read해 deadlock을 피하고, authorized generation set을 attempt fact에 남긴다. ### 16.10 lease expiry와 reaper - `CLAIMED`와 `ATTEMPT_RESERVED`는 `WIRE_AUTHORIZED` evidence가 없으므로 lease 만료 뒤 안전하게 requeue할 수 있다; - `WIRE_AUTHORIZED` 이후 owner를 잃으면 attempt deadline과 transport/finalize grace가 끝나기 전에 retry/fallback을 활성화하지 않는다; - grace 뒤 정확한 result fact가 없으면 retry queue가 아니라 provider card에 따라 `RECONCILE_WAIT` 또는 `TERMINAL_INDETERMINATE`로 이동한다; - clock skew와 DB clock/application clock ownership을 명시한다; - reaper는 provider side effect가 없었다고 추론하지 않는다; - expired intent도 이미 maybe-sent인 attempt를 not-sent/cancelled로 낮추지 않는다. ### 16.11 transaction failure provider accepted 뒤 finalize transaction이 실패할 수 있다. 다음을 보장하지 못한다. ```text external provider side effect local notification_delivery_leg projection update ``` 따라서 pre-send correlation/native operation key가 있으면 provider contract대로 사용하고, response loss/finalize failure는 지원되는 lookup mode로 reconciliation한다. post-response message reference가 없는데 있다고 가정하지 않는다. reconciliation이 없는 provider는 `TERMINAL_INDETERMINATE`와 수동 runbook을 갖는다. ### 16.12 broker wake-up 향후 throughput/latency 때문에 broker를 쓰더라도 payload에는 encrypted notification body를 복제하지 않고 opaque `NotificationIntentId` 또는 delivery wake-up key만 싣는다. broker message는 hint다. consumer는 DB state/claim을 다시 확인한다. duplicate/lost wake-up이 정확성에 영향을 주지 않도록 periodic DB scan을 유지한다. ## 17. provider capability matrix 초기 provider 후보의 목표 위치는 다음과 같다. | Provider | Channel | 초기 위치 | native idempotency | response reference | recipient feedback | R2 판정 | | --- | --- | --- | --- | --- | --- | --- | | `slack-web-api` | Slack | reference | 의존할 문서 계약 없음 | `channel`, `ts` | 사용자 delivery/read 없음 | sandbox evidence 필요 | | `slack-webhook` | Slack | legacy fixed target | 없음 | 없음 | 없음 | R0/R1 compatibility | | `aws-ses-v2` | Email | reference | `SendEmail` client token 없음 | `MessageId` | delivery/delay/bounce/complaint 등 | sandbox/feedback evidence 필요 | | `google-email` | Email | legacy ambiguous seam | 정의 안 됨 | 정의 안 됨 | 정의 안 됨 | R0 only | | `gmail-api` | Email | optional future | send native idempotency 계약 없음 | Gmail message resource | generic recipient delivery feedback 아님 | 별도 card | | `smtp` | Email | optional future transport | protocol 전체의 generic idempotency 없음 | server-dependent | DSN/feedback topology별 상이 | 별도 card | 한 provider의 submission API와 callback/reconciliation capability를 별도 provider인 것처럼 오해하지 않는다. capability card는 send path, feedback transport, account/region/workspace와 credential mode를 함께 고정한다. ### 17.1 최소 R2 candidate card의 exact set 초기 implementation/qualification 범위는 다음 세 card뿐이다. 이 목록은 target이며 required evidence가 쌓이기 전에는 R2라고 부르지 않는다. | Card ID | 정확한 보장 | | --- | --- | | `slack-web-api-inline-single-local-v1` | application-policy inline, SINGLE, local Block Kit/text renderer, `chat.postMessage`, `(channel,ts)` conversation post, response-loss terminal unknown, receipt/reconcile 없음 | | `slack-web-api-durable-single-local-v1` | same-DB append, one provider leg, local renderer, `chat.postMessage`, response-loss terminal unknown, receipt/reconcile 없음 | | `aws-ses-v2-durable-single-local-sns-v1` | same-DB append, one recipient/leg/call, local-rendered text/HTML, SES v2 `SendEmail`, pre-send EmailTag correlation, SNS HTTPS feedback와 직교 projection | 각 deployed card instance는 §7.1의 모든 축, exact provider binding revision, Slack workspace/channel class 또는 AWS account/region/configuration set/topic, resolved credential source, evidence manifest digest와 maturity를 채운다. derived card는 축 하나라도 바뀌면 새 ID/revision과 독립 evidence를 요구한다. `FAN_OUT_ALL`과 `ORDERED_FALLBACK` kernel은 R1 contract 대상으로 구현할 수 있으나, 정확한 provider chain/card ID와 concurrency/fault evidence를 승인하기 전에는 초기 provider R2 set에 포함하지 않는다. legacy `slack-webhook`, `google-email`, future Gmail/SMTP도 이 세 card의 evidence를 상속하지 않는다. ## 18. Slack reference provider ### 18.1 선택 초기 R2 reference는 Slack Web API [`chat.postMessage`](https://docs.slack.dev/reference/methods/chat.postMessage/)다. 선택 이유: - route binding이 고정한 channel ID를 요청마다 정확히 선택할 수 있다; - 성공 응답에 `channel`과 message `ts`가 있다; - thread/update/delete와 future reconciliation에 사용할 provider reference를 얻는다; - incoming webhook보다 destination/capability가 명시적이다. provider ID는 capability 차이를 드러내는 `slack-web-api`를 사용한다. 기존 `slack-webhook`을 같은 ID 뒤의 credential mode로 숨기지 않는다. ### 18.2 destination과 권한 application은 Slack channel ID를 전달하지 않는다. ```text NotificationRouteId -> compiled target -> workspaceBindingId -> channelId secret/config reference -> provider credential reference ``` bot token은 least-privilege `chat:write`를 기준으로 하고 public/private channel 접근과 membership을 startup/readiness card에서 검증한다. 모든 public channel에 쓰는 추가 scope를 편의상 기본 요구하지 않는다. credential은 workload secret provider reference로 주입하고 plain application YAML, test fixture, log에 넣지 않는다. resolution ownership은 §24.2의 bootstrap bridge/adapter-owned material factory를 따른다. token rotation을 사용하는 profile은 old/new token generation과 in-flight attempt의 binding revision을 정의한다. ### 18.3 payload - local typed renderer가 `text`와 bounded Block Kit payload를 만든다; - accessibility fallback용 top-level `text` 정책을 template descriptor에 둔다; - block/element/text/overall byte 상한은 Slack documented limit보다 보수적으로 설정한다; - arbitrary channel/user mention과 external URL은 allowlisted value type만 허용한다; - correlation metadata를 쓰더라도 secret/PII를 넣지 않는다; - unfurl은 route policy에서 명시적으로 disable/allow한다; - provider raw JSON을 application parameter로 받지 않는다. ### 18.4 rate limit과 retry Slack은 message posting에 channel별 대략 초당 1건 기준과 HTTP `429`의 `Retry-After` 처리를 문서화한다. 정확한 burst 크기를 capacity 상수로 사용하지 않는다. dispatcher는 `(workspaceBinding, channelId)`별 bounded rate bucket/admission을 둔다. - local admission wait도 intent deadline 안에 포함한다; - `429`는 response가 해당 request를 수락하지 않았다는 exact card evidence가 있을 때 retryable rejection으로 분류한다; - `Retry-After`는 local maximum과 expiry로 cap한다; - timeout/5xx/response parse failure는 provider가 side effect를 만들었을 수 있으므로 기본 `INDETERMINATE`다; - retry worker/thread를 target마다 무한 생성하지 않는다. 공식 기준: [Slack Web API rate limits](https://docs.slack.dev/apis/web-api/rate-limits/). ### 18.5 success와 receipt 의미 `chat.postMessage` 성공 응답의 `(workspace, channel, ts)`를 encrypted/opaque provider message reference로 저장한다. ```text PROVIDER_ACCEPTED + (workspace, channel, ts) -> POSTED_TO_CONVERSATION ``` 이는 Slack conversation에 message가 생성되었다는 의미다. 특정 사용자의 desktop/mobile push 도착 또는 읽음을 증명하지 않는다. `conversations.history` 또는 event를 이용한 확인은 conversation presence reconciliation일 뿐 user delivery receipt가 아니다. response를 잃어 `ts`가 없는 unknown attempt에서 history absence만으로 definite-not-posted를 증명하지 않는다. 공식 기준: - [Web API response contract](https://docs.slack.dev/apis/web-api/) - [conversations.history](https://docs.slack.dev/reference/methods/conversations.history/) - [message event](https://docs.slack.dev/reference/events/message/) ### 18.6 idempotency와 unknown outcome 현재 `chat.postMessage`의 normative method contract에는 운영상 의존할 수 있는 request idempotency key와 dedupe retention semantics가 없다. error reference의 특정 field 이름을 idempotency guarantee로 승격하지 않는다. 따라서 response-loss attempt는 blind retry하지 않는다. duplicate-tolerant route가 아닌 한 terminal/manual reconciliation 또는 provider card가 검증한 별도 reconciliation로 이동한다. ### 18.7 incoming webhook compatibility Incoming Webhook은 다음 exact capability로만 등록한다. ```text FIXED_DESTINATION NO_PROVIDER_MESSAGE_REFERENCE NO_DOCUMENTED_IDEMPOTENCY NO_USER_RECEIPT NO_UPDATE_DELETE_BY_WEBHOOK ``` webhook URL 자체가 secret이며 고정 destination에 결합된다. 성공은 일반적으로 HTTP 200과 `ok` text지만 `ts`를 반환하지 않는다. dynamic channel, durable reconciliation 또는 receipt-required route에 사용하지 않는다. 공식 기준: [Sending messages using incoming webhooks](https://docs.slack.dev/messaging/sending-messages-using-incoming-webhooks/). ### 18.8 Slack qualification R2 evidence lane은 production token이 아니라 별도 [Slack developer sandbox/test workspace](https://docs.slack.dev/tools/developer-sandboxes/)와 격리 channel을 사용한다. 필수 evidence: - valid post와 returned `(channel, ts)`; - invalid auth/channel/scope classification; - 429와 `Retry-After`; - deadline/connection loss fault injection의 indeterminate 분류; - message size/block/escaping contract; - credential rotation; - per-channel concurrency/admission; - no PII/secret telemetry; - optional history/event presence 확인의 정확한 한계. Slack은 provider-side dry-run/emulator를 baseline으로 제공한다고 가정하지 않는다. ## 19. Email reference provider ### 19.1 선택 초기 R2 reference는 Amazon SES v2 [`SendEmail`](https://docs.aws.amazon.com/ses/latest/APIReference-V2/API_SendEmail.html/)이다. 선택 이유: - transactional email submission API와 account sending 상태/quota가 명시되어 있다; - message ID와 event destination을 통한 delivery/bounce/complaint lifecycle을 구성할 수 있다; - AWS SDK v2, workload IAM과 region/account profile을 명확히 고정할 수 있다; - Gmail mailbox-specific OAuth/quotas를 generic baseline에 결합하지 않는다. provider ID는 `aws-ses-v2`다. 기존 `google-email` provider를 내부에서 SES로 바꾸지 않는다. ### 19.2 한 recipient 한 provider call 최소 R2는 SES `SendEmail` 한 호출에 intent의 logical recipient 정확히 한 명만 보낸다. 이유: - recipient별 outcome, bounce/suppression, attempt identity를 정확히 연결한다; - multi-destination partial semantics를 피한다; - provider message ID를 하나의 delivery와 매핑한다; - fan-out budget과 privacy boundary가 명확해진다. 대량 personalized/bulk API는 별도 capability card와 partial result/state model이 필요하다. ### 19.3 identity, sender와 credential route binding은 다음을 고정한다. ```text awsAccountBinding region verifiedFromIdentity configurationSet replyTo policy feedback event destination credential mode ``` application은 from address, region, configuration set 또는 IAM credential을 선택하지 않는다. credential baseline은 AWS SDK v2 [default credentials provider chain](https://docs.aws.amazon.com/sdk-for-java/latest/developer-guide/credentials-chain.html/) 을 무조건 허용하는 것이 아니라 deployed card가 `WEB_IDENTITY`, `CONTAINER` 또는 `INSTANCE_PROFILE` 중 resolved source를 하나 고정하는 workload role profile이다. default chain을 구현에 사용하더라도 readiness가 실제 선택된 source를 exact card와 비교해야 한다. production에서 `SYSTEM_PROPERTY`/`ENVIRONMENT_STATIC` credential이 선택되면 startup/readiness를 실패시킨다. static access key literal은 settings에 넣지 않는다. IAM은 route가 필요한 verified identity/configuration set/send operation으로 최소화한다. client construction/refresh는 §24.2의 bootstrap bridge와 adapter-owned credential factory를 따르며 application/settings에 resolved credential 값을 전달하지 않는다. 공식 기준: [Controlling access to Amazon SES](https://docs.aws.amazon.com/ses/latest/dg/control-user-access.html). ### 19.4 content - local renderer가 UTF-8 text와 HTML part를 만든다; - subject/header control character를 거부한다; - from/reply-to/return-path는 route policy가 고정한다; - provider-stored SES template는 별도 `SES_STORED_TEMPLATE` card로만 지원한다; - open/click tracking은 privacy/security/URL mutation을 검토한 route에서만 opt-in한다; - attachment/raw MIME는 최소 R2에서 제외한다; - provider hard limit보다 낮은 local encoded-byte limit을 둔다. ### 19.5 send response의 의미 SES `SendEmail` 응답의 `MessageId`는 요청이 accepted되었다는 evidence다. AWS 문서도 accepted message가 이후 실제로 전송되지 않을 수 있음을 명시한다. ```text SendEmail MessageId -> PROVIDER_ACCEPTED != DELIVERED_TO_RECIPIENT_MTA != INBOX_DELIVERED != READ ``` 공식 기준: - [SES email sending process](https://docs.aws.amazon.com/ses/latest/dg/send-email-concepts-process.html) - [SendEmail API](https://docs.aws.amazon.com/ses/latest/APIReference-V2/API_SendEmail.html) ### 19.6 idempotency, SDK retry와 indeterminate SES v2 `SendEmail` request에는 notification coordinator가 의존할 native `ClientToken`이 없다. timeout/connection loss 뒤 동일 message를 재요청하면 duplicate를 배제할 provider contract가 없다. `aws-ses-v2-durable-single-local-sns-v1`은 send 전에 만든 opaque non-PII ASCII/Base32 `AttemptCorrelationId`를 고정 tag name `ca_attempt_v1`의 SES `EmailTags`에 넣는다. tag에는 tenant/user/recipient/intent 의미를 인코딩하지 않는다. verified SES event의 matching tag와 `SEND` fact는 response-loss attempt가 provider에 accepted되었음을 사후 복원할 수 있다. 이는 provider dedupe/idempotency key가 아니며 동일 send 재요청을 안전하게 만들지 않는다. AWS SDK v2의 standard retry는 기본적으로 여러 attempt를 수행할 수 있으므로 mutation send baseline에서는 disable하고 coordinator의 physical attempt journal을 사용한다. future provider card가 SDK retry를 허용하려면 모든 actual attempt, backoff와 transmission certainty가 local budget/evidence에 포함됨을 증명해야 한다. 공식 기준: [AWS SDK for Java 2.x retry strategy](https://docs.aws.amazon.com/sdk-for-java/latest/developer-guide/retry-strategy.html). ### 19.7 quota, sandbox와 admission SES account/region의 sandbox 상태와 sending capability는 startup/readiness에서 exact profile로 확인한다. quota 수치는 계정/region/상태에 따라 달라질 수 있으므로 문서에 고정 숫자를 박지 않고 runtime control plane/readiness evidence를 사용한다. - send rate/24-hour quota에 맞춘 bounded token bucket; - local backlog/expiry와 provider quota intersection; - provider throttling의 bounded backoff; - sandbox에서는 verified recipient/mailbox simulator만 사용; - production access가 없으면 selected production card R2를 주장하지 않는다. 공식 기준: - [SES quotas](https://docs.aws.amazon.com/ses/latest/dg/quotas.html) - [GetAccount API](https://docs.aws.amazon.com/ses/latest/APIReference-V2/API_GetAccount.html) - [Managing sending quota errors](https://docs.aws.amazon.com/ses/latest/dg/manage-sending-quotas-errors.html) ### 19.8 feedback event 최소 R2 feedback topology를 다음 하나로 고정한다. ```text SES configuration set -> one SNS standard topic -> HTTPS endpoint owned by adapter-inbound-web -> NormalizedNotificationReceiptUseCase SNS exhausted-delivery -> infrastructure-managed DLQ ``` card는 SES account/region/configuration set, SNS TopicArn/account/region, endpoint profile, SignatureVersion 2, retry horizon/ACK contract와 DLQ identity를 고정한다. 같은 route에서 SES identity notification과 configuration-set event publishing을 이중 활성화하지 않는다. event mapping: | SES event | immutable fact/projection | | --- | --- | | `SEND` | `SubmissionProjection=ACCEPTED`; EmailTag correlation으로 response-loss 복원 가능 | | `REJECT` | accepted fact를 지우지 않고 `RecipientTransport=FAILED_AFTER_ACCEPT` | | `BOUNCE` | `RecipientTransport=BOUNCED`; hard bounce만 suppression 후보 | | `COMPLAINT` | `Abuse=COMPLAINED`, 다른 transport projection과 공존 | | `DELIVERY` | `RecipientTransport=MTA_ACCEPTED` | | `DELIVERY_DELAY` | normalized `DELAYED` fact/projection | | `RENDERING_FAILURE` | provider-stored template card에서만 `FAILED_AFTER_ACCEPT`; local-rendered 초기 card는 기대 event set에 넣지 않음 | `DELIVERY`는 recipient mail server가 message를 accepted했다는 의미이며 inbox placement/read가 아니다. exact expected event set은 deployed card에 고정하고 extra/unknown event는 quarantine한다. 공식 기준: - [SES event destination](https://docs.aws.amazon.com/ses/latest/APIReference-V2/API_EventDestination.html) - [Monitoring sending activity using notifications](https://docs.aws.amazon.com/ses/latest/dg/monitor-sending-activity-using-notifications.html) - [SES event publishing and message tags](https://docs.aws.amazon.com/ses/latest/dg/monitor-using-event-publishing.html) - [SES SNS event examples](https://docs.aws.amazon.com/ses/latest/dg/event-publishing-retrieving-sns-examples.html) - [SES message insights](https://docs.aws.amazon.com/ses/latest/APIReference-V2/API_GetMessageInsights.html) ### 19.9 bounce, complaint와 suppression - hard bounce와 complaint는 technical suppression 후보로 처리한다; - transient/delayed event를 hard suppression으로 즉시 승격하지 않는다; - provider/account/global suppression과 local technical suppression의 precedence를 명시한다; - business unsubscribe/marketing consent와 별도 store/port를 유지한다; - recipient HMAC scope와 encryption key lifecycle을 적용한다; - suppression 충돌/해제는 audit 가능한 use case로만 수행한다. 공식 기준: [SES global suppression list](https://docs.aws.amazon.com/ses/latest/dg/sending-email-global-suppression-list.html). ### 19.10 Gmail API와 SMTP의 위치 `gmail-api`: - Workspace/mailbox identity가 실제 요구일 때만 선택한다; - OAuth user consent 또는 domain-wide delegation, per-user quota, message resource와 push notification을 exact card에 포함한다; - Gmail `users.messages.send` response를 recipient delivery receipt로 부르지 않는다; - 기존 `google-email` 이름만으로 Gmail R2를 주장하지 않는다. 공식 기준: - [Gmail users.messages.send](https://developers.google.com/workspace/gmail/api/reference/rest/v1/users.messages/send) - [Gmail API usage limits](https://developers.google.com/workspace/gmail/api/reference/quota) - [Gmail API sending](https://developers.google.com/workspace/gmail/api/guides/sending) `smtp`: - SMTP는 provider가 아니라 transport profile로 다룬다; - STARTTLS/TLS, AUTH, DSN, connection reuse, server response, timeout, duplicate semantics를 exact provider card가 정의해야 한다; - generic SMTP success를 inbox delivery/read로 표현하지 않는다; - port 25 fallback, opportunistic TLS 또는 plaintext credential을 허용하지 않는다. ## 20. receipt, reconciliation과 suppression workflow ### 20.1 submission과 recipient outcome 분리 submission control과 recipient lifecycle fact를 별도로 보존한다. ```text submission: appended -> wire-authorized -> accepted | definitely-rejected | indeterminate receipt facts: SEND | REJECT | BOUNCE | COMPLAINT | DELIVERY | DELIVERY_DELAY orthogonal projections: submission + recipientTransport + abuse + conversationPresence ``` provider accepted 뒤에도 bounce/complaint가 올 수 있으므로 receipt가 submission fact를 덮어쓰지 않는다. complaint도 transport projection과 공존한다. summary projection은 모든 축을 함께 보여준다. ### 20.2 callback verification raw callback inbound adapter는 provider transport별로 다음을 적용한다. - raw body size/content-type/method limit; - provider-defined signed representation에 대한 signature/authenticity 검증. raw bytes가 signature contract인 transport만 exact raw bytes를 사용; - provider retry semantics에 맞는 replay/dedupe window; - expected account/topic/configuration set/workspace allowlist; - verification key/secret rotation; - constant-time comparison where applicable; - event ID digest uniqueness; - batch event count/depth/string limit; - unknown schema/version quarantine; - raw header/body/DTO를 application으로 넘기지 않음. 검증 성공 뒤에만 `NormalizedNotificationReceiptCommand`를 만든다. SES R2의 SNS HTTPS ingress는 generic webhook secret으로 검증하지 않는다. - bounded JSON parse 뒤 SNS가 정의한 field canonical string을 구성하고 `SignatureVersion=2`를 검증한다; - `SigningCertURL`은 HTTPS, allowlisted AWS SNS host/path, DNS/IP/redirect와 certificate chain을 검증해 SSRF/host confusion을 막고 bounded cache/deadline으로 가져온다; - exact TopicArn의 account/region/name과 canonical binding을 비교한다; - exact SES card의 `max_callback_age = bounded SNS HTTP retry horizon + bounded DLQ retention/redrive horizon + allowed clock skew`를 checked-in ingress profile로 고정하고 Task 19가 실제 topology와 대조한다. initial `ses-notification-v1`은 각각 `1h + 7d + 5m = 7d1h5m`이고 outer tombstone은 ingestion safety margin `1h`를 더 길게 덮는 `8d`, inner semantic tombstone은 `30d`다. 이 window 안의 정상 delayed retry는 `Timestamp`만으로 거부하지 않는다. allowed skew보다 미래인 timestamp와 window를 초과한 envelope는 signature가 유효해도 receipt/quarantine DB mutation 없이 4xx로 거부하고 bounded security metric만 남긴다; - outer SNS `MessageId`와 inner SES semantic fingerprint를 HMAC dedupe한다. 두 tombstone은 `max_callback_age + ingestion safety margin`보다 길고, inner semantic tombstone은 승인된 manual redrive window 전체를 덮는다; - `SubscriptionConfirmation`/`UnsubscribeConfirmation`의 arbitrary `SubscribeURL`을 runtime에서 자동 fetch하지 않는다. IaC 또는 별도 인증·승인된 운영 절차가 exact TopicArn을 확인해 subscription을 확정한다; - normalized receipt transaction이 commit된 뒤에만 success ACK를 반환한다. transient failure는 SNS retry를 유도하고 exhausted delivery는 configured DLQ에서 replay한다. - original outer envelope가 max age를 지난 수동 DLQ replay는 public endpoint에 그대로 재주입하지 않는다. 별도 인증·승인된 운영 절차가 exact TopicArn으로 inner SES event를 republish해 새 SNS outer `MessageId/Timestamp/signature`를 만들고, inner semantic fingerprint는 그대로 유지한다. semantic tombstone retention을 지난 replay는 projection을 변경하지 않는 forensic 절차를 새로 승인하지 않는 한 거부한다. 공식 기준: [SNS message signature verification](https://docs.aws.amazon.com/sns/latest/dg/sns-verify-signature-of-message.html), [SNS HTTP delivery retry](https://docs.aws.amazon.com/sns/latest/dg/sns-message-delivery-retries.html). ### 20.3 duplicate/out-of-order/orphan - 같은 provider event ID는 idempotent duplicate다; - SNS outer retry는 `(TopicArn, MessageId)`로 dedupe하고 SES inner semantic fingerprint는 versioned HMAC over `(provider binding revision, SES messageId, normalized event type, one-recipient digest, documented provider occurrence discriminator)`로 계산한다; - provider message reference와 event ID가 충돌하면 quarantine한다; - accepted finalize보다 callback이 먼저 오면 `ORPHAN` inbox에 저장한다; - later attach worker가 bounded window 동안 재매칭한다; - reducer는 같은 verified fact set의 모든 arrival permutation에서 같은 projection을 만든다; - semantic conflict만 provider semantics table에 따라 audit/quarantine하고 단순 out-of-order를 이전 state overwrite로 처리하지 않는다; - callback retry 응답은 receipt transaction commit 여부와 일치시킨다. ### 20.4 reconciliation reconciliation은 provider card가 명시적으로 지원하는 경우만 호출한다. ```text ReconcileOutcome = CONFIRMED_ACCEPTED CONFIRMED_NOT_APPLIED STILL_IN_PROGRESS STILL_INDETERMINATE RECONCILIATION_UNSUPPORTED ``` absence가 not-applied를 증명하는지 provider별로 검증한다. list/history API에서 못 찾았다는 사실만으로 definite failure를 만들지 않는다. reconcile call에도 별도 deadline, quota, max count와 total amplification budget을 적용한다. ### 20.5 cancellation intent cancellation은 아직 시작하지 않은 delivery를 막는 best effort control이다. - `QUEUED/RETRY_WAIT`은 token/version guarded하게 cancel할 수 있다; - `CLAIMED/ATTEMPT_RESERVED`도 `WIRE_AUTHORIZED` 전에 token/version guarded하게 cancel할 수 있다; - `WIRE_AUTHORIZED/RECONCILE_WAIT/TERMINAL_INDETERMINATE/PROVIDER_ACCEPTED`를 not-sent로 바꾸지 않는다; - cancel과 wire authorization 중 먼저 commit된 transition이 이긴다; - Slack message delete나 email recall을 generic cancellation으로 약속하지 않는다; - provider-specific delete/update는 별도 operation/capability card다. ### 20.6 manual operation operator action은 다음 bounded use case만 허용한다. - inspect summary and safe reason codes; - pause/resume route dispatcher; - retry definite-not-sent terminal with new audited occurrence; - request reconciliation; - attach/quarantine orphan receipt; - expire/redact payload; - rotate/restore required template or encryption revision. raw DB state edit, arbitrary resend, suppression row 직접 삭제는 runbook 정식 동작이 아니다. ## 21. canonical configuration ### 21.1 source of truth 현재 channel-wide selector와 legacy enabled boolean을 폐기하고 하나의 canonical graph로 activation을 결정한다. 개념 예: ```yaml app: notification: expected-state: disabled | configured expected-binding-ids: [security-email, engineering-alerts] expected-writer-generations: security-email-v3: 12 engineering-alerts-v2: 8 bindings: security-email: notification-kind: security-alert-email channel: email expected-mode: durable-async strategy: single route-revision: security-email-v3 template: security-alert-v4 providers: [aws-ses-primary] engineering-alerts: notification-kind: engineering-alert-slack channel: slack expected-mode: best-effort-inline strategy: single route-revision: engineering-alerts-v2 template: engineering-alert-v2 providers: [slack-web-api-primary] providers: aws-ses-primary: type: aws-ses-v2 region: ap-northeast-2 credential-ref: workload-role expected-credential-source: web-identity from-identity-ref: transactional-sender configuration-set: notification-events-v1 feedback: type: sns-https topic-arn: ${APP_NOTIFICATION_SES_TOPIC_ARN} expected-signature-version: "2" ingress-profile: ses-notification-v1 dlq-ref: notification-events-dlq slack-web-api-primary: type: slack-web-api workspace-ref: engineering-workspace token-ref: slack-bot-primary destination-ref: engineering-alert-channel ``` 실제 properties type과 env mapping은 implementation plan에서 env-key registry와 validation grammar를 함께 정의한다. 위 YAML은 의미 예시이며 현재 동작하는 설정이 아니다. checked-in `NotificationCanonicalRouteCatalog`가 writer fence route key의 retained SSOT다. 그 key set은 canonical binding graph와 post-migration fresh provisioning의 reviewed initial key set에 exact equality여야 한다. PRE-only `NotificationCutoverRouteCatalog`는 canonical key를 추가/삭제하지 못하고 route별 legacy alias와 bounded current+retiring transport profile registry만 장식한다. registry는 active admission profile 하나와 각 revision의 proof class (`HARD_BOUND_PROVEN | QUIESCENCE_REQUIRED`)/evidence revision을 고정한다. runtime canonical target generation map은 별도 reviewed config/evidence revision이고 catalog key를 추가/삭제할 수 없다. legacy settings나 현재 consumer 수는 이 set을 축소하지 못한다. production consumer 0 또는 live legacy mapping이 없는 route도 PRE에서는 closed LEGACY predecessor fence로 batch 초기화한 뒤 동일한 audited drain/complete를 거치며, direct canonical seed는 FINAL의 independent issuer가 irreversible no-legacy-authority fence를 먼저 commit한 뒤 서명한 DB-birth authorization을 exact two-function protocol로 검증한 `notificationFreshProvisioning`에만 허용한다. bootstrap은 canonical catalog key와 exact runtime target map을 retained application-owned canonical route set으로 변환하고, PRE에서만 cutover proof decorator를 transitional writer route set에 더한다. legacy/canonical composition, initializer와 migration cross-check는 같은 key-set digest를 사용한다. permit acquire는 active profile만 사용하고 timeout, attestation과 COMPLETE는 request나 현재 permit rows만으로 proof class를 추론하지 않는다. batch initialization이 compiled PRE current+retiring registry를 retained `notification_writer_transport_proof_registry`에 같은 root transaction으로 동결하고, 이후 PRE runtime은 양쪽 exact equality를 검증한다. unknown/omitted historical blocking profile은 fail closed하고 permit/attestation/operation history가 참조하는 retiring profile은 제거하지 못한다. permit 0인 route도 persisted active profile이 `QUIESCENCE_REQUIRED`이면 attestation이 필수다. `HARD_BOUND_PROVEN`은 reviewed deadline/ cancellation integration evidence revision이 catalog와 qualification manifest에 일치할 때만 허용하며, current R0 profile은 `QUIESCENCE_REQUIRED`다. ### 21.2 `expected-state` ```text disabled + legacy config absent -> PURE_DISABLED -> canonical binding 0 -> canonical/legacy provider, client, dispatcher, operator and table scan 0 disabled + exact legacy-only config + PRE_CUTOVER_BRIDGE artifact -> PRE_LEGACY_BRIDGE -> canonical binding/provider/store/worker 0 -> transitional initializer/fence/permit/operator와 selected legacy provider만 구성 -> audited initialization 전 legacy admission/provider call 0 configured -> CANONICAL_CONFIGURED -> actual binding ID set == expected-binding-ids -> compiled route-revision key set == expected-writer-generations key set -> FINAL/REQUIRE_CANONICAL이면 persisted fence set == ACTIVE/CANONICAL exact target set -> PRE이면 exact predecessor/target closed state set을 허용하고 route별 owner match만 admission -> 모든 required provider/template/store/worker가 exact graph를 만족 -> 누락/unknown/mismatch면 startup failure ``` blank selector를 암묵적으로 disabled로 해석하는 것과 운영자가 configured를 기대했는데 실제 binding 0인 것을 구분한다. configured에서 missing/extra binding 하나라도 실패하며 “하나 이상” 검사로 필수 SES binding의 소실을 숨기지 않는다. release artifact에는 sorted exact graph의 manifest digest와 evidence revision도 남긴다. canonical configured와 어떤 legacy key도 같은 process에서 공존할 수 없다. PRE legacy bridge는 pure disabled의 예외가 아니라 별도 closed composition state이고 FINAL artifact에서는 존재할 수 없다. writer generation map은 runtime cutover revision assertion이며 secret이 아니다. route key는 retained canonical catalog에서만 오고 값은 canonical target generation이다. legacy predecessor는 `target - 1`이며 missing/extra/unknown/overflow를 허용하지 않는다. abort로 generation이 바뀌면 config/evidence revision도 바뀌며 canonical node는 새 exact set으로 재배포되기 전 fail closed한다. dark legacy bridge는 같은 map의 predecessor set을 batch init에 사용하되 fence absent/partial 상태에서는 startup endpoint만 열고 provider admission은 0이다. PRE artifact에서 canonical-only graph가 exact catalog key set과 persisted closed predecessor/ target state를 만나면 bootstrap이 `CUTOVER_WAIT`를 내부적으로 derive한다. request, env 또는 generic property로 이 mode를 선택할 수 없다. process liveness는 유지하지만 predecessor/ DRAINING route의 admission, claim, provider call은 0이고 notification readiness는 `CUTOVER_WAIT`다. route가 committed `ACTIVE/CANONICAL@target`이 된 것을 fresh DB read로 확인한 뒤에만 그 route를 동적으로 연다. FINAL artifact는 CUTOVER_WAIT production branch를 제거하고 모든 route가 exact canonical target이 아니면 startup을 실패시킨다. ### 21.3 binding compile notification-local compiler 결과와 application compatibility validator가 합성한 최종 composition contract는 다음 exact tuple이다. ```text effective binding = notification kind + application NotificationKindPolicy revision/mode/admission class + config expected-mode assertion + route revision + channel + strategy + template/version/checksum/locale set + ordered provider target revisions + required provider capability + attempt/reconcile/amplification limits + receipt expectation + provider-local runtime profile ``` notification-local compiler가 검증할 항목: - code catalog에 없는 route/template/provider type 거부; - duplicate/empty/cyclic binding 거부; - channel/provider mismatch 거부; - durable route + legacy/fail-open provider 거부; - receipt-required route + receipt-unsupported provider 거부; - fallback + indeterminate-unsafe chain 거부; - bound 초과의 target/retry/reconcile 거부; - unknown properties fail closed. application pure compatibility validator와 bootstrap composition이 별도로 검증할 항목: - `NotificationKindPolicy.mode`와 config `expected-mode` 불일치; - actual/expected binding ID exact set와 release manifest digest; - persistence store/schema/crypto descriptor와 durable policy; - receipt-required card와 inbound SNS ingress descriptor; - send/receipt slice의 account/region/configuration set/topic identity 일치; - live/retained intent가 참조하는 모든 revision의 가용성; - required worker/readiness/evidence manifest의 exact composition. notification-local compiler가 persistence/inbound sibling을 탐색하지 않는다. bootstrap은 §13.1의 provider-neutral descriptor를 application validator에 전달할 뿐 business/retry/fallback 정책을 settings/configuration class에 구현하지 않는다. ### 21.4 legacy migration legacy: ```text app.notification.slack.provider app.notification.email.provider app.notification.slack-webhook.enabled app.notification.google-email.enabled app.notification.routes.* ``` canonical graph와 legacy key가 동시에 나타나면 값이 같더라도 startup을 실패시킨다. temporary migration translator를 두더라도 한 방향으로만 변환하고 deprecation telemetry와 removal deadline을 둔다. ## 22. activation과 zero-resource contract `PURE_DISABLED`, 즉 canonical `expected-state=disabled`이면서 legacy key도 없는 상태에서 다음이 0이어야 한다. - Slack/AWS/Gmail/SMTP client; - provider credential resolution; - HTTP connection/pool; - dispatcher scheduler/thread/executor; - claim/reconcile/reaper scan; - rate limiter bucket background task; - provider startup network probe; - provider health indicator; - callback subscription expectation; - application/runtime notification table DML과 scan. framework가 settings metadata 또는 harmless validator를 생성하는 것은 가능하지만 external resource/secret/worker side effect는 없어야 한다. expand-first V7 DDL과 V8의 structural classification은 feature flag로 gate하지 않는 migration lifecycle이므로 이 runtime zero-resource 계수에서 제외한다. V8이 `AWAITING_SIGNED_FRESH_PROVISIONING`을 남긴 경우 별도 provisioning job 전까지 normal application startup/readiness, bean/resource/DML/scan/provider call은 0이어야 한다. `notificationFreshProvisioning`은 issuer가 먼저 commit한 irreversible no-legacy-authority fence가 포함된 signed DB-birth authorization을 받고, 같은 provisioner transaction에서 snapshot/read-lock -> Java verifier -> apply 순서로 실행하는 exact one-shot deployment operation이며 runtime zero-resource 경로에 포함하지 않는다. `PRE_LEGACY_BRIDGE`는 zero-resource 상태가 아니다. canonical provider/store/dispatcher/ readiness resource는 0이지만 exact legacy provider와 transitional initializer/fence/permit/ operator surface는 의도적으로 존재한다. batch initialization 전에는 그 surface도 provider call 0이며, 이후 legacy send는 committed permit을 반드시 거친다. composition/zero-resource/ fenced-legacy tests는 PURE_DISABLED, PRE_LEGACY_BRIDGE와 CANONICAL_CONFIGURED를 별도 fixture로 검증하고 same-process legacy+canonical overlap을 거부한다. 한 channel만 binding되면 다른 channel provider/client는 생성하지 않는다. durable binding 없이 best-effort만 있으면 persistence dispatcher를 만들지 않는다. feedback-required SES binding이 없으면 receipt reconciliation worker를 만들지 않는다. optional consumer가 port를 호출했는데 해당 route binding이 없으면 `NotificationCapabilityUnavailable` 같은 typed failure를 반환한다. silent no-op bean을 만들지 않는다. ## 23. deadline, resource와 capacity ### 23.1 deadline을 분리한다 | deadline/window | 의미 | | --- | --- | | append deadline | business transaction 안 intent 저장 한도 | | dispatch eligibility | `notBefore` | | claim lease | worker ownership 한도 | | per-attempt deadline | 한 authorized provider call의 monotonic budget | | retry horizon | first eligibility부터 retry 가능한 총 기간 | | intent expiry | 이후 새 send를 시작하지 않는 business limit | | reconciliation horizon | unknown attempt를 확인할 최대 기간 | | receipt window | delayed feedback를 attach할 기간 | | payload retention | encrypted recipient/parameter 보존 기간 | | dedupe tombstone | duplicate source/callback을 막을 보존 기간 | 이 값을 하나의 `timeout`으로 합치지 않는다. caller deadline과 route maximum의 intersection을 사용하고 wall-clock rollback이 elapsed attempt budget을 늘리지 않도록 monotonic time을 사용한다. durable scheduling timestamp는 DB/UTC wall time을 사용하되 elapsed attempt deadline과 구분한다. ### 23.2 concurrency bounded control: ```text global dispatcher concurrency per-provider concurrency per-account/workspace concurrency per-destination/channel rate bucket claim batch size max in-memory rendered bytes max outstanding attempts max receipt batch/events ``` virtual thread를 사용해도 admission 상한이 사라지지 않는다. provider client 내부 queue와 local worker queue를 모두 유한하게 둔다. ### 23.3 backpressure - DB backlog가 높으면 claim batch/concurrency를 bounded하게 조절한다; - provider quota가 낮아도 hot-loop claim/release를 하지 않는다; - retry storm에 full jitter를 사용한다; - critical route와 bulk/low-value route의 admission partition을 분리할 수 있다; - priority가 starvation을 만들지 않도록 aging/weight를 명시한다; - expiry 가까운 intent를 무조건 먼저 보내 privacy/consent를 우회하지 않는다; - overload 시 best-effort와 durable append 정책을 별도로 정의한다. ### 23.4 capacity equation 최소 capacity review는 다음을 계산한다. ```text incoming durable intents/sec × 1 logical recipient per intent × provider legs per recipient × expected physical attempts + reconciliation calls + callback events ``` worst-case는 route별 amplification cap으로 계산한다. provider advertised throughput만 보지 않고 DB claim/finalize TPS, encryption/render CPU, callback burst와 retention storage를 함께 측정한다. ## 24. security, privacy와 retention ### 24.1 data classification | 데이터 | 기본 분류 | 저장 | | --- | --- | --- | | recipient address/channel mapping | PII/secret 가능 | versioned direct-AEAD ciphertext + lookup HMAC | | template parameter | PII/business secret 가능 | versioned direct-AEAD ciphertext | | rendered body/subject | PII/business secret 가능 | 기본 미저장, 필요 시 짧은 encrypted retention | | provider token/webhook URL/AWS credential | secret | secret manager reference only | | provider message reference | high-cardinality, 간접 PII 가능 | encrypted/opaque + optional HMAC | | intent/delivery/attempt opaque ID | internal identifier | log 허용, metric tag 제한 검토 | | route/template/provider/reason code | bounded operational metadata | log/metric 허용 | ### 24.2 versioned direct AEAD와 key ownership 최소 R2는 “envelope encryption”을 주장하지 않고 versioned direct AEAD를 선택한다. - algorithm profile은 `DIRECT_AEAD_AES_256_GCM_V1`로 고정하고 field마다 CSPRNG 96-bit nonce와 128-bit authentication tag를 사용한다. 같은 key에서 nonce 재사용을 허용하지 않는다; - canonical AAD는 versioned length-prefix encoding으로 `schema/table + record ID + notification ID + optional delivery/attempt ID + field purpose + provider binding revision + crypto profile version`을 묶어 row/field swapping을 막는다; - DB에는 ciphertext/tag, nonce, algorithm/profile, non-secret key reference와 key version만 저장한다. raw key/credential은 settings record, application value, persistence entity, log, metric, backup/export에 넣지 않는다; - 새 encrypt는 current key, decrypt는 live/retained row가 참조하는 current/retiring exact version을 사용한다. retiring row는 decrypt-reencrypt migration과 evidence 뒤 제거한다; - 검색/dedupe는 별도 purpose-separated keyed HMAC과 §10.2의 alias/re-HMAC rotation protocol을 사용한다; - old AEAD/HMAC key 폐기 전 active/backlog뿐 아니라 retained intent, suppression, callback dedupe, orphan, message-reference lookup, tombstone과 backup retention을 scan한다. 현재 `SecretSource`는 `app-bootstrap` 소유이며 `Optional`을 반환하므로 notification/persistence leaf가 이를 import하지 않는다. 각 consuming adapter는 `CredentialMaterialProvider` 또는 `PayloadKeyMaterialProvider` 같은 최소 framework-free factory/handle contract를 자기 leaf에 두고, bootstrap이 canonical secret reference를 현재 `SecretSource`로 resolve해 bridge를 구현한다. 반환 material은 version이 붙은 `AutoCloseable` char/byte handle로 adapter 내부에서만 짧게 사용하고 close 시 wipe한다. 장기 provider client가 credential refresh를 요구하면 reference 기반 provider가 매번 새 handle을 받고 generation을 검증한다. settings에는 secret reference만 남긴다. bootstrap은 adapter factory를 조합할 수 있지만 adapter는 bootstrap type에 의존하지 않는다. current string-based `SecretSource`에서 생기는 immutable String copy 최소화/wiping 한계와 binary secret 지원은 구현 계획의 bootstrap 변경·테스트 항목으로 명시한다. key rotation은 current/retiring handle factory를 원자 교체하고 기존 in-flight attempt가 frozen credential/key generation을 잃지 않는 protocol로 검증한다. ### 24.3 safe value type recipient/parameter/provider response value의 `toString()`은 redacted 형태여야 한다. Java record의 자동 `toString()`에 raw email/body가 노출되는 현재 `Notification`을 durable path에서 재사용하지 않는다. exception message, assertion failure, structured log argument, span event에도 raw value를 넣지 않는다. debug profile도 이 원칙을 완화하지 않는다. ### 24.4 retention retention class는 notification kind가 고정한다. - terminal 뒤 provider retry/reconciliation에 필요 없는 ciphertext를 먼저 redact/delete한다; - dedupe digest/tombstone은 source retry와 provider/callback replay window보다 길게 유지한다. SES/SNS outer/semantic tombstone은 exact `max callback age + ingestion safety margin`보다 길고 semantic tombstone은 승인된 manual redrive horizon도 덮어야 한다; - recipient/message-reference/correlation digest는 SNS retry/DLQ와 orphan attach window가 끝날 때까지 유지한다. indefinite suppression은 ciphertext 삭제 전에 current-key alias/re-HMAC을 완료한다; - dead/indeterminate row가 PII 무기한 보관 수단이 되지 않게 maximum retention을 둔다; - orphan receipt evidence는 bounded attach window 뒤 quarantine summary만 남긴다; - audit상 content 보존이 필요하면 목적/기간/access/key deletion을 별도 승인한다; - delete는 delivery fact/aggregate metric과 content ciphertext를 분리한다. ### 24.5 email security - verified sender identity를 route에 고정한다; - SPF, DKIM, DMARC alignment와 bounce/complaint monitoring을 production readiness에 포함한다; - marketing/상업성 email에 필요한 unsubscribe header/one-click semantics는 legal/product policy와 함께 별도 notification kind에서 강제한다; - header injection, display-name spoofing, external link policy를 test한다; - SES account/region/configuration set drift를 readiness에서 검출한다. ### 24.6 Slack security - bot token과 webhook URL을 secret으로 취급한다; - route가 고정한 workspace/channel 외 전송을 막는다; - public-wide posting scope와 user token/impersonation을 default로 사용하지 않는다; - Block Kit link/mention/metadata에 secret/PII를 넣지 않는다; - token rotation/revocation 뒤 old generation의 in-flight outcome을 indeterminate로 잘못 downgrade하지 않는다. ### 24.7 callback security provider event가 직접 suppression 또는 delivery를 바꾸므로 callback은 일반 telemetry webhook이 아니다. signature 검증 실패, unexpected topic/account/workspace, transport contract에 어긋난 timestamp/replay, oversize, schema drift는 성공으로 흘려보내지 않고 bounded quarantine/metric을 남긴다. SNS는 exact max-callback-age 안의 늦은 정상 retry를 timestamp만으로 거부하지 않고 message/semantic dedupe를 사용한다. 그 age를 넘긴 signed outer envelope는 mutation 없이 거부하고, manual redrive는 새 outer envelope와 보존된 inner semantic fingerprint를 요구한다. ## 25. observability ### 25.1 metrics 허용할 bounded tag 예: ```text channel provider_type/provider_binding route_id template_id/version mode strategy outcome/reason_code attempt_bucket readiness_card_revision ``` 금지 tag: ```text recipient/address body/subject/parameter provider message ID Slack channel ID/workspace ID raw value tenant/user/source operation raw ID intent/idempotency/correlation ID token/webhook/endpoint exception message ``` 핵심 metric: ```text notification_intent_append_total notification_delivery_backlog notification_oldest_eligible_age notification_attempt_total notification_attempt_duration notification_indeterminate_total notification_retry_scheduled_total notification_reconcile_total notification_receipt_total notification_orphan_receipt_total notification_suppression_total notification_payload_redaction_lag notification_claim_conflict_total notification_expired_total notification_admission_gate_park_total notification_parked_delivery_count ``` summary `success rate`는 submission/recipient outcome을 섞지 않고 별도 metric으로 표시한다. ### 25.2 traces 권장 span: ```text notification.request notification.intent.append notification.dispatch.claim notification.render notification.provider.attempt notification.reconcile notification.receipt.verify notification.receipt.apply notification.retention.redact ``` durable worker는 persisted trace link/correlation을 사용하며 원래 request span을 며칠간 parent로 열어두지 않는다. baggage를 provider request에 자동 전파하지 않는다. ### 25.3 logs structured log에는 opaque internal ID와 bounded codes만 쓴다. ```text intent_id delivery_id attempt_id channel route_id provider_binding state_from/state_to reason_code claim_owner_hash [필요 시] ``` provider raw response/error payload, recipient/content, credential, message reference는 기본 log 금지다. 필요 evidence는 allowlisted parsed code와 short digest로 남긴다. ### 25.4 audit 다음 action은 audit 대상이다. - route/template/provider revision activation; - route writer `INITIALIZE_LEGACY`, `INITIALIZE_CANONICAL_FRESH`, `BEGIN_DRAIN`, `TERMINALIZE_EXPIRED_PERMITS`, `COMPLETE_SWITCH`, `ABORT_DRAIN`; - retained signed writer inventory/quiescence evidence, fresh-install provenance와 finalization discriminator/provisioning; - critical route pause/resume; - manual retry/reconcile/cancel; - suppression add/remove; - orphan receipt attach/quarantine; - encryption/template old revision retirement; - operator payload access/redaction override. audit에는 actor/authorization/reason/revision과 opaque target만 기록하고 raw notification content를 복제하지 않는다. ## 26. startup, health와 readiness ### 26.1 liveness application liveness는 Slack/SES/network/DB backlog와 독립이다. provider outage나 quota exhaustion 때문에 process liveness를 실패시켜 restart loop를 만들지 않는다. ### 26.2 startup validation startup에서 network send 없이 다음을 검증한다. - `expected-state`, `expected-binding-ids`와 canonical graph manifest; - notification-local code catalog와 config route/provider/template revision; - template asset checksum/schema/locale/output static bounds; - provider credential/identity reference의 존재와 형식; - application validator가 받은 persistence/crypto/worker와 inbound receipt descriptor; - route mode/strategy와 provider capability compatibility; - retry/fallback/amplification bound; - callback-required route의 send/receipt profile identity 일치; - live/retained intent가 참조하는 모든 revision 가용성; - legacy/canonical key conflict; - compiled cutover route key set, canonical target generations와 persisted predecessor/target fence state machine, retained signed inventory/attestation/provenance/finalization integrity와 Java Ed25519 재검증; - disabled/zero binding resource 0. notification-local compiler는 sibling bean/store/controller를 직접 탐색하지 않는다. `app-bootstrap`이 각 adapter descriptor를 application의 pure compatibility validator에 전달해 최종 composition을 판정한다. 실제 provider credential validity/account state를 확인하는 network probe는 startup bean construction과 분리한다. provider outage가 process boot를 무한 지연시키지 않도록 finite deadline, cache와 readiness semantics를 둔다. ### 26.3 readiness readiness는 active required binding만 평가한다. ```text required binding ready = compiled graph valid AND actual binding IDs exactly match expected set AND required template revisions loaded AND durable store reachable/schema compatible [durable only] AND encryption/key refs usable AND provider account/profile check acceptable AND required route/provider/account admission gates ACTIVE AND dispatcher admission running [durable only] AND callback topology expected state met [receipt-required only] ``` PRE canonical-only node가 exact predecessor/DRAINING state를 관측하면 application liveness는 healthy지만 notification readiness는 `CUTOVER_WAIT`이고 해당 route admission/worker/provider call은 0이다. exact canonical target으로 committed 전이한 route만 fresh read 뒤 활성화한다. partial/extra/unrelated generation, owner drift 또는 rollback generation 변화는 즉시 route를 닫고 readiness를 내린다. FINAL은 wait state가 없으며 exact all-canonical set이 아니면 startup failure다. best-effort optional binding outage가 전체 service readiness를 실패시킬지는 bootstrap의 reviewed required/optional policy가 정한다. global “all notification provider healthy” boolean로 합치지 않는다. ### 26.4 provider health probe - 실제 user/channel/email에 synthetic message를 보내지 않는다; - Slack `auth.test`는 token/team/bot identity 확인에만 사용하고 health를 위해 read scope를 추가하지 않는다. channel write access는 sandbox qualification 또는 실제 bounded send evidence로 증명하며, route 기능에 필요하지 않은 `conversations.info/history` scope를 health 전용으로 요구하지 않는다; - SES는 account sending status/quota/identity/configuration set을 safe control-plane call로 확인하고 exact resolved credential source/account/region을 card와 비교한다; - network probe는 bounded cache/jitter를 사용한다; - probe failure를 send outcome으로 사용하지 않는다; - disabled provider는 probe하지 않는다; - readiness component 이름/tag에 secret/destination raw ID를 넣지 않는다. ### 26.5 backlog health provider reachable 여부와 별도로 다음을 본다. - oldest eligible delivery age; - retry/reconcile lag; - expired-before-attempt rate; - indeterminate accumulation; - orphan receipt accumulation; - payload redaction/key retirement lag; - claim conflict/stale lease rate; - provider quota headroom. - parked admission gate/leg count와 oldest parked age. health threshold는 alert/runbook 신호이며 liveness restart trigger로 자동 재사용하지 않는다. ## 27. lifecycle와 deployment safety ### 27.1 startup order ```text settings bind/validate -> catalog/template manifest load -> binding compile -> store schema/key/provider dependency validate -> provider clients construct -> readiness components register -> dispatcher admission open ``` compile 실패 뒤 일부 provider client/worker를 남기지 않는다. ### 27.2 graceful shutdown 1. 신규 claim/admission을 닫는다; 2. 이미 claim했지만 send 전인 row를 safe release 또는 lease expiry 대상으로 표시한다; 3. in-flight attempt를 bounded grace 동안 기다린다; 4. wire call을 취소했더라도 possible-send는 reconcile path 또는 `TERMINAL_INDETERMINATE`로 finalize하려 시도한다; 5. finalize 실패 시 lease/reaper가 reconcile path로 보내도록 durable evidence를 남긴다; 6. callback intake는 load balancer drain과 transaction completion 순서를 맞춘다; 7. provider client/executor를 닫는다. shutdown timeout 뒤 interrupt를 `DEFINITELY_NOT_APPLIED`로 해석하지 않는다. ### 27.3 rolling deployment - expand schema가 구/신 version 모두와 호환된 뒤 code를 배포한다; - 모든 live/retained intent가 참조하는 route/template/provider/renderer revision을 backlog와 retention horizon 동안 유지한다; - old worker와 new worker가 같은 row를 처리해도 owner token/version이 stale finalize를 막는다; - state enum 추가는 unknown value로 old node가 row를 손상하지 않게 rollout한다; - credential/key/template revision retirement는 active/backlog/retention scan 뒤 진행한다; - rollback 가능한 기간 동안 new-only state와 ciphertext를 old code가 읽지 못하는 문제를 검증한다. ### 27.4 clock - persisted schedule/expiry/provider occurred time은 UTC instant로 저장한다; - elapsed provider deadline은 monotonic source를 쓴다; - DB claim eligibility가 DB clock인지 application clock인지 하나로 고정한다; - provider callback timestamp는 trusted ordering evidence로 바로 사용하지 않고 verification window와 server received time을 함께 기록한다; - NTP drift alert를 운영 prerequisite에 둔다. ## 28. error taxonomy와 application mapping ### 28.1 stable internal reason reason code family: ```text CONFIGURATION_* CAPABILITY_UNAVAILABLE INTENT_DUPLICATE INTENT_FINGERPRINT_MISMATCH BUSINESS_POLICY_REJECTED TEMPLATE_* RECIPIENT_* ADMISSION_* PROVIDER_THROTTLED PROVIDER_AUTHORIZATION_REJECTED PROVIDER_REQUEST_REJECTED PROVIDER_ACCEPTED PROVIDER_RESPONSE_INDETERMINATE BINDING_PARKED BINDING_RESUMED RECONCILIATION_* RECEIPT_* SUPPRESSED_* CLAIM_* ENCRYPTION_* EXPIRED ``` provider raw error code는 allowlisted mapping table을 통과해 stable reason code가 된다. unknown provider error text를 exception/log/metric에 복제하지 않는다. ### 28.2 application failure application-facing error는 대략 다음으로 제한한다. ```text NotificationCapabilityUnavailable NotificationRequestRejected NotificationIntentConflict NotificationIntentPersistenceFailure NotificationDispatchConflict NotificationOutcomeIndeterminate ``` feature use case는 자신의 failure policy에 따라 이를 business error 또는 asynchronous operational state로 mapping한다. controller가 provider status/SDK exception을 직접 mapping하지 않는다. ### 28.3 inbound error callback inbound adapter는 signature/auth/size/schema failure를 transport status로 정확히 반환하되 raw reason을 외부에 과다 노출하지 않는다. verified duplicate는 idempotent acknowledgement, transient store failure는 provider retry를 유도하는 response, permanent invalid event는 provider contract에 맞는 bounded response로 mapping한다. ## 29. test, CI와 evidence strategy ### 29.1 application-core test - feature-specific port가 reviewed kind/route/mode만 선택; - consent/preference/quiet-hours/not-before/expiry; - source operation idempotency와 fingerprint mismatch; - typed parameter/recipient value validation과 redacted `toString()`; - best-effort와 durable result 의미; - dispatch state transition table; - definite/retryable/permanent/indeterminate decision; - `PARK_BINDING`과 initial fallback hold policy; - fallback activation과 block; - total amplification budget; - cancellation/expiry와 maybe-sent 보존; - callback duplicate/orphan/out-of-order command semantics. Spring, provider SDK, persistence entity 없이 fake port/clock을 사용한다. ### 29.2 notification adapter test - code catalog와 canonical binding compiler; - duplicate/unknown/mismatch/legacy conflict; - SINGLE/FAN_OUT_ALL/ORDERED_FALLBACK; - frozen plan/revision compatibility; - template manifest/checksum/schema/locale fallback; - text/HTML/Slack escaping과 injection property test; - size/count/depth/control-character limit; - provider error/outcome exact mapping; - SDK hidden retry 0 또는 physical attempt count evidence; - deadline/cancellation/response-loss indeterminate; - provider descriptor/card compatibility; - disabled/partial binding zero client/thread/probe; - no PII/secret log, metric tag, exception. ### 29.3 persistence-jpa integration test real PostgreSQL에서 다음을 검증한다. - business write + intent append same transaction commit/rollback; - `TransactionPort.inRootWrite` physical commit-before-return과 ambient transaction fail-fast; - outer REQUIRED transaction 안 best-effort 호출 rejection/rollback 시 provider call 0; - same idempotency/same fingerprint와 mismatch; - encrypted payload와 plaintext absence; - concurrent `SKIP LOCKED` claim; - owner token + expected state/version finalize; - stale worker conflict; - claim crash before/after `WIRE_AUTHORIZED`; - lease 만료 뒤 늦은 exact provider result의 terminal-once append/projection merge; - provider accepted 뒤 finalize failure; - retry/backoff/expiry query; - fan-out partial state; - fallback activation atomicity; - multi-node gate park CAS, restart persistence와 audited resume; - park/resume/fallback/expiry 경쟁; - cancellation/expiry/suppression과 wire authorization 경쟁; - duplicate/out-of-order/orphan receipt; - callback receipt apply와 delivery projection transaction; - retention/redaction, AEAD/HMAC rotation 전후 dedupe/suppression matching; - indexes/query plan/backlog capacity. H2-only test로 PostgreSQL lock/concurrency evidence를 대체하지 않는다. ### 29.4 inbound callback test - provider-defined signed representation authenticity; - SNS SignatureVersion 2 canonical string, cert URL/chain/SSRF와 delayed retry; - current/previous verification key rotation; - unexpected account/topic/workspace; - oversize/content-type/schema/depth; - batch partial invalid event policy; - duplicate acknowledgement; - transient store failure response; - no raw DTO/SDK type escape; - no sensitive body logging. ### 29.5 app-bootstrap composition test - expected-state와 expected-binding-id exact set; - canonical graph only; - binding별 bean/client/worker/readiness exact count; - durable binding에 store/worker/key 누락 시 startup failure; - synchronous best-effort use case의 root transaction port wiring; - receipt-required binding에 callback topology 누락 시 failure; - legacy/canonical conflict; - shutdown order와 in-flight classification; - selected provider dependency/classpath absence failure; - environment key registry와 sample/default YAML alignment. - FINAL startup/readiness가 application retained-evidence read use case만 호출하고 app-bootstrap repository/entity/JDBC 직접 접근이 0임; - cleanup 뒤 FRESH/UPGRADE full bounded evidence read와 Java verifier wiring이 유지되고 forged/mismatched retained row에서는 readiness/provider I/O가 0임. ### 29.6 local protocol/fault test real provider 호출 없는 deterministic server/fake에서: - exact request auth/header/body mapping; - response status/body/error mapping; - 429/retry-after; - timeout before connect/during possible write/after response; - truncated/malformed success response; - connection reset; - provider SDK actual invocation count; - cancellation and client resource close; - request body/response log redaction. mock이 provider semantics를 창작하지 않도록 fixture는 공식 contract의 allowlisted case만 구현한다. ### 29.7 Slack real-provider lane real network test는 `:adapter:outbound:notification:test`에 넣지 않는다. 그 focused test는 항상 offline deterministic test이며 credential/network 유무에 따른 skip/pass가 없어야 한다. `app-bootstrap` 소유의 명시적 opt-in `notificationReadiness` source set/harness가 별도 developer sandbox/workspace/channel에서 다음 safe smoke를 실행한다. - real `chat.postMessage`; - returned channel/ts와 optional conversation presence; - message rendering/escaping; - no production workspace/token; - cleanup/update/delete가 필요한 test message lifecycle. invalid scope/channel/auth, 429/`Retry-After`, timeout/response loss는 local official-contract protocol/fault suite에서 deterministic하게 매번 검증한다. 실제 channel throttling, credential rotation/revocation은 승인된 scheduled/manual destructive drill로 분리한다. 이 ownership을 구현할 때 notification leaf `CLAUDE.md`의 “no real network calls”는 focused module test에 계속 적용되며, app-bootstrap opt-in readiness harness의 소유권과 금지 범위를 함께 문서화하는 변경을 implementation deliverable로 포함한다. ### 29.8 SES real-provider lane 같은 app-bootstrap opt-in harness가 격리 AWS account/region, SES sandbox와 mailbox simulator/verified recipient에서 다음 safe smoke를 실행한다. - account/sandbox/sending state; - real `SendEmail`와 MessageId; - EmailTag correlation과 SNS `SEND/DELIVERY/BOUNCE/COMPLAINT` 중 card의 safe deterministic simulator case; - IAM least privilege; - exact credential source/account/region/configuration set/topic; - no production recipient; - feedback configuration set/account drift. invalid identity/auth/recipient, throttling/quota와 duplicate/out-of-order callback은 local protocol/inbound fault suite에서 검증한다. 실제 quota pressure, credential/key rotation, `DELIVERY_DELAY`, DLQ replay와 provider outage는 scheduled/manual drill로 분리한다. local-rendered card에는 invalid provider template/`RENDERING_FAILURE` drill을 요구하지 않는다. 실제 inbox placement/read를 acceptance로 사용하지 않는다. ### 29.9 qualification evidence policy provider qualification은 세 lane으로 분리한다. 1. safe real-provider smoke: exact sandbox profile에서 release/candidate마다 실행; 2. deterministic protocol/fault: offline focused/integration test에서 모든 build에 실행; 3. destructive/rotation/quota/delay drill: schedule과 승인된 manual run으로 실행. required lane은 secret/profile 부재를 “통과” skip으로 바꾸지 않는다. `notificationProductionReadiness`는 exact card ID, binding/account/region/workspace, source commit, test artifact, 실행 시각, lane type과 expiration을 가진 evidence manifest를 검증한다. freshness window가 지났거나 required manifest가 없으면 `NOT_QUALIFIED` 또는 aggregate failure다. 한 lane의 evidence를 다른 card/profile로 재사용하지 않는다. 모든 manifest에는 build artifact에서만 파생한 immutable `release_stage = PRE_CUTOVER_BRIDGE | FINAL_CLEANUP` 축을 포함한다. Gradle build가 compiled production class/resource inventory, production dependency lock/source digest와 artifact digest로 구조 manifest를 만들고 detector가 이를 판정한다. caller나 environment가 stage를 override할 수 없다. 두 stage 모두 additive V7 transport-proof-registry/permit/attestation/operation journal/history resource를 보존하며 table/column/migration 문자열 자체는 executable legacy marker가 아니다. legacy path와 fenced bridge, PRE cutover catalog/route set, initializer/switch/permit/terminalizer/attestation class/bean/controller와 세 operator permission surface가 모두 존재하고 final cleanup migration이 없을 때만 `PRE_CUTOVER_BRIDGE`, 그 executable surface와 CUTOVER_WAIT production branch/role mapping이 모두 없고 retained canonical catalog/route set/graph, retained V7과 reviewed cleanup migration이 있을 때만 `FINAL_CLEANUP`이다. 일부만 남은 mixed/unknown artifact는 manifest를 발급하지 않는다. final aggregator는 `PRE_CUTOVER_BRIDGE` evidence를 cleanup artifact에 재사용하지 않는다. detector가 비교할 legacy class/config marker 이름은 verification source와 reviewed detector test allowlist에 명시적으로 남긴다. production consumer-zero 검사는 registered production leaf의 `src/**/src/main` tree와 production config만 대상으로 하고, 별도 allowlist test가 detector marker의 complete set과 allowlist 밖 reference 0을 검증한다. 문자열 분할/난독화로 hygiene scan을 피하지 않는다. ownership setup도 release stage별로 닫힌 계약이다. PRE artifact의 provider/local lane은 absent/LEGACY fence를 test fixture SQL로 우회하지 않는다. isolated sandbox에서 exact PRE artifact를 legacy-only/dark로 배포하고 audited batch `INITIALIZE_LEGACY`를 root-commit한다. 그 뒤 canonical-only instances를 같은 PRE artifact의 `CUTOVER_WAIT`로 올리고 route별 `BEGIN_DRAIN`에서 trusted external issuer의 complete old-node inventory signed header와 row set을 동결한다. old-node 0도 header 한 건을 요구한다. expired ACTIVE permit은 별도 authenticated bounded terminalizer를 root-commit한 뒤 read-only snapshot으로 다시 확인하며 query/COMPLETE가 암묵적으로 state를 바꾸지 않는다. PRE qualification ownership evidence는 다음 closed union이다. - `PRE_QUIESCENCE_EVIDENCE`: exact signed BEGIN inventory, selected signed quiescence attestation, exact registry/permit/holder/node set, per-node deployment-generation tombstone와 legacy credential/egress irreversible revocation, consumer inventory 0, provider-call ledger identity/ snapshot/open-count 0, ACTIVE permit 0을 요구한다; - `PRE_HARD_BOUND_EVIDENCE`: exact signed BEGIN inventory, all-hard-bound registry/evidence revision, `RELEASED|EXPIRED_PROVEN` permit과 ACTIVE permit 0을 요구하며 selected/current-drain attestation은 금지한다. 두 branch가 모두 있거나 둘 다 없으면 qualification을 발급하지 않는다. exact `ACTIVE/CANONICAL@g_final`을 관측한 뒤에만 provider probe를 보낸다. signed evidence의 acceptance window 뒤에도 QUIESCENCE branch의 root-committed irreversible facts는 유효하지만, qualification runner는 retained canonical payload/signature/trust snapshot을 Java로 다시 Ed25519 검증한다. FINAL qualification ownership evidence도 closed union이다. - `FINAL_FRESH`는 V8의 AWAITING state 뒤 external infrastructure issuer authorization으로 실행한 `notificationFreshProvisioning`, retained signed provenance, `FRESH_PROVISIONED` discriminator와 exact `INITIALIZE_CANONICAL_FRESH`/canonical fence set을 요구한다. signed provenance에는 exact DB resource/birth certificate, 세 zero inventory, provider-ledger zero와 issuer가 서명 전에 commit한 irreversible no-legacy-authority fence의 전체 retained field가 있어야 한다; - `FINAL_UPGRADE`는 V8의 `UPGRADE_VALIDATED` discriminator, validated history digest와 exact canonical upgrade fence/history set을 요구한다. 둘 다 있거나 둘 다 없거나 반대 branch provenance/history가 섞이면 fail closed한다. production lane은 production private key를 artifact/environment/DB에 두지 않은 external issuer만 수락한다. deterministic local issuer의 `LOCAL_TEST` evidence는 production qualification을 충족하지 못한다. qualification runner도 production과 같은 application read use case -> retained-evidence query port -> persistence read-only adapter -> verifier port를 사용한다. FRESH는 discriminator/provenance/init/fence, UPGRADE는 discriminator와 full operation/registry/permit/inventory row와 selected·superseded·unselected를 포함한 모든 attestation header/child를 bounded snapshot으로 읽고 Java payload/SPKI/trust 및 semantic exact equality를 재검증한다. cleanup artifact에서 이 read seam이나 retained row가 빠지면 qualification을 발급하지 않는다. FINAL은 transitional endpoint/class/permission surface 0, 정확히 migrator/runtime/provisioner 세 database role, migration-only migrator ownership과 runtime non-ownership을 증명한다. runtime의 retained cutover write·cutover sequence·transitional function `EXECUTE`는 0이되 operational-journal exact least-privilege DML/SELECT는 유지되어야 한다. provisioner는 exact snapshot/read-lock과 apply 두 function `EXECUTE`만 가지며 generic SELECT/DML/sequence/DDL/role-membership과 다른 function `EXECUTE`는 0이어야 한다. test는 Java 검증 뒤 expiry까지 pause하면 apply mutation 0, apply 성공 뒤 commit 지연은 irreversible birth/fence 아래 안전하고 commit acknowledgement 전 success 0, issuer fence commit 전 authorization 발급 0도 검증한다. enforcement activation/read-back보다 앞선 zero snapshot, post-enforcement source revision/time이 없는 manifest와 fence seal 전 signing도 거부한다. credential revoke 전부터 열린 legacy DB session/provider connection을 가진 paused client를 resume해도 session/flow termination과 established-flow deny 때문에 DB/provider I/O가 0임을 integration evidence로 남긴다. manifest는 writer route-set digest와 exact canonical generation-set digest 외에 위 PRE/FINAL discriminator와 branch별 signed payload/history digest를 가진다. mixed marker, caller stage override 또는 branch mismatch는 fail closed한다. abort로 `g_final` 또는 runtime expected-generation profile이 바뀌면 기존 PRE evidence는 stale이며 같은 production semantics로 sandbox cutover/qualification을 다시 수행한다. ### 29.10 privacy/cardinality test - representative PII/secret marker를 log/span/metric scrape/exception/DB plaintext scan에서 검색; - metric unique time-series upper bound; - queue/backlog dump와 actuator/health payload redaction; - Java `toString()`/assertion snapshot redaction; - backup/export fixture에서 ciphertext/key reference만 확인; - terminal retention/redaction과 dedupe tombstone 분리. ### 29.11 proposed task/lane 다음 이름은 구현 계획에서 생성할 conceptual target이며 현재 존재한다고 주장하지 않는다. ```text :application-core:test :adapter:outbound:notification:test :adapter:outbound:persistence-jpa:test :adapter:inbound:web:test :app-bootstrap:test notificationContractTest notificationPostgresIntegrationTest notificationSlackProtocolTest notificationSesProtocolTest notificationPrivacyTest :app-bootstrap:notificationSlackReadiness :app-bootstrap:notificationSesReadiness notificationProductionReadiness ``` `notificationProductionReadiness`는 §17.1에서 release가 선택한 exact card ID set의 required real-provider lane, persistence concurrency, callback, privacy와 config composition evidence를 aggregate한다. ### 29.12 evidence claim matrix | 주장 | 최소 evidence | | --- | --- | | local route/render behavior | unit/property/contract | | durable append | same-DB transaction integration | | concurrent single-owner claim | real PostgreSQL concurrency/fault | | no blind retry after maybe-send | crash/response-loss state test | | Slack inline/durable exact card R2 | sandbox real API + mode별 transaction/fault/protocol/config | | SES durable SNS exact card R2 | sandbox real API + EmailTag/SNS feedback + IAM/config | | zero-resource disabled | bootstrap bean/thread/client/probe assertions | | PII-safe | log/metric/span/DB/retention scan | | rolling revision compatibility | all live/retained revision migration/rollback test | | production topology R3 | production-like scale/failure/rotation exercise | 한 row의 evidence를 다른 provider, mode, region, workspace 또는 topology로 일반화하지 않는다. ## 30. performance와 chaos qualification ### 30.1 load profile 적어도 다음 workload를 분리한다. - steady transactional email; - burst security Slack alert; - provider throttling 중 backlog; - callback burst; - retry/reconcile storm; - large-but-valid template rendering; - mixed critical/best-effort route. 측정: - append p50/p95/p99와 business transaction 영향; - eligible-to-first-attempt lag; - provider attempt latency; - claim/finalize DB TPS와 lock wait; - encryption/render CPU/heap; - backlog recovery rate; - duplicate provider call evidence; - callback apply lag; - payload redaction lag. ### 30.2 failure injection - process kill after claim; - process kill immediately before/after `WIRE_AUTHORIZED` commit와 provider call; - lease 만료 중 blocked provider call의 늦은 accepted response; - provider accepted response 뒤 DB unavailable; - DB commit success response loss to caller; - key manager/secret manager unavailable; - old template/key revision removed; - Slack/SES auth revoked; - provider 429/throttle/outage; - malformed provider response; - callback before accepted finalize; - duplicate/out-of-order/corrupt callback; - concurrent fallback finalizer가 next leg 하나만 활성화하는 경쟁; - cancellation/expiry/suppression과 wire authorization 경쟁; - SES response-loss 뒤 EmailTag + verified `SEND`로 accepted 복원; - `DELIVERY_DELAY/DELIVERY/COMPLAINT` fact 모든 순열의 동일 projection; - HMAC rotation 전 event/suppression의 rotation 후 replay; - SNS 늦은 정상 retry와 위조 `SigningCertURL`; - payload ciphertext redaction 뒤 receipt/suppression matching; - clock skew; - disk/DB capacity pressure; - rolling deploy with old/new worker. 각 fault 뒤 state가 terminal인지 retry/reconcile/manual인지와 duplicate risk를 증거로 남긴다. ### 30.3 no exactly-once claim 테스트에서 duplicate 0건이 관찰되어도 외부 provider와 DB 사이 exactly-once를 증명한 것이 아니다. readiness card는 다음처럼 표현한다. ```text at-least-one durable intent record + bounded single-owner local attempt + provider/card-specific retry/reconciliation + explicit indeterminate state ``` provider native idempotency/reconciliation이 없으면 unknown window의 duplicate 또는 terminal manual resolution risk를 runbook에 남긴다. ## 31. Gradle dependency와 architecture ### 31.1 notification leaf notification leaf가 유지할 project edge: ```text domain-core application-core shared-contract adapter-outbound-support ``` 추가할 수 있는 external library 후보: - AWS SDK for Java 2.x SES v2 module; - Slack Java SDK Web API client 또는 같은 leaf 안의 bounded provider-local HTTP engine; - template/rendering library가 필요하면 sandboxable, bounded, reflection-off evidence가 있는 최소 모듈; - provider response JSON/HTTP dependencies는 leaf 내부 implementation detail. 초기 구현 계획에서 Slack은 공식 [Java Slack SDK](https://docs.slack.dev/tools/java-slack-sdk/)의 Web API client를 우선 평가한다. timeout, proxy, TLS, retry, connection lifecycle과 actual attempt count를 통제하지 못하면 provider-local bounded client로 바꾸며, generic `adapter-outbound-httpclient`를 몰래 의존하지 않는다. 모든 external dependency는 lockfile, license, CVE, transitive HTTP/logging conflict와 Java 21/Spring Boot 4 호환을 검증한다. ### 31.2 persistence-jpa notification table/migration와 store adapter는 persistence-jpa leaf에 추가한다. - persistence entity/repository가 application이나 notification leaf로 나가지 않는다; - application port를 구현한다; - application-owned `NotificationFinalizationRetainedEvidenceQueryPort`를 구현하는 read-only adapter가 FRESH/UPGRADE branch의 full bounded child row를 한 consistent snapshot으로 읽고 persistence entity가 아닌 immutable application projection을 반환한다; - `TransactionPort.inRootWrite`는 ambient actual transaction을 거부하고 physical commit 뒤 반환한다. 기존 join-capable `inWrite`와 의미를 섞거나 `NEVER` propagation을 추가하지 않는다; - PostgreSQL-specific claim SQL은 adapter 내부다; - encryption abstraction의 key material은 persistence entity에 노출하지 않는다; - schema migration/rollback/retention index를 같은 owner leaf가 검증한다. ### 31.3 inbound web callback controller/verifier는 inbound web leaf에 두고 application receipt use case만 호출한다. notification outbound adapter의 internal provider type에 의존하지 않는다. provider-specific signature code가 application DTO로 유출되지 않게 inbound internal collaborator로 둔다. SES R2 ingress는 SNS SignatureVersion 2 verifier와 provider-neutral `NotificationReceiptIngressDescriptor`를 제공한다. send-side notification adapter를 직접 호출하거나 그 settings class를 import하지 않는다. ### 31.4 app-bootstrap bootstrap은 다음만 조합한다. - canonical settings에서 파생한 notification-local send profile, inbound receipt profile과 persistence profile; - adapter별 capability descriptor와 application pure compatibility validator; - current `SecretSource`를 adapter-owned credential/key material factory에 연결하는 bridge; - application dispatch/receipt use case; - FINAL retained-evidence read use case와 query/verifier port implementation binding; - notification provider port implementation; - persistence store implementation; - scheduler/executor/lifecycle/readiness. retry/fallback/consent/state policy 자체를 `@Configuration`이나 settings class에 구현하지 않는다. bootstrap composition이 `ApplicationContext`/bean reflection으로 sibling capability를 추론하지 않으며, secret/key material을 settings/application value에 보관하지 않는다. bootstrap readiness는 위 application read use case만 호출하고 repository, persistence entity, JDBC, query adapter나 verifier 구현을 직접 호출하지 않는다. ### 31.5 registry 변경 조건 다음 요구가 생기면 `modules.json`, `settings.gradle`, Gradle dependency gate, architecture test와 문서를 함께 변경하는 별도 architecture decision이 필요하다. - notification leaf가 generic HTTP client capability를 의존; - broker consumer/inbox를 위한 inbound messaging leaf; - provider callback 전용 inbound notification leaf; - notification persistence를 독립 leaf로 분리; - 별도 notification service/deployment. 현재 19-leaf 경계를 우회해 app-bootstrap에 consumer/business handler를 넣지 않는다. ## 32. 단계별 migration ### Phase 0 — truth와 activation drift 정리 - 현재 implementation/evidence 표 확정; - README/CLAUDE/YAML/env registry/conditional test의 selector drift inventory; - canonical config와 migration alias 결정; - 기존 `slack-webhook`, `google-email`, `NotificationPort`를 R0 legacy로 명시; - production consumer 0과 fake-only evidence 명시; - design 승인 전 behavior 변경 없음. Acceptance: - 한 문서에서 current truth를 재현할 수 있다; - legacy/canonical key의 removal/cutover rule이 정해진다; - R0를 R2로 오해하는 문구가 없다. ### Phase 1 — application semantic foundation - bounded identity/value; - feature-specific application request factory/policy와 outbound port pattern; - typed parameter/recipient와 redacted value; - intent/mode/policy/result; - synchronous best-effort용 `TransactionPort.inRootWrite` boundary; - provider-neutral plan/append/store/attempt/receipt ports; - state/outcome/fingerprint contract; - application contract/unit test. Acceptance: - core에 Spring/JPA/SDK/transport/raw DTO가 없다; - critical vs best-effort가 type/catalog로 구분된다; - best-effort는 physical commit 뒤에만 send하고 ambient transaction에서는 side effect 전에 fail-fast한다; - indeterminate/fallback/state transition test가 있다. ### Phase 2 — catalog, template와 canonical activation - code catalog/route compiler; - immutable template manifest/assets; - typed renderer/locale/escaping/limits; - canonical settings/expected-state/expected-binding-ids; - adapter별 descriptor와 application compatibility validator; - zero-resource binding; - startup/readiness graph; - legacy conflict fail-fast. Acceptance: - binding graph가 exact tuple로 compile된다; - no binding resources 0; - template drift/injection/locale test; - existing legacy는 아직 별도 path로만 동작한다. ### Phase 3 — Slack Web API best-effort reference - `slack-web-api` provider/client; - one physical attempt/outcome mapping; - per-channel rate/admission; - `BEST_EFFORT_INLINE`; - app-bootstrap sandbox readiness lane; - incoming webhook legacy capability descriptor. Acceptance: - `chat.postMessage` response `(channel, ts)`와 exact state; - timeout/response loss indeterminate; - no documented idempotency를 readiness/runbook에 반영; - no PII/secret telemetry. 이 단계는 durable Notification R2 완료가 아니다. ### Phase 4 — durable PostgreSQL workflow와 SES submission - intent/delivery/attempt/receipt schema; - same-transaction append; - claim token/attempt execution token/`WIRE_AUTHORIZED`/terminal-once result; - shared admission gate park/resume generation; - versioned direct AEAD/HMAC rotation/retention; - dispatcher/retry/reconcile protocol; - Slack Web API durable-single binding과 response-loss terminal unknown; - SES v2 one-recipient send와 EmailTag attempt correlation; - account/quota/IAM/readiness; - concurrency/crash/finalize failure integration test. Acceptance: - same DB append atomicity; - stale owner 차단; - multi-instance park/restart/resume에서 binding fault backlog 보존; - provider call transaction 밖; - maybe-send crash -> indeterminate; - Slack durable response-loss를 blind retry하지 않음; - SES MessageId는 provider accepted로만 표시; - selected submission cards의 real-provider evidence. ### Phase 5 — feedback, suppression과 operational R2 - verified SES -> SNS HTTPS feedback callback와 DLQ; - duplicate/orphan/out-of-order receipt; - technical suppression; - backlog/reconcile/redaction health; - runbook/alerts/dashboards; - rolling revision/key/credential test; - `notificationProductionReadiness`. Acceptance: - selected Slack/SES cards의 required lanes no-skip; - feedback authenticity/dedupe/race evidence; - privacy/cardinality/retention evidence; - rollback/rotation/failure drill; - blocker/high architecture review 0. ### Phase 6 — legacy removal과 optional provider - route별 retained signed old-node inventory, irreversible quiescence와 PRE closed-union cutover evidence; - V8 AWAITING/UPGRADE discriminator, post-migration irreversible no-legacy-authority fence가 결합된 signed DB-birth provenance, retained read seam과 FINAL closed-union evidence; - legacy `NotificationPort`, global fail-open wrapper와 stale config 제거; - `google-email` 제거 또는 exact Gmail card로 rename/rebuild; - incoming webhook/Gmail/SMTP/SES stored template optional cards; - 필요 시 inbound messaging/notification leaf architecture migration. Acceptance: - dual-running duplicate path 없음; - env/sample/docs/test가 canonical graph 하나만 사용; - optional provider가 baseline 보장을 자동 상속하지 않음. ## 33. rollout와 cutover ### 33.1 schema/code/config 순서 ```text expand schema -> new code dark/disabled -> contract and readiness evidence -> route-specific canonical binding -> old path disabled for that route -> observation window -> legacy config/code contract ``` old notifier와 new durable dispatcher가 같은 business event에 동시에 send하지 않게 route별 single-writer cutover token/config revision을 둔다. 구현은 §16.7.1의 PostgreSQL fence/permit과 `BEGIN_DRAIN -> bounded external poll -> COMPLETE_SWITCH`를 사용한다. 각 node config에는 legacy와 canonical key를 동시에 넣지 않고, rolling node 간 config revision 차이는 shared owner/generation이 fail-closed로 중재한다. legacy transport hard deadline이 증명되지 않으면 TTL expiry로 drain을 추론하지 않고 BEGIN에서 동결한 complete old-node inventory, signed quiesce/consumer/ledger-0 evidence, per-node deployment-generation tombstone와 legacy credential/egress irreversible revocation, ACTIVE permit 0가 모두 준비될 때까지 switch를 중단한다. signed evidence는 수락 시점의 bounded window 안에서 Java가 검증하고 root-commit하며, 이후 COMPLETE는 retained signature와 immutable facts를 재검증한다. canonical-only PRE node는 persisted route set이 exact catalog이고 각 route가 configured target의 LEGACY/DRAINING predecessor 또는 CANONICAL target인 경우에만 `CUTOVER_WAIT`로 liveness-healthy 기동할 수 있다. predecessor/DRAINING route의 admission/worker/provider call은 0이며, committed CANONICAL target을 fresh read한 route만 열린다. route별 전환 중 mixed set은 이 closed state 범위에서 허용된다. FINAL artifact는 wait branch를 제거하고 all-canonical exact target만 허용한다. 첫 bridge release는 V7이나 startup에서 legacy ownership을 자동 생성하지 않는다. 인증된 least-privilege operator가 canonical key catalog + PRE cutover decorator와 exact equality인 전체 route/generation set/reason/token으로 §16.7.1의 audited batch `INITIALIZE_LEGACY`를 root-commit한 뒤에만 bridge admission을 연다. 이 초기화 전에 fence가 없거나 set이 partial이면 legacy와 canonical admission은 모두 fail closed한다. abort가 있으면 최종 canonical generation은 단순 `g+1`이 아니라 latest committed `COMPLETE_SWITCH` operation result와 일치하는 route별 `g_final`이다. runtime expected generation/config와 qualification manifest는 이 값을 exact axis로 가지며 변경 시 재검증한다. final cleanup은 empty-store만으로 fresh를 추론하지 않는다. V8은 complete upgrade history를 `UPGRADE_VALIDATED`로 보존하거나 notification state가 완전히 빈 database를 `AWAITING_SIGNED_FRESH_PROVISIONING`으로 남긴다. any nonempty missing-fence/partial-history database는 중단한다. fresh database는 V8 뒤 별도 `notificationFreshProvisioning` job이 independent infrastructure issuer가 exact DB resource/birth certificate에 irreversible no-legacy-authority fence를 먼저 commit한 뒤 서명한 environment/DB-system/database/schema/ artifact/route-set/application-workload-0/business-consumer-0/legacy-node-0/provider-ledger-0 authorization을 받는다. 같은 provisioner transaction에서 exact snapshot/read-lock function, Java verifier, exact apply function 순서와 fresh apply-time window check를 통과한 후에만 retained provenance, reviewed canonical route set 전체의 `INITIALIZE_CANONICAL_FRESH`와 `FRESH_PROVISIONED` discriminator를 만든다. AWAITING 동안 normal runtime/readiness와 provider I/O는 0이고 upgrade database의 complete operation history와 route별 `g_final` fence 및 FINAL startup/readiness read seam은 cleanup 뒤에도 보존한다. ### 33.2 backfill 과거 generic outbox event나 log를 새 notification intent로 자동 backfill하지 않는다. 이미 provider side effect가 있었는지 알 수 없기 때문이다. backfill이 필요하면: - 대상 event type/window를 명시; - prior-send evidence와 duplicate tolerance를 검토; - one-time migration occurrence ID; - dry inventory와 승인; - bounded batch; - 별도 audit/rollback; - active path와 dedupe collision test. ### 33.3 rollback new route를 rollback한다고 legacy path로 자동 resend하지 않는다. 이미 provider accepted 또는 indeterminate intent가 있을 수 있다. - `BEGIN_DRAIN` 뒤 `COMPLETE_SWITCH` 전에는 `ABORT_DRAIN`만 허용하며 새 LEGACY generation을 발급한다; - `COMPLETE_SWITCH` 뒤에는 이 설계가 reverse handoff를 지원하지 않는다. legacy re-enable, CANONICAL→LEGACY owner CAS와 old-code worker 재개를 금지한다; - 이상 징후가 있으면 canonical 신규 append admission과 worker를 shared gate로 pause; - in-flight/new states가 더 진행되지 않음을 확인; - plan/template/key revision 유지; - exact route별 pending/accepted/indeterminate inventory; - 자동 resend 없이 manual reconciliation 또는 forward-fix 결정. COMPLETE 뒤 reverse가 필요하다면 canonical in-flight/intent backlog/provider-result를 drain하는 별도 state machine, permit/evidence protocol, duplicate policy와 provider requalification을 먼저 설계·승인해야 한다. duplicate risk 승인만으로 legacy를 다시 열 수 없다. ## 34. completion criteria ### 34.1 design 완료 - [x] current code/config/evidence diagnosis - [x] alternatives와 selected architecture - [x] module ownership/dependency direction - [x] application contract와 business/technical policy boundary - [x] mode/routing/template/provider outcome - [x] durable state/store/claim/reconciliation - [x] Slack/SES reference provider semantics - [x] config/security/observability/health/lifecycle - [x] test/evidence/migration/completion criteria - [x] independent architecture/consistency/durability design review blocker/high 0 - [x] 사용자 설계 승인 - [x] 승인 후 implementation plan 작성 이 문서의 상태는 “상세 설계 승인, 구현 계획 작성, 구현 미착수”다. ### 34.2 minimum implementation R2 - [ ] canonical binding/expected-state와 legacy conflict 제거 - [ ] feature-specific application semantic port - [ ] typed/frozen intent/template/route plan - [ ] best-effort와 durable path 분리 - [ ] same-DB intent/delivery/attempt/receipt journal - [ ] owner token/version claim/finalize - [ ] encrypted PII + keyed HMAC + retention - [ ] indeterminate/reconcile/fallback safety - [ ] Slack Web API exact provider card - [ ] SES v2 submission/feedback exact provider card - [ ] callback verification/dedupe/orphan race - [ ] zero-resource disabled - [ ] bounded deadlines/concurrency/amplification - [ ] health/metrics/traces/runbook - [ ] focused/architecture/real-provider/no-skip/privacy evidence - [ ] independent review blocker/high 0 - [ ] LLM Wiki capture ### 34.3 금지할 완료 표현 - fake test만으로 “Slack/Email 연동 완료”; - MessageId/ts만으로 “사용자에게 전달 완료”; - global fail-open path를 “reliable notification”; - local DB claim만으로 “exactly once delivery”; - optional provider lane skip 상태로 “production ready”; - 한 provider/account/region card로 notification module 전체 R2; - config만 있고 실제 client/consumer가 없는데 “enabled”; - branch-note/검증 없이 “설계/구현 완료”. ## 35. 운영 runbook 최소 항목 ### 35.1 backlog 증가 1. channel/provider/route/mode별 backlog와 oldest eligible age를 본다. 2. provider quota/auth/outage, DB claim/finalize, encryption/render failure를 분리한다. 3. concurrency를 무조건 높이기 전에 provider rate와 DB capacity를 확인한다. 4. expiry와 business urgency를 확인하되 consent/suppression을 우회하지 않는다. 5. pause/resume은 audited route use case로 수행한다. ### 35.2 indeterminate 증가 1. attempt phase/provider/revision/deployment window를 분류한다. 2. blind retry/fallback을 켜지 않는다. 3. provider card의 reconciliation 가능 여부를 확인한다. 4. Slack은 `ts`가 없는 response-loss를 terminal unknown으로 두고, SES는 verified EmailTag+`SEND` fact가 있을 때만 accepted로 복원한다. 5. duplicate risk와 business impact를 함께 보고 manual resolution한다. ### 35.3 bounce/complaint 증가 1. verified feedback인지와 account/configuration set을 확인한다. 2. recipient/content를 log/export하지 않는다. 3. technical suppression 적용/충돌을 점검한다. 4. business consent/unsubscribe 시스템과 별도 incident로 연계한다. 5. sender identity/DKIM/DMARC/content/reputation과 provider account 상태를 조사한다. ### 35.4 credential/key/template rotation 1. new revision을 추가하고 startup/readiness를 통과한다. 2. new append/attempt가 새 revision을 쓰는지 확인한다. 3. old active/backlog/receipt/retention row를 inventory한다. 4. 모든 live/retained row가 참조하는 revision compatibility와 rollback을 검증한다. 5. old secret/key/template를 제거한 뒤 canary/failure alert를 확인한다. ### 35.5 provider outage - liveness restart loop를 만들지 않는다; - durable route는 bounded retry/backlog, best-effort route는 explicit outcome; - auth/account/config fault는 shared admission gate를 park하고, readiness/config 재검증 뒤 audited generation-bumping resume만 수행한다; - unknown outcome과 definite rejection을 분리; - cross-provider fallback은 authoritative failure일 때만; - expiry/retention/capacity 임계치와 stakeholder communication을 실행한다. ## 36. 승인 gate와 남은 설계 가정 다음 다섯 결정을 이 설계의 승인 gate로 둔다. 1. 최소 R2 durability는 business state와 notification journal이 같은 PostgreSQL transaction에 참여할 수 있다는 가정을 채택한다. 2. 최소 R2는 intent 하나에 logical recipient 정확히 한 명이며, delivery row는 provider leg다. 3. Slack reference는 `chat.postMessage`, email reference는 Amazon SES v2로 채택하고 §17.1의 exact 세 card만 초기 qualification 대상으로 둔다. 4. SES feedback은 `configuration set -> SNS HTTPS adapter-inbound-web + DLQ` topology로 고정한다. 5. 기존 `slack-webhook`, `google-email`, raw `NotificationPort`는 R0 legacy best-effort로 분류하고 route별 migration 뒤 제거한다. 이 가정 중 1번이 실제 제품 topology와 다르면 구현 계획을 쓰기 전에 broker handoff + inbound messaging/inbox architecture로 설계를 수정해야 한다. 사용자는 2026-07-28에 위 다섯 결정을 승인했다. 구현은 [Notification Production Capability Implementation Plan](../plans/2026-07-28-notification-production-capability.md)의 작은 TDD task, owner leaf, registry-derived Gradle path, focused test, architecture gate, real-provider evidence와 rollback point를 따른다. ## 37. primary references ### 37.1 repository - [Production Capability Platform Design](2026-07-26-production-capability-platform-design.md) - [Redis Production Capability Deep Design](2026-07-26-redis-production-capability-design.md) - [Fileserver Production Capability Deep Design](2026-07-26-fileserver-production-capability-design.md) - [HTTP Client Production Capability Deep Design](2026-07-27-httpclient-production-capability-design.md) - `AGENTS.md` - `src/config/architecture/modules.json` - `src/adapter/outbound/notification/CLAUDE.md` - `src/adapter/outbound/notification/README.md` ### 37.2 Slack - [chat.postMessage](https://docs.slack.dev/reference/methods/chat.postMessage/) - [Incoming Webhooks](https://docs.slack.dev/messaging/sending-messages-using-incoming-webhooks/) - [Web API rate limits](https://docs.slack.dev/apis/web-api/rate-limits/) - [Web API response contract](https://docs.slack.dev/apis/web-api/) - [conversations.history](https://docs.slack.dev/reference/methods/conversations.history/) - [message event](https://docs.slack.dev/reference/events/message/) - [chat.update](https://docs.slack.dev/reference/methods/chat.update/) - [chat.delete](https://docs.slack.dev/reference/methods/chat.delete/) - [Slack OAuth installation](https://docs.slack.dev/authentication/installing-with-oauth/) - [Slack token rotation](https://docs.slack.dev/authentication/using-token-rotation/) - [Slack developer sandboxes](https://docs.slack.dev/tools/developer-sandboxes/) - [Java Slack SDK](https://docs.slack.dev/tools/java-slack-sdk/) - [Slack `auth.test`](https://docs.slack.dev/reference/methods/auth.test/) ### 37.3 Amazon SES and AWS SDK - [SES v2 SendEmail](https://docs.aws.amazon.com/ses/latest/APIReference-V2/API_SendEmail.html) - [SES email sending process](https://docs.aws.amazon.com/ses/latest/dg/send-email-concepts-process.html) - [SES quotas](https://docs.aws.amazon.com/ses/latest/dg/quotas.html) - [SES GetAccount](https://docs.aws.amazon.com/ses/latest/APIReference-V2/API_GetAccount.html) - [Managing SES sending quota errors](https://docs.aws.amazon.com/ses/latest/dg/manage-sending-quotas-errors.html) - [SES EventDestination](https://docs.aws.amazon.com/ses/latest/APIReference-V2/API_EventDestination.html) - [SES message insights](https://docs.aws.amazon.com/ses/latest/APIReference-V2/API_GetMessageInsights.html) - [Monitoring SES activity using notifications](https://docs.aws.amazon.com/ses/latest/dg/monitor-sending-activity-using-notifications.html) - [SES event publishing and message tags](https://docs.aws.amazon.com/ses/latest/dg/monitor-using-event-publishing.html) - [SES SNS event examples](https://docs.aws.amazon.com/ses/latest/dg/event-publishing-retrieving-sns-examples.html) - [SES sending authorization](https://docs.aws.amazon.com/ses/latest/dg/control-user-access.html) - [SES suppression list](https://docs.aws.amazon.com/ses/latest/dg/sending-email-global-suppression-list.html) - [SNS signature verification](https://docs.aws.amazon.com/sns/latest/dg/sns-verify-signature-of-message.html) - [SNS HTTP retry policy](https://docs.aws.amazon.com/sns/latest/dg/sns-message-delivery-retries.html) - [SNS HTTP subscription confirmation](https://docs.aws.amazon.com/sns/latest/dg/http-subscription-confirmation-json.html) - [SNS `ConfirmSubscription`](https://docs.aws.amazon.com/sns/latest/api/API_ConfirmSubscription.html) - [AWS SDK v2 default credentials provider chain](https://docs.aws.amazon.com/sdk-for-java/latest/developer-guide/credentials-chain.html) - [AWS SDK v2 retry strategy](https://docs.aws.amazon.com/sdk-for-java/latest/developer-guide/retry-strategy.html) ### 37.4 Gmail and SMTP references for future cards - [Gmail users.messages.send](https://developers.google.com/workspace/gmail/api/reference/rest/v1/users.messages/send) - [Gmail API quotas](https://developers.google.com/workspace/gmail/api/reference/quota) - [Gmail API sending](https://developers.google.com/workspace/gmail/api/guides/sending) - [OAuth service accounts/domain-wide delegation](https://developers.google.com/identity/protocols/oauth2/service-account) - [Gmail push notifications](https://developers.google.com/workspace/gmail/api/guides/push) - [RFC 5321 — SMTP](https://www.rfc-editor.org/rfc/rfc5321) - [RFC 3461 — SMTP DSN](https://www.rfc-editor.org/rfc/rfc3461) - [RFC 4954 — SMTP AUTH](https://www.rfc-editor.org/rfc/rfc4954) - [RFC 8314 — TLS for email submission/access](https://www.rfc-editor.org/rfc/rfc8314)