Files
tech-log-backend/docs/superpowers/specs/2026-07-28-notification-production-capability-design.md

238 KiB
Raw Permalink Blame History

Notification Production Capability Deep Design

0. 문서 상태와 구현 상태

이 문서는 Notification capability의 승인된 상세 설계다. 2026-07-28에 §36의 다섯 gate를 사용자가 승인했으며, 실제 구현 순서는 Notification Production Capability Implementation Plan을 정본으로 사용한다. 구현 및 production readiness는 아직 주장하지 않는다.

2026-07-28 현재 구현된 범위:

  • application-coreChannel, 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_INLINEDURABLE_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-webhookgoogle-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는 다음 세 타입뿐이다.

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를 순서대로 모두 호출한다.

app.notification.routes.<channel>.<route>=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를 노출한다.

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을 조건으로 사용한다.

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가 아니다

SlackClientGoogleEmailClient는 extension interface이며 production 구현이 없다. SlackWebhookProviderGoogleEmailProvider는 이 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 MessageIdDELIVERY 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 전체의 단일 숫자가 아니다.

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

예:

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는 다음뿐이다.

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를 조합할 수 있다.

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를 넘기지 않는다.

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를 둔다.

개념 예:

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는 개념적으로 다음 값을 갖는다.

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<String,Object>와 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 성공을 뜻하지 않는다.

NotificationRequestResult =
  InlineCompleted(bounded TargetAttemptOutcome list)
  | AppendedDurably(intentReference)
  | DuplicateExistingIntent(intentReference)
  | RejectedByBusinessPolicy(reasonCode)
  | RejectedInvalidRequest(reasonCode)
  | CapabilityUnavailable(reasonCode)

InlineCompletedSINGLE뿐 아니라 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가 고정한다.

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를 제공한다.

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만 정한다.

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.inWritePROPAGATION_REQUIRED이므로 이미 열린 outer transaction에 참여할 수 있다. 따라서 단순히 inWrite가 반환했다는 사실을 physical commit 완료로 간주하면 안 된다. synchronous InlineCompleted를 유지하는 최소 R2는 기존 application TransactionPortinRootWrite 계약을 추가한다.

이 port의 persistence implementation은 시작 전에 ambient physical transaction이 없음을 검사하고, 있으면 business write나 provider call 전에 typed NestedRootTransactionRejectedException으로 fail-fast한다. ambient transaction이 없을 때만 root REQUIRED transaction을 열고 physical commit이 끝난 뒤 반환한다.

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로 설계한다.

inRootWriteTransactionMode는 여전히 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를 선언한다.

@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도 선택한다.

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을 가질 수 있으므로 다음을 분리한다.

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:

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:

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으로 둔다.

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

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을 만든다.

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

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다.

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는 다음 상한을 모두 고정한다.

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에 둔다.

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다.

record PasswordResetTemplateParameters(
    DisplayName displayName,
    ResetLinkReference resetLink,
    ExpiryMinutes expiryMinutes) {}

공통 engine boundary가 필요하면 closed scalar/value set만 허용한다.

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 <!channel>, <@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을 사용하지 않는다.

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이며 개념적으로 다음 책임을 갖는다.

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를 생성 시점과 의미에 따라 분리한다.

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

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를 직교 축으로 유지한다.

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_BINDINGFaultScope에 대응하는 shared admission gate를 닫고 readiness를 내리며 backlog를 terminal 유실시키지 않는다.

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에 참여할 수 있다는 가정에 기반한다.

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:

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

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_BINDINGnext_action_at=null이며 gate가 audited resume되기 전 eligible scan에 나타나지 않는다.

16.4 notification_attempt

append 중심의 physical evidence:

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

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로 둔다.

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이다.

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:

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:

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:

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:

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:

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:

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-attestationsnotification: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_atissued_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다.

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으로 동결한다.

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를 보존한다.

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이다.

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를 보존한다.

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 notificationFreshProvisioningAWAITING_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_atINITIALIZE_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를 결정한다.

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-expirednotification: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_PROVENHARD_BOUND_PROVEN에만, TIMED_OUT_UNPROVENQUIESCENCE_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_provisionernotification_fresh_provisioning_snapshot_and_locknotification_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:

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

  • CLAIMEDATTEMPT_RESERVEDWIRE_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이 실패할 수 있다. 다음을 보장하지 못한다.

external provider side effect
  <atomic>
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_ALLORDERED_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다.

선택 이유:

  • 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를 전달하지 않는다.

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 429Retry-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.

18.5 success와 receipt 의미

chat.postMessage 성공 응답의 (workspace, channel, ts)를 encrypted/opaque provider message reference로 저장한다.

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를 증명하지 않는다.

공식 기준:

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로만 등록한다.

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.

18.8 Slack qualification

R2 evidence lane은 production token이 아니라 별도 Slack developer sandbox/test workspace와 격리 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이다.

선택 이유:

  • 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은 다음을 고정한다.

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 을 무조건 허용하는 것이 아니라 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.

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가 이후 실제로 전송되지 않을 수 있음을 명시한다.

SendEmail MessageId
  -> PROVIDER_ACCEPTED
  != DELIVERED_TO_RECIPIENT_MTA
  != INBOX_DELIVERED
  != READ

공식 기준:

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.

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를 주장하지 않는다.

공식 기준:

19.8 feedback event

최소 R2 feedback topology를 다음 하나로 고정한다.

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한다.

공식 기준:

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.

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를 주장하지 않는다.

공식 기준:

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를 별도로 보존한다.

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, SNS HTTP delivery retry.

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가 명시적으로 지원하는 경우만 호출한다.

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_RESERVEDWIRE_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을 결정한다.

개념 예:

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

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이다.

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:

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:

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는 다음을 계산한다.

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한다.

현재 SecretSourceapp-bootstrap 소유이며 Optional<String>을 반환하므로 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 예:

channel
provider_type/provider_binding
route_id
template_id/version
mode
strategy
outcome/reason_code
attempt_bucket
readiness_card_revision

금지 tag:

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:

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:

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만 쓴다.

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만 평가한다.

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

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:

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는 대략 다음으로 제한한다.

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이며 현재 존재한다고 주장하지 않는다.

: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는 다음처럼 표현한다.

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:

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의 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 순서

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_FRESHFRESH_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_DRAINCOMPLETE_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 완료

  • current code/config/evidence diagnosis
  • alternatives와 selected architecture
  • module ownership/dependency direction
  • application contract와 business/technical policy boundary
  • mode/routing/template/provider outcome
  • durable state/store/claim/reconciliation
  • Slack/SES reference provider semantics
  • config/security/observability/health/lifecycle
  • test/evidence/migration/completion criteria
  • independent architecture/consistency/durability design review blocker/high 0
  • 사용자 설계 승인
  • 승인 후 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의 작은 TDD task, owner leaf, registry-derived Gradle path, focused test, architecture gate, real-provider evidence와 rollback point를 따른다.

37. primary references

37.1 repository

37.2 Slack

37.3 Amazon SES and AWS SDK

37.4 Gmail and SMTP references for future cards