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