diff --git a/docs/superpowers/plans/2026-07-28-notification-production-capability.md b/docs/superpowers/plans/2026-07-28-notification-production-capability.md new file mode 100644 index 00000000..5e16b1ef --- /dev/null +++ b/docs/superpowers/plans/2026-07-28-notification-production-capability.md @@ -0,0 +1,4082 @@ +# Notification Production Capability Implementation Plan + +> **Execution workflow:** 구현 시 `superpowers:subagent-driven-development`, +> `superpowers:test-driven-development`, `superpowers:verification-before-completion`, +> `superpowers:requesting-code-review`를 순서에 맞게 사용한다. 현재 세션에는 해당 skill package가 +> 노출되지 않았으므로 이 문서는 저장소의 기존 Redis/Fileserver/HTTP Client 계획 형식과 동일한 +> RED/GREEN/verification 규칙을 수동으로 명시한다. + +- 작성일: 2026-07-28 +- 상태: Wave B locally verified checkpoint, 사용자 요청으로 Task 9 전 일시 중단 +- 작업 위치: + `/home/donghyeon/workspace/clean-architecture-backend-template-notification` + (`codex/notification-production-capability`) +- 중단점: Wave A application/transaction foundation, Wave B catalog/render/provider fake + protocol, Task 8 local crypto primitive 구현. Task 9의 Testcontainers dependency/lock 준비만 + 수행했으며 migration/persistence model은 미착수. +- 설계 정본: + [Notification Production Capability Deep Design](../specs/2026-07-28-notification-production-capability-design.md) +- 비교한 실행 계획: + [Redis Foundation](2026-07-28-redis-production-capability-foundation.md), + [Redis Runtime](2026-07-28-redis-runtime-cache.md), + [HTTP Client Foundation](2026-07-28-httpclient-production-capability-foundation.md), + [HTTP Client Total Deadline](2026-07-28-httpclient-total-deadline.md), + [Fileserver Foundation](2026-07-28-fileserver-production-capability-foundation.md), + [Fileserver Durable Recovery](2026-07-28-fileserver-durable-recovery.md), + [Fileserver R2](2026-07-28-fileserver-r2-control-plane-provider-selection.md) + +Repository commit policy는 모든 플랫폼에서 `human-only`다. 이 계획에는 +`git add`, `git commit`, `git amend`, `git push` 단계가 없다. + +## 1. Goal + +provider-neutral application intent를 다음 두 실행 모드로 안전하게 처리하는 production +Notification capability를 구현한다. + +- `BEST_EFFORT_INLINE`: root business transaction의 physical commit 뒤 한정된 provider attempt를 + 수행하고 직교 outcome을 호출자에게 반환한다. +- `DURABLE_ASYNC`: business state와 같은 PostgreSQL transaction에서 한 logical recipient의 + intent와 frozen provider leg를 append하고, 별도 dispatcher가 claim, `WIRE_AUTHORIZED`, + provider call, terminal-once result, receipt/reconciliation을 수행한다. + +초기 qualification 대상은 다음 exact card 세 개뿐이다. + +```text +slack-web-api-inline-single-local-v1 +slack-web-api-durable-single-local-v1 +aws-ses-v2-durable-single-local-sns-v1 +``` + +Slack은 Web API `chat.postMessage`, email은 Amazon SES v2 `SendEmail`을 사용한다. SES feedback +topology는 `configuration set -> SNS HTTPS adapter-inbound-web -> DLQ`로 고정한다. + +이 계획은 외부 provider와 local DB 사이 exactly-once, inbox placement, read receipt 또는 +production topology R3를 주장하지 않는다. + +## 2. Architecture and fixed decisions + +```text +feature application policy + -> NotificationKindPolicy + -> BEST_EFFORT_INLINE + -> TransactionPort.inRootWrite(business write) + -> physical commit + -> InlineNotificationAttemptPort + -> DURABLE_ASYNC + -> TransactionPort.inWrite(business write + NotificationIntentAppendPort) + -> same PostgreSQL commit + +NotificationDispatchUseCase + -> short claim transaction + -> short ATTEMPT_RESERVED/WIRE_AUTHORIZED transaction + -> render + exactly one authorized provider call outside DB transaction + -> terminal-once result/frozen projection transaction + +SES SNS HTTPS callback + -> inbound signature/account/topic verification + -> NormalizedNotificationReceiptCommand + -> application receipt reducer + -> PostgreSQL receipt/orphan/suppression projection +``` + +고정 결정: + +1. source business state와 notification journal은 같은 PostgreSQL transaction manager에 + 참여한다. +2. intent 하나는 logical recipient 한 명이고 delivery row는 provider leg다. +3. mode와 admission class는 application `NotificationKindPolicy`만 결정한다. +4. config의 `expected-mode`는 assertion이며 mode override가 아니다. +5. provider call은 DB transaction 밖에서만 수행한다. +6. claim owner token과 immutable attempt execution token을 분리한다. +7. `WIRE_AUTHORIZED` commit을 local linearization point로 사용한다. +8. transmission certainty, retry disposition, fault scope를 한 enum으로 합치지 않는다. +9. binding/account fault는 PostgreSQL shared admission gate를 `PARKED`로 만들고 initial fallback을 + 자동 활성화하지 않는다. +10. PII payload는 `DIRECT_AEAD_AES_256_GCM_V1`, lookup/dedupe는 purpose-separated versioned + HMAC을 사용한다. +11. 기존 `slack-webhook`, `google-email`, raw `NotificationPort`는 R0 legacy path로 동결한 뒤 + route cutover가 증명되면 제거한다. +12. legacy→canonical 전환은 PostgreSQL fence, bounded permit, append-only operation/ + attestation sequence, retained signed-evidence header와 proof registry로 수행한다. +13. BEGIN은 독립 infrastructure issuer가 서명한 complete old-node inventory header와 node rows를 + server-side 검증·동결한다. `QUIESCENCE_REQUIRED` COMPLETE는 그 BEGIN에 귀속된 exact signed + per-node irreversible deployment-generation tombstone, legacy credential-or-egress revocation, + ACTIVE permit 0과 provider-call-ledger open-count 0의 durable proof를 요구한다. 이 사실은 + monotonic/irreversible하므로 application pre-commit freshness나 DEFERRABLE trigger를 + COMPLETE authority로 사용하지 않는다. +14. cutover causality는 post-lock shared DB sequence와 explicit BEGIN FK가 SSOT다. timestamp는 + post-lock `clock_timestamp()` 보조 evidence이고 transaction-start 시각은 사용하지 않는다. +15. V8은 complete canonical upgrade history를 검증·보존하고 discriminator를 + `UPGRADE_VALIDATED`+validated-history digest로 닫으며, exact empty database는 + `AWAITING_SIGNED_FRESH_PROVISIONING`+두 arm field null로 남긴다. fresh canonical seed는 + migration 안에서 만들지 않는다. 별도 final-artifact `notificationFreshProvisioning` + Gradle/CLI가 independent infrastructure issuer의 signed DB-birth certificate와 이미 + committed irreversible no-legacy-authority fence를 Java로 검증한 뒤, exact two-method/ + two-function PostgreSQL provisioning port를 통해 provenance, + `INITIALIZE_CANONICAL_FRESH`, 모든 canonical fence와 `FRESH_PROVISIONED`+fresh token을 한 + transaction으로 생성한다. runtime은 이 commit과 retained Java 재검증 전까지 dark다. +16. 정확히 세 role만 둔다. `notification_migrator`는 Flyway/schema owner, + `notification_runtime`은 non-owner runtime role, `notification_provisioner`는 fresh + provisioning exact two-function operation 전용 role이며 그 밖의 제4 notification role은 + 만들지 않는다. raw + database credential은 각 전용 reference로만 해석하고 production artifact에는 issuer private + key를 넣지 않는다. + +## 3. Scope boundary and owner leaves + +정확한 leaf와 production dependency edge는 +`src/config/architecture/modules.json`에서 파생한다. 이 계획은 registry edge를 추가하지 않는다. + +| 책임 | owner leaf | Gradle path | 기존 허용 edge | +| --- | --- | --- | --- | +| semantic values, policy, port, use case | `application-core` | `:application-core` | `domain-core`, `shared-contract` | +| provider catalog/render/SPI/Slack/SES | `adapter-outbound-notification` | `:adapter:outbound:notification` | `domain-core`, `application-core`, `shared-contract`, `adapter-outbound-support` | +| transaction/crypto/schema/store/claim | `adapter-outbound-persistence-jpa` | `:adapter:outbound:persistence-jpa` | `domain-core`, `application-core`, `shared-contract` | +| SNS HTTPS verification/transport mapping | `adapter-inbound-web` | `:adapter:inbound:web` | `domain-core`, `application-core`, `shared-contract` | +| canonical graph/composition/scheduler/readiness | `app-bootstrap` | `:app-bootstrap` | registry에 등록된 runtime leaves | + +금지: + +- `domain-core`에 notification framework/transport/persistence 개념을 추가하지 않는다. +- notification leaf가 persistence, inbound-web, httpclient sibling leaf를 의존하지 않는다. +- inbound-web가 notification outbound adapter 타입을 import하지 않는다. +- app-bootstrap settings/configuration에 mode, retry, fallback, consent 같은 정책을 구현하지 + 않는다. +- sample WorkLog를 production Notification consumer로 만들지 않는다. + +## 4. Evidence ladder and claim rule + +| evidence | 허용되는 주장 | +| --- | --- | +| application unit/contract | framework-free semantic/state policy가 정의됨 | +| adapter fake/loopback protocol | local render와 provider request/outcome mapping이 정의됨 | +| real PostgreSQL concurrency/fault | same-DB append와 provider-neutral durable protocol의 local evidence | +| Slack sandbox | exact Slack card의 provider evidence | +| SES sandbox + actual SNS callback | exact SES/SNS card의 provider/feedback evidence | +| privacy/load/rotation/rollout drill | selected card의 operational R2 | + +낮은 row의 evidence를 높은 row나 다른 provider/account/region/workspace/mode로 일반화하지 않는다. +실 provider lane이 실행되지 않으면 코드는 구현될 수 있어도 해당 exact card는 +`NOT_QUALIFIED`다. + +## 5. Execution rules + +1. 모든 checkbox는 구현 시작 시 `[ ]`에서 시작한다. +2. 각 behavior task는 먼저 명시한 test를 작성하고 같은 focused command로 RED와 GREEN을 + 확인한다. +3. RED가 예상 원인이 아니라 compilation drift, 외부 환경 또는 unrelated dirty change로 + 실패하면 구현하지 말고 원인을 먼저 분리한다. +4. RED가 처음부터 통과하면 기존 coverage 또는 plan drift를 조사하고 test를 강화한다. +5. ordinary `test`/`check`에는 실제 network, credential, account 또는 skip 기반 성공을 넣지 + 않는다. +6. task가 끝날 때 focused test, owner leaf test/check, 그 task가 건드린 boundary gate 순으로 + 검증한다. +7. migration은 expand-first다. 새 worker와 provider는 canonical binding 전까지 dark/disabled다. +8. canonical disabled + legacy absent인 `PURE_DISABLED`에서 provider client, thread, scheduler, + probe, callback subscription, operator와 application/runtime table DML·scan은 0이어야 한다. + PRE legacy-only bridge는 별도 closed state다. expand-first V7 DDL, V8 structural + validation과 explicitly invoked final `notificationFreshProvisioning`은 runtime + zero-resource 계수에서 제외한다. exact empty FINAL startup은 + `AWAITING_SIGNED_FRESH_PROVISIONING`으로 liveness만 유지하고 provider/worker/admission/DML은 + 0이다. +9. rollback 시 accepted/indeterminate intent를 legacy path로 자동 resend하지 않는다. +10. active/retained row가 참조하는 template, binding, renderer, AEAD/HMAC revision을 제거하지 + 않는다. +11. shared workspace의 기존 Fileserver/JPA/Object Storage 및 file-publication 변경을 덮어쓰지 + 않는다. 각 task 시작 전 `git status --short`로 overlap을 다시 확인한다. +12. 새 타입이나 파일이 이 계획의 surface 밖에 필요하면 조용히 확장하지 말고 plan을 먼저 + 갱신한다. +13. 각 Wave exit에서 LLM Wiki branch-note를 갱신해 files, decisions, commands/results, + evidence grade와 blocker를 남긴다. 파생 raw 문서가 없으면 cluster에 “없음”을 명시한다. + +## 6. Stop conditions + +다음 중 하나라도 확인되면 해당 wave를 중단하고 설계/계획을 수정한다. + +- business DB와 notification journal이 같은 transaction manager에 참여하지 못한다. +- `TransactionPort.inRootWrite`가 ambient actual transaction을 side effect 전에 거부하거나 + physical commit-before-return을 보장하지 못한다. +- Slack/AWS SDK의 hidden retry를 끄거나 실제 physical attempt를 journal에 계수할 수 없다. +- SES configuration set, SNS TopicArn, HTTPS ingress, DLQ topology를 exact profile로 묶을 수 없다. +- provider callback signature 검증에 outbound notification adapter 의존이 필요하다. +- migration `V7`이 실행 시점에 이미 다른 의미로 사용 중이다. +- canonical/legacy activation을 동시에 허용해야만 rollout이 가능하다. +- independent issuer가 complete old-node inventory와 per-node irreversible + deployment/credential/egress fence를 서명·검증할 수 없다. +- BEGIN inventory 또는 quiescence evidence의 canonical signed payload, signature, issuer/trust + snapshot, issued/expires/verified time, environment/DB/artifact, consumer inventory identity, + provider-call-ledger identity/snapshot과 zero-node authority를 retained header로 보존하고 Java + write/startup에서 재검증할 수 없다. +- COMPLETE 뒤 paused old node나 revoked legacy credential/egress identity가 provider I/O 0임을 + 증명하지 못한다. +- V8이 exact empty를 `AWAITING_SIGNED_FRESH_PROVISIONING`으로 분류하거나 nonempty + missing-fence database를 mutation 0으로 거부하지 못한다. +- independent infrastructure issuer가 committed irreversible no-legacy-authority fence를 먼저 + 확인한 signed DB-birth authorization과 final-artifact `notificationFreshProvisioning` + one-transaction/two-function protocol을 제공하지 못한다. +- `notification_migrator`/`notification_runtime`/`notification_provisioner` role, safe + `SECURITY DEFINER` ownership/grant closure 또는 FINAL DML/sequence/execute revoke를 증명하지 + 못한다. +- real-provider evidence가 없는데 R2/production-ready 표현이 필요하다. + +--- + +## Wave A — Truth freeze and application foundation + +### Task 1: Freeze current R0 truth and protect unrelated changes + +**Owner leaf:** documentation + existing notification/bootstrap tests +**Depends on:** approved deep design +**Behavior change:** none + +**Files:** + +- Modify: + `src/adapter/outbound/notification/README.md` +- Modify: + `src/adapter/outbound/notification/CLAUDE.md` +- Test: + `src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/core/NotificationAdapterTest.java` +- Test: + `src/app-bootstrap/src/test/java/dev/caskeleton/adapter/outbound/OptionalAdapterBeanGatingTest.java` + +- [x] Record the starting `git status --short`, current branch, registry edges and current provider + dependencies in the branch-note. +- [x] Re-run the existing R0 behavior without changing it: + + ```bash + cd src && ./gradlew :adapter:outbound:notification:test \ + --tests '*NotificationAdapterTest' --console=plain + cd src && ./gradlew :app-bootstrap:test \ + --tests '*OptionalAdapterBeanGatingTest' --console=plain + ``` + +- [x] Document one truth table covering raw `NotificationPort`, fan-out routing, global fail-open, + fake-only `google-email`/`slack-webhook`, selector drift and production consumer count 0. +- [x] Mark all current provider seams `R0 legacy`; do not call them Slack/Email integration. +- [x] Preserve a deletion inventory for Wave G rather than adding behavior to legacy classes. +- [x] Acceptance: the baseline is reproducible, no source behavior changes, no unrelated dirty file + changes. + +**Rollback checkpoint:** documentation-only changes may be reverted independently; legacy tests remain +the executable baseline until the canonical-only cutover and deletion in Task 21. + +### Task 2: Add the physical root-write transaction contract + +**Owner leaves:** `application-core` (`:application-core`), then +`adapter-outbound-persistence-jpa` (`:adapter:outbound:persistence-jpa`) +**Depends on:** Task 1 + +**Files:** + +- Create: + `src/application-core/src/main/java/dev/caskeleton/application/transaction/NestedRootTransactionRejectedException.java` +- Modify: + `src/application-core/src/main/java/dev/caskeleton/application/transaction/TransactionPort.java` +- Modify: + `src/application-core/src/test/java/dev/caskeleton/application/transaction/TransactionPortTest.java` +- Modify: + `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringTransactionPort.java` +- Modify: + `src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringTransactionPortTest.java` +- Modify: + `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/CleanArchitectureTest.java` +- Modify: + `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/ArchitectureViolationFixtureTest.java` +- Modify: + `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/application/MissingTransactionBoundaryUseCase.java` +- Create: + `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/fixtures/application/RootWriteTransactionBoundaryUseCase.java` +- Modify only as mechanical interface implementers: + `src/application-core/src/test/java/dev/caskeleton/application/outbox/PublishPendingOutboxEventsUseCaseTest.java`, + `src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/authz/WorkLogAuthorizationE2ETest.java`, + `src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/authz/WorkLogAuthorizationContractTest.java`, + `src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/worklog/ListRecentWorkLogSummariesUseCaseTest.java`, + `src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/worklog/CreateWorkLogOutboxTest.java`, + `src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/worklog/WorkLogUseCasesTest.java` +- Modify: + `src/application-core/README.md`, + `src/application-core/CLAUDE.md`, + `src/adapter/outbound/persistence-jpa/README.md`, + `src/adapter/outbound/persistence-jpa/CLAUDE.md` + +- [x] Write RED tests proving: + `inRootWrite` is part of the framework-free contract; ambient actual transaction is rejected before + action/TM side effects; root execution is `WRITE + REQUIRED + READ_COMMITTED`; return occurs after + commit; commit failure propagates and no caller-visible committed result is produced. +- [x] Verify RED: + + ```bash + cd src && ./gradlew :application-core:test \ + --tests '*TransactionPortTest' --console=plain + cd src && ./gradlew :adapter:outbound:persistence-jpa:test \ + --tests '*SpringTransactionPortTest' --console=plain + cd src && ./gradlew :app-bootstrap:test \ + --tests '*CleanArchitectureTest' \ + --tests '*ArchitectureViolationFixtureTest' \ + --console=plain + ``` + + Expected failure: `inRootWrite`/typed rejection and adapter behavior do not exist. + +- [x] Add an abstract `inRootWrite` contract and update all current fake implementations explicitly; + do not provide a default that silently delegates to join-capable `inWrite`. +- [x] Implement the adapter precondition with actual transaction state inspection before + `TransactionTemplate.execute`. +- [x] Reuse a prebuilt `WRITE + REQUIRED + READ_COMMITTED` template. Do not add `NEVER` propagation or + a new `TransactionMode`. +- [x] Extend the transaction fitness rule so a `WRITE_REPOSITORY + WRITE` use case may directly call + either join-capable `inWrite` or root-only `inRootWrite`, while READ/REQUIRES_NEW mappings stay + unchanged. Add positive and negative fixtures so this is not a broad transaction bypass. +- [x] Verify GREEN with the same three commands. +- [x] Verify the architecture RED/GREEN with the third command too; the violation fixture must fail + for a declared WRITE boundary that calls neither `inWrite` nor `inRootWrite`, while the positive + root-write fixture passes. +- [x] Run compatibility regression: + + ```bash + cd src && ./gradlew :application-core:test \ + :adapter:outbound:persistence-jpa:test \ + :sample-portfolio:test --console=plain + ``` + +- [x] Acceptance: nested use fails before action/provider call, root return is post-commit, existing + `inWrite`/`inRead`/`inNew` semantics are unchanged. + +**Rollback checkpoint:** this public contract cannot be rolled back after Task 4 callers use it. +Before that point, revert the interface and all mechanical fake changes together. + +### Task 3: Introduce bounded application notification values and policy + +**Owner leaf:** `application-core` (`:application-core`) +**Depends on:** Task 2 + +**Files — create under** +`src/application-core/src/main/java/dev/caskeleton/application/notification/`: + +- `NotificationChannel.java` +- `NotificationIntentId.java` +- `NotificationDeliveryId.java` +- `NotificationAttemptId.java` +- `NotificationReceiptEventId.java` +- `NotificationKindId.java` +- `NotificationRouteId.java` +- `NotificationTemplateRef.java` +- `NotificationMode.java` +- `NotificationAdmissionClass.java` +- `NotificationRouteStrategy.java` +- `ConsentCheckMode.java` +- `NotificationRecipientReference.java` +- `EmailRecipientReference.java` +- `SlackAudienceReference.java` +- `NotificationTemplateValue.java` +- `NotificationTemplateParameters.java` +- `NotificationKindPolicy.java` +- `NotificationFrozenPlan.java` +- `NotificationIntentDraft.java` +- `SubmissionCertainty.java` +- `RetryDisposition.java` +- `NotificationFaultScope.java` +- `NotificationReasonCode.java` +- `ProviderAttemptOutcome.java` +- `TargetAttemptOutcome.java` +- `NotificationRequestResult.java` + +**Tests — create:** + +- `src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationValueContractTest.java` +- `src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationKindPolicyTest.java` +- `src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationRequestResultTest.java` + +- [x] Write RED tests for bounded/nonblank IDs, one-recipient typing, closed template scalar types, + locale/time bounds, immutable collections, redacted `toString`, and exact orthogonal outcome + axes. +- [x] Write RED policy tests proving mode/admission are code-owned, config cannot strengthen or + weaken them, and a critical kind cannot bind `BEST_EFFORT_INLINE`. +- [x] Verify RED: + + ```bash + cd src && ./gradlew :application-core:test \ + --tests '*NotificationValueContractTest' \ + --tests '*NotificationKindPolicyTest' \ + --tests '*NotificationRequestResultTest' \ + --console=plain + ``` + + Expected failure: the new semantic types and invariants do not exist. + +- [x] Implement only Java 21/framework-free records, sealed interfaces and immutable collections. +- [x] Do not add provider IDs, AWS/Slack types, raw JSON, `Map`, inbound DTOs, raw HTML + or arbitrary address/channel union types. +- [x] Keep feature-specific factory examples in test fixtures; do not add WorkLog or password-reset + business concepts to production packages. +- [x] Verify GREEN with the same command and then: + + ```bash + cd src && ./gradlew :application-core:test --console=plain + ``` + +- [x] Acceptance claim: application semantic contract R1 only; no provider or durable evidence yet. + +**Rollback checkpoint:** no external side effects/schema. Revert this whole value cluster before ports +in Task 4 depend on it. + +### Task 4: Add application ports, dispatcher and receipt reducer contracts + +**Owner leaf:** `application-core` (`:application-core`) +**Depends on:** Task 3 + +**Files — create under** +`src/application-core/src/main/java/dev/caskeleton/application/notification/`: + +- `InlineNotificationAttemptPort.java` +- `NotificationIntentAppendPort.java` +- `NotificationPlanPort.java` +- `NotificationPlanningResult.java` +- `NotificationAppendResult.java` +- `NotificationDeliveryStorePort.java` +- `NotificationProviderAttemptPort.java` +- `NotificationTechnicalSuppressionPort.java` +- `NotificationReceiptStorePort.java` +- `NotificationMaintenanceStorePort.java` +- `NotificationReconciliationPort.java` +- `NotificationAdmissionReadinessPort.java` +- `NotificationCanonicalWriterFencePort.java` +- `NotificationCanonicalWriterRouteSet.java` +- `NotificationWriterRouteSet.java` +- `NotificationWriterCutoverPort.java` +- `NotificationWriterQuiescenceAttestationPort.java` +- `NotificationWriterInventoryEvidenceVerifierPort.java` +- `NotificationWriterInventoryEvidence.java` +- `NotificationSignedEvidenceHeader.java` +- `NotificationEvidenceTrustSnapshot.java` +- `SignedNotificationWriterInventoryManifest.java` +- `SignedNotificationWriterQuiescenceManifest.java` +- `InitializeNotificationWriterFencesCommand.java` +- `InitializeNotificationWriterFencesResult.java` +- `InitializeNotificationWriterFencesOperation.java` +- `InitializeNotificationWriterFencesUseCase.java` +- `NotificationCanonicalWriterFenceGuard.java` +- `NotificationLegacyWriterPermitCommand.java` +- `NotificationLegacyWriterPermitResult.java` +- `NotificationLegacyWriterPermitUseCase.java` +- `TerminalizeExpiredNotificationWriterPermitsCommand.java` +- `TerminalizeExpiredNotificationWriterPermitsResult.java` +- `TerminalizeExpiredNotificationWriterPermitsOperation.java` +- `TerminalizeExpiredNotificationWriterPermitsUseCase.java` +- `RecordNotificationWriterQuiescenceAttestationCommand.java` +- `RecordNotificationWriterQuiescenceAttestationResult.java` +- `RecordNotificationWriterQuiescenceAttestationOperation.java` +- `RecordNotificationWriterQuiescenceAttestationUseCase.java` +- `SwitchNotificationWriterOwnershipCommand.java` +- `SwitchNotificationWriterOwnershipResult.java` +- `SwitchNotificationWriterOwnershipOperation.java` +- `SwitchNotificationWriterOwnershipUseCase.java` +- `NotificationWriterOwnership.java` +- `NotificationDispatchCommand.java` +- `NotificationDispatchResult.java` +- `NotificationDispatchUseCase.java` +- `NormalizedNotificationReceiptCommand.java` +- `NotificationReceiptFact.java` +- `NotificationReceiptProjection.java` +- `ApplyNotificationReceiptCommand.java` +- `ApplyNotificationReceiptResult.java` +- `ApplyNotificationReceiptUseCase.java` +- `NotificationAdmissionGateCommand.java` +- `NotificationAdmissionGateUseCase.java` +- `ReconcileNotificationDeliveriesCommand.java` +- `ReconcileNotificationDeliveriesResult.java` +- `ReconcileNotificationDeliveriesUseCase.java` +- `NotificationMaintenanceCommand.java` +- `NotificationMaintenanceResult.java` +- `NotificationMaintenanceUseCase.java` +- `NotificationProviderCapabilityDescriptor.java` +- `NotificationStoreCapabilityDescriptor.java` +- `NotificationReceiptIngressCapabilityDescriptor.java` +- `NotificationCapabilityCompatibilityValidator.java` +- `NotificationOperationsSnapshotPort.java` +- `NotificationOperationsSnapshot.java` +- `NotificationOperationsSnapshotQuery.java` +- `NotificationOperationsSnapshotUseCase.java` +- `NotificationApplicationException.java` + +**Tests — create:** + +- `src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationPortBoundaryTest.java` +- `src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationPlanningBoundaryTest.java` +- `src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationDispatchUseCaseTest.java` +- `src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationReceiptReducerTest.java` +- `src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationAdmissionGateUseCaseTest.java` +- `src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationCanonicalWriterFenceGuardTest.java` +- `src/application-core/src/test/java/dev/caskeleton/application/notification/InitializeNotificationWriterFencesUseCaseTest.java` +- `src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationLegacyWriterPermitUseCaseTest.java` +- `src/application-core/src/test/java/dev/caskeleton/application/notification/TerminalizeExpiredNotificationWriterPermitsUseCaseTest.java` +- `src/application-core/src/test/java/dev/caskeleton/application/notification/RecordNotificationWriterQuiescenceAttestationUseCaseTest.java` +- `src/application-core/src/test/java/dev/caskeleton/application/notification/SwitchNotificationWriterOwnershipUseCaseTest.java` +- `src/application-core/src/test/java/dev/caskeleton/application/notification/ReconcileNotificationDeliveriesUseCaseTest.java` +- `src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationMaintenanceUseCaseTest.java` +- `src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationCapabilityCompatibilityValidatorTest.java` +- `src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationOperationsSnapshotUseCaseTest.java` + +- [x] Execute Task 4 as five sequential RED/GREEN subcycles, never as one large implementation: + (1) port/planning boundary, (2) dispatch state machine, (3) receipt reducer, + (4) admission/reconciliation/maintenance, and (5) compatibility descriptors/validator. + Run only the named focused test(s) for a subcycle before starting the next, then run the + combined command below. +- [x] Write RED port tests proving append joins caller transaction semantics, provider SDK/entity/DTO + types are absent, and no giant send/store/receipt port is introduced. +- [x] Write RED planning handoff tests: + feature factory + `NotificationKindPolicy` produce a draft; `NotificationPlanPort` returns only + application-owned `NotificationFrozenPlan`; append and inline ports consume that frozen plan; + adapter compiled binding/profile types never cross into application or persistence. +- [x] Write RED dispatcher tests for the sequence: + short claim transaction -> short reserve/authorize transaction -> provider outside transaction + -> terminal-once result transaction. +- [x] Cover stale claim token, distinct attempt execution token, late exact result, fallback only on + `DEFINITELY_NOT_APPLIED`, and terminal indeterminate without blind retry. +- [ ] Cover `PARK_BINDING`: gate CAS by scope/generation, parked leg not hot-looping, audited resume + rechecking expiry/cancel/suppression and not activating initial fallback. +- [ ] Cover a route-specific single-writer fence with an exact database generation and owner + (`LEGACY` or `CANONICAL`). Both legacy admission and canonical intent admission must present the + expected generation; stale/mismatched ownership fails closed before append or provider I/O. + Split the transaction contracts: `NotificationCanonicalWriterFenceGuard` asserts canonical + ownership inside the caller's business-write/intent-append transaction and holds a tested + share lock that conflicts with `BEGIN_DRAIN` until physical commit/rollback; + `NotificationLegacyWriterPermitUseCase` physically commits a bounded acquire before provider + I/O, returns DB-time acquired/wire-deadline/expiry facts, and releases afterward. The wrapper/ + client refuses network start after the committed absolute wire deadline and enforces the + smaller of its monotonic elapsed budget and DB interval; + `TerminalizeExpiredNotificationWriterPermitsUseCase` is a distinct PRE-only authenticated + root-write operation that, only during exact DRAINING, terminalizes bounded DB-time-expired + ACTIVE permits across all generations with registry-bound token/rowVersion CAS; + `SwitchNotificationWriterOwnershipUseCase` physically commits the + audited `BEGIN_DRAIN`, `COMPLETE_SWITCH` or `ABORT_DRAIN` CAS. `BEGIN_DRAIN` verifies an + independently signed, short-lived exact environment/DB/route/PRE-artifact complete + old-writer inventory through `NotificationWriterInventoryEvidenceVerifierPort`, then + atomically freezes its canonical node row set/count/digest while closing new legacy acquire; + caller-authored node digests have no authority. The operations query polls active count/max + expiry outside a transaction; + `COMPLETE_SWITCH` refuses unsafe legacy permits. Freeze the only transition matrix: + `ACTIVE/LEGACY@g -> DRAINING/LEGACY@g` (BEGIN), + unchanged DRAINING (terminalize), + `DRAINING/LEGACY@g -> ACTIVE/CANONICAL@g+1` (COMPLETE), or + `DRAINING/LEGACY@g -> ACTIVE/LEGACY@g+1` (ABORT). Requests never supply a target owner; + every action from CANONICAL and every reverse transition fails with mutation 0. Crashed permits become + `EXPIRED_PROVEN` only for a transport profile with tested hard bounds; current R0 timeouts + become `TIMED_OUT_UNPROVEN`. For an unproven profile, an authenticated, immutable, fresh + route/generation quiescence attestation is mandatory even when the unproven set is empty. + Its independently signed manifest must exactly match the complete BEGIN node inventory and bind + per-node retired/quiesced facts plus deployment-generation tombstones and legacy + credential/egress revocation that make resume impossible, production consumer inventory/count 0 and provider-call ledger + identity/open-count 0. ACTIVE permit 0 remains mandatory for every profile and cannot be + overridden by attestation. It includes the bounded canonical + digest of every generation/profile `TIMED_OUT_UNPROVEN` + `(token,generation,profile,state,rowVersion)` permit tuple, the distinct permit-holder set and + the persisted transport-proof registry; COMPLETE locks/recomputes the exact sets and records + its token/digest. Every permit holder must be in the BEGIN inventory. The route-level + proof requirement is `QUIESCENCE_REQUIRED` when any current/retiring persisted registry + profile is `QUIESCENCE_REQUIRED`; only an all-`HARD_BOUND_PROVEN` registry may use the + hard-bound path. Every switch + operation carries authenticated actor, reason and reviewed target + generation. For every COMPLETE arm, application validation alone is insufficient: the + persistence port must lock the retained BEGIN inventory header/children and pass its canonical + payload/signature/SPKI/trust snapshot back through the Java verifier before the ownership + mutation. It rejects a missing, altered, unverifiable or semantically mismatched BEGIN even + when the permit/registry rows are structurally valid. QUIESCENCE_REQUIRED additionally locks + and revalidates the committed signed quiescence header, exact per-node + tombstone/revocation rows, ACTIVE permit 0 and provider-ledger open-count 0. + HARD_BOUND_PROVEN forbids a quiescence header/children but still requires the verified signed + BEGIN plus the exact all-hard-bound registry/evidence revision, safe terminal permits and + ACTIVE permit 0. The ownership transaction returns only after physical commit. Manifest + expiry gates admission into an immutable evidence header; once verified and committed, the + signed deployment/credential/egress fences are durable monotonic facts and are not converted + into a pre-commit TTL guard. No use case sleeps or holds a DB transaction while waiting. + The route's exact transport profile/proof/evidence registry comes from + `NotificationWriterRouteSet`, not permit rows or request data; therefore a + `QUIESCENCE_REQUIRED` route with permit count 0 still requires attestation. +- [ ] Cover audited batch `InitializeNotificationWriterFencesUseCase`: it rejects ambient + transactions and root-commits the bounded ordered reviewed route set as + `ACTIVE/LEGACY@predecessor` only when the command set/digest exactly equals + `NotificationWriterRouteSet` derived from the compiled cutover route catalog and every + notification control/data-plane journal table is empty. It inserts all + fences, one immutable operation header, all route-result children and the exact route/profile/ + admission-role/proof-class/evidence-revision registry snapshot atomically; partial or + sequential route initialization is forbidden. Initialization and every later switch append + immutable operation history in the same root transaction as fence mutation. Replay of any old + same-token/same-input route set returns its stored committed result; token reuse with a + different route set/action/input and existing/mismatched/nonempty state fail without mutation. + A mutable `last_operation_token` fence field is never the audit or replay SSOT. +- [ ] Cover `TerminalizeExpiredNotificationWriterPermitsUseCase`: it is not a scheduler, snapshot + query or COMPLETE side effect. An authenticated PRE operator supplies exact route/drain + generation, bounded batch (`<=100`), reason and opaque operation token. The root transaction + locks `DRAINING/LEGACY`, the immutable persisted registry and DB-time-expired ACTIVE permits + across all historical generations in canonical order. It CASes exact token/rowVersion to + `EXPIRED_PROVEN` only for HARD_BOUND_PROVEN or `TIMED_OUT_UNPROVEN` only for + QUIESCENCE_REQUIRED, then appends affected count/set digest and actor/reason in the operation + journal before commit. Same-token/same-input replay returns the stored result; changed input, + non-DRAINING fence, unknown/drifted profile, nonexpired row or commit failure changes nothing. + Idempotency lookup precedes set selection, so replay still returns the original affected result + after those rows are terminal. The affected set is a derived result, not caller input, and its + digest covers the sorted immutable post-CAS tuple. The operation journal persists requested + batch bound and a server-canonical request-input digest so changed-input token reuse fails. It + performs provider I/O 0 and never updates the fence's latest-mutation pointer. +- [ ] Cover `RecordNotificationWriterQuiescenceAttestationUseCase`: only a method-security operator + path may root-commit an immutable exact route/draining-generation/transport-profile + set attestation after BEGIN_DRAIN. It accepts a bounded signed quiescence manifest, verifies it + through the trusted issuer-key port, and server-derives the bounded sorted blocking permit + `(token,generation,profile,state,rowVersion)` set/count/digest, distinct holder set, frozen BEGIN + old-node set, consumer inventory/count 0 and provider-call-ledger identity/open-count 0. Exact + node-set equality, holder subset, per-node retired/quiesced + restart/credential/egress + revocation facts, environment/DB/artifact/ + generation identity and bounded freshness are mandatory; caller-provided node/zero-fact digest + is never authoritative. Same-token/same-input replay is idempotent and mismatch is rejected. + `COMPLETE_SWITCH` for an unproven transport requires the attestation token and fails on + missing/stale/wrong-route/wrong-generation, partial multi-profile/node coverage, omitted/extra + node or permit holder, changed permit-set digest, registry mismatch, unsigned/unknown-issuer + evidence or nonzero facts. After initialization, proof data comes from the immutable persisted + registry/BEGIN inventory; caller data and permit rows cannot invent it. + Both BEGIN and attestation persist a first-class immutable signed-evidence header containing + exact canonical payload bytes/profile, signature bytes/digest, algorithm, issuer key ID, + bounded canonical issuer public-key SPKI plus its digest and trust-catalog + revision/historical-key-status snapshot, issued/expires/verified DB times, + profile-pinned `allowedClockSkew` and `acceptanceMargin`, + environment/DB/artifact identity, consumer-inventory identity, provider-call-ledger identity/ + snapshot and canonical child count/set digest. A zero-node manifest still creates one header, + so issuer authority is never hidden in absent child rows. Java write validation and canonical + startup reverify stored payload/signature/SPKI, header/child exact equality and that the + historical key digest remains allowed/non-revoked in the current closed catalog. Admission + requires + `issuedAt - allowedClockSkew <= serverVerifiedAt <= expiresAt - acceptanceMargin`; after + acceptance, expiry does not reverse the recorded irreversible facts. SQL owns only structural + FK/digest/state/window-shape constraints and never claims Ed25519 verification. +- [ ] Cover the stale-node safety proof that closes the paused-node race: pause an old bridge node + after its last permitted local step, commit BEGIN, signed per-node deployment-generation + tombstone plus legacy credential/egress revocation, ACTIVE permit 0, provider-ledger 0 and + COMPLETE, then resume that exact process. Its legacy client must fail before provider network + I/O, the revoked credential/egress identity must record provider-call count 0, and canonical + ownership must remain the only admitted writer. Repeat for a cached credential and an already + constructed client/connection. If this cannot be proven, keep the route DRAINING and + `NOT_QUALIFIED`. +- [x] Cover periodic reconciliation as a separate use case: + bounded claim transaction -> provider reconciliation outside transaction -> token/version + guarded result transaction; orphan attach without provider I/O stays in the bounded store + transaction. A scheduler must not call store/provider ports itself. +- [x] Cover receipt fact permutations so `SEND/DELIVERY/BOUNCE/COMPLAINT/DELIVERY_DELAY` produce the + same orthogonal projection independent of order; accepted fact is never erased. +- [x] Keep technical-suppression policy in the receipt reducer/application use case: hard bounce and + complaint may emit an explicit suppression mutation; transient/delayed/soft bounce does not. + Business consent/unsubscribe remains outside this capability. +- [x] Cover the pure compatibility validator: application policy mode/admission, provider/store/ + ingress descriptors, exact receipt requirement and frozen revision availability. Concrete + adapter settings/types must not enter the validator. +- [x] Cover the operational read boundary: bootstrap never calls + `NotificationOperationsSnapshotPort` directly. A concrete + `NotificationOperationsSnapshotUseCase implements + QueryUseCase` invokes + the port only inside `TransactionPort.inRead` and returns bounded, non-sensitive values. +- [x] Verify RED: + + ```bash + cd src && ./gradlew :application-core:test \ + --tests '*NotificationPortBoundaryTest' \ + --tests '*NotificationPlanningBoundaryTest' \ + --tests '*NotificationDispatchUseCaseTest' \ + --tests '*NotificationReceiptReducerTest' \ + --tests '*NotificationAdmissionGateUseCaseTest' \ + --tests '*NotificationCanonicalWriterFenceGuardTest' \ + --tests '*InitializeNotificationWriterFencesUseCaseTest' \ + --tests '*NotificationLegacyWriterPermitUseCaseTest' \ + --tests '*TerminalizeExpiredNotificationWriterPermitsUseCaseTest' \ + --tests '*RecordNotificationWriterQuiescenceAttestationUseCaseTest' \ + --tests '*SwitchNotificationWriterOwnershipUseCaseTest' \ + --tests '*ReconcileNotificationDeliveriesUseCaseTest' \ + --tests '*NotificationMaintenanceUseCaseTest' \ + --tests '*NotificationCapabilityCompatibilityValidatorTest' \ + --tests '*NotificationOperationsSnapshotUseCaseTest' \ + --console=plain + ``` + + Expected failure: ports/use cases/state transitions do not exist. + +- [x] Implement `NotificationDispatchUseCase` as a manually wired + `CommandUseCase` with + `WRITE`, `IDEMPOTENT`, `WRITE_REPOSITORY`, `externalOutboundAllowed=true` capability metadata. +- [x] Make receipt apply, admission operation, writer-fence initialization, legacy writer permit, + expired-permit terminalization, quiescence attestation, writer ownership switch and maintenance + concrete `CommandUseCase` + implementations too. The + canonical guard is an internal + application policy collaborator invoked only from an existing application write use case, not + a bootstrap-callable `*UseCase`. Annotate every concrete use case with exact existing + capability vocabulary and a type-level permission: + dispatch `notification:dispatch`, receipt apply `notification:receipt`, admission operation + `notification:operate`, fence initialization and ownership switch `notification:cutover`, + legacy writer permit `notification:cutover-admit`, expired-permit terminalization + `notification:cutover-terminalize`, quiescence attestation + `notification:cutover-attest`, maintenance `notification:maintain`. + Receipt apply uses + `WRITE + WRITE_REPOSITORY + IDEMPOTENT` and calls `inRootWrite`; dispatch/maintenance use + `externalOutboundAllowed=true` only when they actually call provider/reconciliation ports. + Do not add a `capabilities.yaml` row because no new capability attribute is introduced. +- [x] Freeze the exact capability matrix: + + | use case | transaction/repository | idempotency | external | direct boundary | + | --- | --- | --- | --- | --- | + | dispatch | `WRITE` / `WRITE_REPOSITORY` | `IDEMPOTENT` | true | `inWrite` claim/authorize/finalize | + | receipt apply | `WRITE` / `WRITE_REPOSITORY` | `IDEMPOTENT` | false | `inRootWrite` | + | admission operate | `WRITE` / `WRITE_REPOSITORY` | `IDEMPOTENT` | true | probe outside, then `inWrite` | + | writer fence initialize | `WRITE` / `WRITE_REPOSITORY` | `IDEMPOTENT` | false | `inRootWrite` | + | legacy writer permit | `WRITE` / `WRITE_REPOSITORY` | `IDEMPOTENT` | false | `inRootWrite` acquire/release | + | expired permit terminalize | `WRITE` / `WRITE_REPOSITORY` | `IDEMPOTENT` | false | bounded `inRootWrite` | + | writer quiescence attest | `WRITE` / `WRITE_REPOSITORY` | `IDEMPOTENT` | false | `inRootWrite` | + | writer ownership switch | `WRITE` / `WRITE_REPOSITORY` | `IDEMPOTENT` | false | `inRootWrite` | + | reconcile | `WRITE` / `WRITE_REPOSITORY` | `IDEMPOTENT` | true | `inWrite`, provider outside | + | maintenance | `WRITE` / `WRITE_REPOSITORY` | `IDEMPOTENT` | false | bounded `inWrite` | + | operations snapshot | `READ_ONLY` / `READ_REPOSITORY` | `IDEMPOTENT` | false | `inRead` | + + `sensitiveRead=true` for dispatch and reconcile because their safe application models still + carry decrypted recipient/template data or opaque provider references; receipt/admission/ + maintenance may remain false only when tests prove their application values contain + digest/ciphertext/closed reason fields rather than plaintext. `bulkWrite=false` is valid only + because every claim/receipt/maintenance batch is validated `<=100`; raising that cap requires + `bulkWrite=true`. Initial infrastructure dispatch, provider/account admission, reconcile and + retention sweeps span tenant partitions and therefore declare `crossTenantAdmin=true`; + single-correlated-receipt apply remains false. A future tenant-partitioned command may lower + that flag only with query/fitness evidence. Exact permission tokens are + `notification:dispatch`, `notification:receipt`, `notification:operate`, + `notification:cutover-admit`, `notification:cutover-terminalize`, + `notification:cutover-attest`, `notification:cutover`, + `notification:reconcile`, + `notification:maintain`, `notification:observe`. + Operations snapshot is `sensitiveRead=false`, `bulkWrite=false`, + `crossTenantAdmin=true` because it returns only bounded infrastructure aggregates across + partitions. + All writer cutover operations are `sensitiveRead=false`, `bulkWrite=false` and + `crossTenantAdmin=true`. Initialization requires a bounded ordered route/initial-generation + set exactly equal to the application-owned route set derived from the compiled cutover + catalog, its digest, actor/reason/token, absent fences and empty control/data-plane journals. + BEGIN requires a trusted signed complete old-node inventory; quiescence attestation requires + exact route/draining generation, a signed exact inventory/quiescence/consumer/ledger manifest + and token. Actor, post-lock DB-time validity, immutable persisted current+retiring proof + registry, permit/holder sets and node inventory digests are server-derived. Permit + acquire/release require exact route, + LEGACY owner/generation and opaque token; ownership switch requires exact route/expected + generation/token and derives owner/result from the closed action matrix. Switch additionally + requires exact + `BEGIN_DRAIN|COMPLETE_SWITCH|ABORT_DRAIN` action plus audited actor/reason. Initialization, + attestation, terminalization, acquire, release and every switch action reject ambient + transactions and return success only after `inRootWrite` physical commit. A + every COMPLETE additionally succeeds only when the persistence adapter locks and Java + re-verifies the retained signed BEGIN inventory. QUIESCENCE_REQUIRED also revalidates its + signed durable quiescence proof and monotonic zero/irreversible facts. HARD_BOUND forbids + quiescence evidence and accepts only that verified BEGIN, registry-qualified safe terminal + permits and ACTIVE permit 0. +- [x] Keep retry/fallback/admission state policy in application, not mapper/config/scheduler. +- [ ] Make maintenance/reconciliation/retention schedulers call application use cases; app-bootstrap + must not call repositories or persistence entities directly. +- [x] Use injected `Clock`; use bounded batch/deadline/count values; do not sleep inside the use case. +- [x] Verify GREEN with the same command and run: + + ```bash + cd src && ./gradlew :application-core:check --console=plain + ``` + +- [x] Acceptance claim: pure orchestration/state model is proven with fakes; PostgreSQL/provider R2 + is not yet proven. + +**Rollback checkpoint:** Task 4 is the public port boundary. Later adapters may be rolled back by +removing bindings, but these types must remain while compiled consumers exist. + +### Wave A exit gate + +- [x] Run: + + ```bash + cd src && ./gradlew :application-core:check \ + :adapter:outbound:persistence-jpa:check \ + verifyCleanArchitectureDependencies \ + verifyPublicPathSnapshot \ + --console=plain + cd src && ./gradlew :app-bootstrap:test \ + --tests '*CleanArchitectureTest' --console=plain + ``` + + Isolated-worktree evidence: both commands pass, including the wildcard architecture test. + +- [x] Confirm application bytecode/import scan contains no Spring, JPA, Slack, AWS, JSON or HTTP + provider type. +- [x] Request an application/transaction boundary review before Wave B. +- [x] Update the LLM Wiki branch-note with Wave A evidence and an explicit derived-document decision. + +--- + +## Wave B — Notification-local catalog, rendering and provider protocol + +### Task 5: Build provider/template/route descriptors and binding compiler + +**Owner leaf:** `adapter-outbound-notification` (`:adapter:outbound:notification`) +**Depends on:** Task 4 + +**Files — create:** + +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/catalog/NotificationProviderDescriptor.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/catalog/NotificationProviderCapabilityCard.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/catalog/NotificationTemplateDescriptor.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/catalog/NotificationRouteDescriptor.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/catalog/NotificationCanonicalRouteCatalog.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/catalog/NotificationCutoverRouteCatalog.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/catalog/NotificationProviderRuntimeProfile.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/catalog/CompiledNotificationBinding.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/catalog/NotificationBindingCompiler.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/catalog/NotificationPlanAdapter.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/catalog/NotificationProviderCapabilityDescriptorSource.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/catalog/NotificationCatalogException.java` +- `src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/catalog/NotificationBindingCompilerTest.java` +- `src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/catalog/NotificationCanonicalRouteCatalogTest.java` +- `src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/catalog/NotificationCutoverRouteCatalogTest.java` + +- [x] RED cases: unknown/blank/duplicate local catalog entry; channel/provider mismatch; + durable + legacy/fail-open; receipt-required + unsupported provider; unsafe fallback after + indeterminate; target/retry/reconcile/amplification bound; unsupported non-`SINGLE` binding. + Initial R1 intentionally compiles only the three reviewed `SINGLE` cards. +- [ ] RED cutover-catalog cases: + `NotificationCanonicalRouteCatalog` is the retained key-only canonical SSOT and maps with the + trusted runtime target config to application-owned `NotificationCanonicalWriterRouteSet`. + PRE-only `NotificationCutoverRouteCatalog` decorates exactly those keys with legacy aliases + and transport proof metadata and maps to transitional `NotificationWriterRouteSet`; it cannot + add/remove keys. + the sorted route-revision key set, optional legacy alias and each route's bounded + current+retiring legacy transport profile registry are one checked-in PRE SSOT. The registry + marks one active admission profile and, for every revision, proof class + (`HARD_BOUND_PROVEN|QUIESCENCE_REQUIRED`) plus evidence revision; its key + set exactly equals the canonical binding graph and reviewed V8 provenance-bound seed manifest. + Runtime cutover + target generations are a separate exact config/evidence revision and may not add or remove + catalog keys. PRE bridge possible routes must equal this set even when production consumer + count and current legacy route settings are 0. Missing/extra/duplicate alias, route-key drift + and an R0 mapping/profile outside the catalog, an omitted historical blocking profile, absent + proof evidence or a profile marked HARD_BOUND without the reviewed integration evidence + revision fail closed. A retiring profile cannot be removed while any permit/attestation/ + operation/persisted-registry history references it. The registry digest covers the sorted + route/profile/admission-role/proof-class/evidence-revision tuple set and changes on any drift. + A catalog route + with no live legacy consumer is initialized as closed LEGACY predecessor and switched through + the audited protocol; it is never directly seeded canonical in PRE. +- [x] Verify RED: + + ```bash + cd src && ./gradlew :adapter:outbound:notification:test \ + --tests '*NotificationBindingCompilerTest' \ + --tests '*NotificationCanonicalRouteCatalogTest' \ + --tests '*NotificationCutoverRouteCatalogTest' \ + --console=plain + ``` + + Expected failure: canonical descriptors/compiler do not exist. + +- [x] Implement a pure, deterministic compiler over explicit input; do not inspect Spring beans, + application context, persistence schema or inbound adapters. +- [x] Keep `expected-state`, exact actual/expected binding IDs, application mode/admission matching, + store capability and ingress topology out of this sibling-local compiler. Task 17 passes + provider-neutral descriptors to the application compatibility validator for those checks. +- [x] Register only the three initial card IDs. Legacy descriptors must explicitly advertise R0, + no durable/receipt capability. +- [x] Emit a sorted immutable binding graph and manifest digest; unknown inputs fail closed. +- [ ] Emit one immutable `NotificationCutoverRouteCatalog` and digest from the same route descriptor + inputs over the retained immutable `NotificationCanonicalRouteCatalog`. Bootstrap converts the + canonical catalog to `NotificationCanonicalWriterRouteSet` and the PRE decorator to + `NotificationWriterRouteSet`; neither + legacy settings nor a request may invent/remove route revisions. Bootstrap combines that key + set with the exact reviewed runtime target-generation config; only a reviewed config revision + may change target values after ABORT, and it invalidates qualification evidence. Bootstrap + also maps the catalog's immutable current+retiring transport proof registry into the + application route set; permit acquire uses only the active profile, while timeout, + attestation and COMPLETE must recognize every referenced current/retiring profile even when a + route has zero permits. `NotificationCutoverRouteCatalogTest` freezes the digest algorithm and + proves the exact registry value that batch initialization must persist; after initialization, + PRE composition rejects any persisted/catalog mismatch instead of silently refreshing it. + Static catalog/digest/key-set validation is implemented; persisted-registry comparison and + bootstrap enforcement remain explicitly deferred to Task 17. +- [x] Implement `NotificationPlanPort` by converting the selected adapter-local compiled binding into + an application-owned `NotificationFrozenPlan`. The conversion freezes policy/route/template/ + renderer/provider-leg revisions and contains no credential, SDK, settings or adapter type. +- [x] Derive the application-owned provider capability descriptor from the actual compiled cards, + renderer and client capabilities. Do not reconstruct “actual” provider facts from the expected + bootstrap settings. +- [x] Verify GREEN with the same command. +- [x] Acceptance claim: local graph compatibility only, not actual composition/readiness. + +**Rollback checkpoint:** compiler can coexist dark with the legacy router until Task 18 canonical +composition succeeds. + +### Task 6: Implement immutable local template manifests and bounded renderers + +**Owner leaf:** `adapter-outbound-notification` (`:adapter:outbound:notification`) +**Depends on:** Task 5 + +**Files — create:** + +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/template/NotificationTemplateCatalog.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/template/NotificationTemplateManifest.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/template/NotificationTemplateRenderer.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/template/RenderedNotification.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/template/LocalEmailRenderer.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/template/SlackBlockKitRenderer.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/template/TemplateRenderingException.java` +- `src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/template/NotificationTemplateRendererTest.java` +- `src/adapter/outbound/notification/src/test/resources/notification/templates/email/contract-v1.subject.txt` +- `src/adapter/outbound/notification/src/test/resources/notification/templates/email/contract-v1.text.txt` +- `src/adapter/outbound/notification/src/test/resources/notification/templates/email/contract-v1.html` +- `src/adapter/outbound/notification/src/test/resources/notification/templates/slack/contract-v1.txt` + +- [x] RED cases: checksum/revision drift; missing/unknown/unused parameter; exact locale fallback + independent of JVM default; email header CR/LF; HTML text/attribute/URL escaping; Slack + mrkdwn/plain-text/mention escaping; output byte/block/depth limits; no file/network/reflection + include; redacted failures. +- [x] Verify RED: + + ```bash + cd src && ./gradlew :adapter:outbound:notification:test \ + --tests '*NotificationTemplateRendererTest' --console=plain + ``` + +- [x] Implement checked-in resource loading by exact manifest/checksum. Keep business-specific + assets out of production main resources until a consuming project supplies a reviewed catalog; + use test resources only for the generic contract proof. +- [x] Produce local email subject/text/HTML and Slack Block Kit through typed builders; never accept + caller-supplied arbitrary JSON or provider block objects. +- [x] Verify GREEN with the same command. +- [x] Acceptance claim: deterministic local render R1; no provider call. + +**Rollback checkpoint:** retained intent template revisions prevent later asset deletion. Before +durable append, this task is independently reversible. + +### Task 7: Define the adapter-internal one-authorized-attempt SPI + +**Owner leaf:** `adapter-outbound-notification` (`:adapter:outbound:notification`) +**Depends on:** Tasks 5–6 + +**Files — create:** + +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/provider/NotificationProviderAttemptClient.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/provider/PreparedNotificationAttempt.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/provider/NotificationAttemptContext.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/provider/AttemptCorrelationId.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/provider/ProviderMessageReference.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/provider/ReconciliationLookupMode.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/provider/NotificationProviderAttemptAdapter.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/provider/InlineNotificationAttemptAdapter.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/provider/NotificationReconciliationAdapter.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/provider/NotificationProviderSecretMaterialProvider.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/provider/NotificationSecretMaterialHandle.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/provider/NotificationProviderReadinessProbe.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/provider/NotificationProviderReadinessSnapshot.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/provider/NotificationProviderRateAdmission.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/provider/NotificationAdmissionReadinessAdapter.java` +- `src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/provider/NotificationProviderAttemptContractTest.java` +- `src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/provider/NotificationSecretMaterialHandleTest.java` +- `src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/provider/InlineNotificationAttemptAdapterTest.java` +- `src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/provider/NotificationReconciliationAdapterTest.java` +- `src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/provider/NotificationAdmissionReadinessAdapterTest.java` + +- [x] RED cases: `prepare` has no I/O; one authorization invokes client exactly once; deadline is + absolute/bounded; pre-wire validation maps to definitely-not-applied; possible write timeout + maps indeterminate; accepted response stores only opaque provider reference; SDK exceptions + never escape to application. +- [x] Verify RED: + + ```bash + cd src && ./gradlew :adapter:outbound:notification:test \ + --tests '*NotificationProviderAttemptContractTest' \ + --tests '*NotificationSecretMaterialHandleTest' \ + --tests '*InlineNotificationAttemptAdapterTest' \ + --tests '*NotificationReconciliationAdapterTest' \ + --tests '*NotificationAdmissionReadinessAdapterTest' \ + --console=plain + ``` + +- [x] Keep this SPI adapter-internal. Implement the application + `NotificationProviderAttemptPort` with compiled binding + renderer + internal client lookup. +- [x] Keep secret material resolution, control-plane readiness and provider-local rate admission + behind adapter-owned interfaces. Profiles contain secret references/generations only; readiness + snapshots contain bounded non-secret identity/capability facts. +- [x] Secret acquisition returns a versioned `AutoCloseable` mutable byte/char handle. Acquire it per + provider operation, close it on success/exception/cancellation, wipe on close, reject use after + close, and redact `toString`/exceptions. Never store the raw token in an adapter-owned + record/String/settings field; the wipe claim covers only the adapter-facing mutable copy. +- [x] Implement the application-owned `NotificationAdmissionReadinessPort` with the adapter-internal + readiness probes. Application admission use cases must never import the internal probe type. +- [x] Freeze the outbound binding matrix: + `NotificationPlanPort -> NotificationPlanAdapter`, + `InlineNotificationAttemptPort -> InlineNotificationAttemptAdapter`, + `NotificationProviderAttemptPort -> NotificationProviderAttemptAdapter`, + `NotificationReconciliationPort -> NotificationReconciliationAdapter`, + `NotificationAdmissionReadinessPort -> NotificationAdmissionReadinessAdapter`. + Every implementation has a focused contract test before composition. +- [x] Keep attempt correlation, optional provider operation key and post-response message reference + as distinct types. +- [x] Verify GREEN with the same command and: + + ```bash + cd src && ./gradlew :adapter:outbound:notification:check --console=plain + ``` + +- [x] Acceptance claim: deterministic fake protocol R1, no exact provider card qualification. + +**Rollback checkpoint:** no network resources are created until a canonical profile is bound in +Task 18. + +### Wave B exit gate + +- [x] Run: + + ```bash + cd src && ./gradlew :application-core:check \ + :adapter:outbound:notification:check \ + verifyCleanArchitectureDependencies \ + --console=plain + ``` + +- [x] Verify the notification leaf has no project dependency on persistence, inbound-web or + httpclient. +- [x] Request catalog/template/provider-SPI review. + Final independent re-review: Blocker 0 / High 0. +- [x] Update the LLM Wiki branch-note with Wave B evidence and an explicit derived-document decision. + +--- + +## Wave C — PostgreSQL durable kernel and cryptography + +### Task 8: Add adapter-owned direct AEAD and versioned HMAC primitives + +**Owner leaf:** `adapter-outbound-persistence-jpa` +(`:adapter:outbound:persistence-jpa`) +**Depends on:** Task 4 + +**Files — create:** + +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/crypto/NotificationKeyMaterialProvider.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/crypto/NotificationKeyMaterialHandle.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/crypto/NotificationCiphertext.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/crypto/DirectAeadNotificationPayloadCrypto.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/crypto/NotificationHmacDigester.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/crypto/NotificationCryptoException.java` +- `src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/notification/crypto/NotificationPayloadCryptoTest.java` +- `src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/notification/crypto/NotificationHmacDigesterTest.java` + +- [ ] RED cases: AES-256-GCM profile/version, fresh 96-bit nonce per field, context-bound AAD, + ciphertext swapping failure, wrong key/revision failure, purpose-separated length-prefixed HMAC, + current + bounded retiring keys, no key/plaintext in `toString`/exception. +- [ ] Fix the AEAD contract to a 128-bit GCM tag and canonical length-prefixed AAD tuple: + `(schema/table, record ID, notification ID, optional delivery ID, optional attempt ID, + field purpose, provider binding revision, crypto profile version)`. This is the exact approved + design §24.2 hierarchy; key reference/version remain stored non-secret ciphertext metadata but + are not substitutes for the notification/delivery/attempt and binding coordinates. Any + tuple-field swap must fail authentication. +- [ ] Make key acquisition a versioned `AutoCloseable` mutable handle with close-time wipe and + use-after-close failure. Test success, exception and cancellation paths; never retain key bytes + in adapter-owned immutable records/Strings. +- [ ] Verify RED: + + ```bash + cd src && ./gradlew :adapter:outbound:persistence-jpa:test \ + --tests '*NotificationPayloadCryptoTest' \ + --tests '*NotificationHmacDigesterTest' \ + --console=plain + ``` + +- [ ] Use JCA primitives directly; zero temporary mutable key buffers where feasible and never place + material in settings/application records. +- [ ] Do not claim envelope encryption. Persist algorithm/key reference/version/nonce/AAD revision + with ciphertext. +- [ ] Verify GREEN with the same command. +- [ ] Acceptance claim: local cryptographic contract; external key management/rotation readiness is + not yet proven. + +**Rollback checkpoint:** once Task 10 persists ciphertext, old key/AAD/canonicalization revisions +cannot be removed by code rollback. + +### Task 9: Add the additive Notification journal migration and persistence model + +**Owner leaf:** `adapter-outbound-persistence-jpa` +(`:adapter:outbound:persistence-jpa`) +**Depends on:** Task 8 + +**Files — create:** + +- `src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/postgresql/V7__notification_delivery_journal.sql` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/entity/NotificationIntentEntity.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/entity/NotificationDeliveryLegEntity.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/entity/NotificationAttemptEntity.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/entity/NotificationReceiptEventEntity.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/entity/NotificationTechnicalSuppressionEntity.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/entity/NotificationAdmissionGateEntity.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/entity/NotificationRouteWriterFenceEntity.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/entity/NotificationWriterOperationEntity.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/entity/NotificationWriterOperationRouteEntity.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/entity/NotificationWriterTransportProofRegistryEntity.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/entity/NotificationRouteWriterPermitEntity.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/entity/NotificationWriterQuiescenceAttestationEntity.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/entity/NotificationWriterInventoryManifestEntity.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/entity/NotificationWriterDrainNodeInventoryEntity.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/entity/NotificationWriterQuiescenceManifestEntity.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/entity/NotificationWriterQuiescenceNodeEvidenceEntity.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/entity/NotificationWriterEvidenceTrustSnapshotEntity.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/entity/NotificationFreshInstallationProvenanceEntity.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/entity/NotificationWriterFinalizationDiscriminatorEntity.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/entity/NotificationHmacAliasEntity.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/repository/NotificationIntentJpaRepository.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/repository/NotificationDeliveryLegJpaRepository.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/repository/NotificationAttemptJpaRepository.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/repository/NotificationReceiptEventJpaRepository.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/repository/NotificationTechnicalSuppressionJpaRepository.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/repository/NotificationAdmissionGateJpaRepository.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/repository/NotificationRouteWriterFenceJpaRepository.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/repository/NotificationWriterOperationJpaRepository.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/repository/NotificationWriterOperationRouteJpaRepository.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/repository/NotificationWriterTransportProofRegistryJpaRepository.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/repository/NotificationRouteWriterPermitJpaRepository.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/repository/NotificationWriterQuiescenceAttestationJpaRepository.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/repository/NotificationWriterInventoryManifestJpaRepository.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/repository/NotificationWriterDrainNodeInventoryJpaRepository.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/repository/NotificationWriterQuiescenceManifestJpaRepository.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/repository/NotificationWriterQuiescenceNodeEvidenceJpaRepository.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/repository/NotificationWriterEvidenceTrustSnapshotJpaRepository.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/repository/NotificationFreshInstallationProvenanceJpaRepository.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/repository/NotificationWriterFinalizationDiscriminatorJpaRepository.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/repository/NotificationHmacAliasJpaRepository.java` +- `src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/notification/NotificationJournalMigrationTest.java` +- `src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/notification/NotificationWriterFinalizationDiscriminatorIntegrationTest.java` +- `src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/notification/NotificationDatabaseRoleIsolationIntegrationTest.java` +- `src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/notification/NotificationRetainedEvidenceNoSaveArchitectureTest.java` +- `docs/runbooks/notification-database-role-bootstrap.md` + +- [ ] Before editing, scan every Flyway location. If any `V7` exists, stop and reserve the next global + version instead of creating a collision. +- [ ] Add Testcontainers PostgreSQL dependencies to + `src/adapter/outbound/persistence-jpa/build.gradle` and update its lockfile only when the RED + test requires them. +- [ ] After adding only the test harness dependencies, regenerate and review the leaf lock before the + behavior RED: + + ```bash + cd src && ./gradlew :adapter:outbound:persistence-jpa:resolveAndLockAll \ + --write-locks --console=plain + cd src && ./gradlew :adapter:outbound:persistence-jpa:verifyDependencyLocks \ + --console=plain + ``` +- [ ] RED migration tests for all PK/FK/unique/partial-unique/check constraints and eligible/stale + lease/orphan lookup indexes from design §16.8, including one route writer-fence row and + globally unique append-only writer-operation header token/history, unique DB-assigned + `operation_sequence` plus attestation sequence from the same post-lock cutover sequence, + cross-table collision rejection, composite route-child + identity, route-set digest/action/expected/result ownership constraints including bounded + `TERMINALIZE_EXPIRED_PERMITS` requested batch bound and affected count/set digest. Store a + server-canonical header `request_input_digest`; header `route_set_digest` must equal the sorted + exact child set, and request digest must recompute from action-specific persisted header/child + input. Freeze domain-separated length-prefixed SHA-256 profiles + `writer-operation-route-set-v1`/`writer-operation-input-v1` and prove delimiter/order + permutations differ without including PII/secrets. Reject orphan header/child and empty child + sets. Continue with writer-permit token + uniqueness, route/generation/owner scope, + `ACTIVE/RELEASED/EXPIRED_PROVEN/TIMED_OUT_UNPROVEN` checks and blocking-permit lookup index. + Add the retained immutable transport-proof registry with composite + `(route_revision, transport_profile_revision)` PK, exactly one ACTIVE admission profile per + route, one canonical digest per route, initialization-operation-child composite FK and + UPDATE/DELETE rejection. Permit rows freeze + `(route_revision, transport_profile_revision, transport_proof_class, + transport_proof_evidence_revision)` and reference the exact registry row; operation children + and attestations carry the matching registry digest. Add cross-state CHECKs: + `EXPIRED_PROVEN => HARD_BOUND_PROVEN` and + `TIMED_OUT_UNPROVEN => QUIESCENCE_REQUIRED`; `ACTIVE|RELEASED` allow either class. The two + timeout states require a terminalization-operation FK and DB timestamp; ACTIVE/RELEASED forbid + that FK. Persist DB-time `wire_deadline_at` and enforce the reviewed + `acquired_at <= wire_deadline_at < expires_at` shape; the catalog evidence revision supplies + the stricter finalize-margin proof. + Include globally unique immutable quiescence-attestation token, exact + route/draining-generation/BEGIN-operation scope, transport-profile/blocking-permit/permit-holder/ + old-node count/set digests, signed evidence identity, zero/true fact constraints, + observed/expiry bounds and the COMPLETE operation-child attestation-token/set-digest FK. Add an + immutable per-BEGIN node inventory row set whose exact count/digest equals the BEGIN child and + whose node set covers every distinct route permit holder. Caller-written inventory digests are + not accepted. Add immutable per-attestation node evidence rows whose node keys exactly equal + that BEGIN inventory and which retain each deployment-generation tombstone plus legacy + credential-or-egress revocation digest; the canonical row-set digest must equal the + attestation summary. + Add immutable inventory-manifest and quiescence-manifest headers plus an immutable trust + snapshot. Each header retains canonical domain-separated payload bytes/profile, signature, + algorithm, issuer key ID, bounded canonical issuer public-key SPKI/digest, trust catalog + revision, historical-key allow/revocation snapshot and validity profile, + signed issued/expires facts, DB `verified_at`, profile-pinned `allowedClockSkew` and + `acceptanceMargin`, environment/DB/artifact identity, + consumer-inventory identity, provider-ledger identity/snapshot, exact route/drain generation, + and canonical child count/set digest. Header-to-child exact equality is structural and a + zero-node inventory still has exactly one authoritative header. Store no private key or raw + credential. SQL checks bytes/digests/FK/cardinality/state only; application write/startup Java + verifies Ed25519 from the stored payload/signature/SPKI, requires the historical key digest to + remain allowed/non-revoked in the current closed catalog, and enforces + `issuedAt - allowedClockSkew <= serverVerifiedAt <= expiresAt - acceptanceMargin`. + Add the retained singleton table + `notification_writer_finalization_discriminator`, whose eventual row has the exact closed states + `AWAITING_SIGNED_FRESH_PROVISIONING|FRESH_PROVISIONED|UPGRADE_VALIDATED`. Its structural + XOR is authoritative: `FRESH_PROVISIONED` has exactly one fresh provisioning token and no + validated-history digest; `UPGRADE_VALIDATED` has exactly one validated complete-history digest + and no fresh token; awaiting has neither. No state permits both, an unknown state, or a reverse + transition. The fresh token must equal the provenance initialization token and + `INITIALIZE_CANONICAL_FRESH` operation token; the upgrade digest must equal the + server-canonical complete retained snapshot digest. Freeze the exact columns + `singleton_key=NOTIFICATION_FINALIZATION`, `state`, `fresh_provenance_token`, + `validated_upgrade_history_digest`, `state_operation_token` and `row_version`. + Add the immutable table `notification_fresh_installation_provenance` empty. Freeze these + exact retained axes and names rather than a reduced “zero snapshot”: + + ```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 + ``` + + The external infrastructure issuer must first commit and read back the exact irreversible + enforcement revision, then terminate every pre-existing legacy DB session and provider + connection/flow, then observe the causally later post-enforcement zero/settled manifest, + including ledger entry/open/indeterminate counts 0, then seal-commit and read back the + permanent fence, and only then sign the DB-birth authorization. Enforce + `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`; every source evidence binds the exact + fence token and enforcement revision. Pre-enforcement zero snapshots, sign-before-seal, + cross-revision composition, a reopened cached session/flow and any shortened or renamed axis + fail with mutation 0. V7 creates only empty structure and V8 never manufactures either + authority. Migration/discriminator tests reject every discriminator XOR violation and + independently remove, alter or make nonzero/false each exact DB-birth/fence/enforcement/ + post-enforcement/ledger/session/connection axis above; no partial provenance row is valid. + Operation-child CHECKs encode the closed action/result matrix and forbid + `DRAINING/CANONICAL`, caller-selected target owners and any CANONICAL→LEGACY history. +- [ ] Establish exactly three database roles in the real-PostgreSQL fixture before GREEN and reject + every additional notification-scoped owner/member/grantee: + pre-provisioned `notification_migrator` owns Flyway history, notification schema, tables, + sequences, trigger/functions and is the only Flyway principal; `notification_runtime` is a + non-owner with only the exact runtime DML/SELECT grants needed by the active release and, in + the PRE artifact only, the exact transitional function `EXECUTE` grants; + `notification_provisioner` has no table/sequence privilege and is reserved for the V8-created + two-function fresh-provisioning protocol. The FINAL cleanup migration revokes every PRE + transitional `EXECUTE` grant from runtime/PUBLIC. Object ownership and default privileges must + make REVOKE effective; running Flyway as the runtime user is a RED failure. + Every `SECURITY DEFINER` function is owned by `notification_migrator`, schema-qualified, uses a + fixed safe `search_path`, contains no dynamic SQL, revokes PUBLIC EXECUTE and grants only the + exact role. Tests cover direct table DML, sequence use, function invocation, role switching, + search-path shadowing and forged input. +- [ ] Make role creation an explicit external DB-admin/IaC prerequisite, not a migration side + effect. `docs/runbooks/notification-database-role-bootstrap.md` freezes the exact principal + set, LOGIN/NOINHERIT expectations, external credential references, database/schema ownership + handoff and read-only verification queries without embedding credentials. A privileged + Testcontainers setup connection may emulate that prerequisite before Flyway, then must close; + Flyway starts only afterward as `notification_migrator`. V7/V8 contain no `CREATE ROLE`, + credential generation or membership grant: they validate `current_user`, exact + owner/member/grantee inventory and object/default privileges, create/alter owned schema + objects, and perform the reviewed grants/revokes. Production evidence retains the external + bootstrap revision/digest, all three `current_user` probes and the post-migration privilege + snapshot. Missing bootstrap evidence or a fourth notification-scoped principal stops rollout + before Flyway. +- [ ] Verify RED: + + ```bash + cd src && ./gradlew :adapter:outbound:persistence-jpa:test \ + --tests '*NotificationJournalMigrationTest' \ + --tests '*NotificationWriterFinalizationDiscriminatorIntegrationTest' \ + --tests '*NotificationDatabaseRoleIsolationIntegrationTest' \ + --tests '*NotificationRetainedEvidenceNoSaveArchitectureTest' \ + --console=plain + ``` + + Expected failure: `V7` and journal schema do not exist. + +- [ ] Implement additive tables only. Do not rewrite existing outbox/idempotency tables and do not + backfill historical events. +- [ ] Treat signed headers/trust snapshots, drain inventory, per-node quiescence evidence, fresh + provenance and writer-finalization discriminator entities/repositories as retained + adapter-internal audit projections. Only the + BEGIN adapter may insert a verified inventory header/children, only the attestation adapter may + insert a verified quiescence header/per-node evidence in its root transaction, and only the + two-function final provisioning protocol may insert provenance and CAS + `AWAITING_SIGNED_FRESH_PROVISIONING -> FRESH_PROVISIONED`; V8 alone may establish + `AWAITING_SIGNED_FRESH_PROVISIONING` or `UPGRADE_VALIDATED`. From Task 9 onward, provenance, + discriminator, signed-header/trust-snapshot and other never-Java-written retained repositories + extend only Spring Data's marker `Repository` and expose bounded named reads; they never + inherit `CrudRepository`/`JpaRepository` or declare `save`, `saveAll`, `delete` or `flush`. + `NotificationRetainedEvidenceNoSaveArchitectureTest` enforces that initial surface. Retain + every projection for FINAL startup/evidence reads. +- [ ] Store provider leg separately from logical recipient; enforce one open attempt per delivery + and one active leg per fallback strategy group in PostgreSQL. +- [ ] Keep raw recipient/parameter/provider payload/error out of plaintext columns and indexes. +- [ ] Verify GREEN with the same command. +- [ ] Acceptance claim: schema invariants on real PostgreSQL, not yet append/claim behavior. + +**Rollback checkpoint:** deploy schema before code. Do not use destructive down migration; old code +must tolerate additive tables. If later ciphertext/state is incompatible with old code, rollback is +forward-fix. + +### Task 10: Implement same-transaction append, dedupe aliases and frozen plan storage + +**Owner leaf:** `adapter-outbound-persistence-jpa` +(`:adapter:outbound:persistence-jpa`) +**Depends on:** Task 9 + +**Files — create:** + +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/NotificationStoreAdapter.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/NotificationPersistenceMapper.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/NotificationPersistenceExceptionTranslator.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/NotificationStoreCapabilityDescriptorSource.java` +- `src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/notification/NotificationIntentAppendIntegrationTest.java` +- `src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/notification/NotificationStoreCapabilityDescriptorTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/notification/NotificationSameTransactionAppendContractTest.java` + +- [ ] RED cases on real PostgreSQL: + business write + append commit together; either failure rolls both back; append never uses + `REQUIRES_NEW`; same idempotency digest/fingerprint returns existing intent; different + fingerprint conflicts; concurrent old/current HMAC alias writers resolve to one semantic owner; + frozen legs/template/route/crypto revisions are immutable. +- [ ] Verify RED: + + ```bash + cd src && ./gradlew :adapter:outbound:persistence-jpa:test \ + --tests '*NotificationIntentAppendIntegrationTest' \ + --tests '*NotificationStoreCapabilityDescriptorTest' \ + --console=plain + cd src && ./gradlew :app-bootstrap:test \ + --tests '*NotificationSameTransactionAppendContractTest' --console=plain + ``` + +- [ ] Encrypt recipient/parameters before persistence and insert all current + retiring HMAC aliases + in the same caller transaction. +- [ ] Seed representative recipient, parameter, provider payload, error and key-marker values, then + scan every notification table/index-visible text representation and captured SQL/log output. + The markers may appear only after an explicit decrypt operation in test memory; database + plaintext evidence must be zero. +- [ ] Map unique conflicts to typed duplicate/mismatch results; never catch-and-ignore arbitrary + constraint errors. +- [ ] Derive the application-owned store descriptor from the actual migration/schema, crypto + profile/key generations and live/retained revision inventory. Expected bootstrap config is not + an input to this source. +- [ ] Verify GREEN with the same commands. +- [ ] Acceptance claim: same-DB durable append is locally verified only for the tested PostgreSQL + topology; no provider card R2 is implied. + +**Rollback checkpoint:** leave canonical binding disabled. Schema/data stay in place if code is +rolled forward; never resend persisted rows through legacy code. + +### Task 11: Implement PostgreSQL claim, wire authorization, terminal-once result and admission gate + +**Owner leaf:** `adapter-outbound-persistence-jpa` +(`:adapter:outbound:persistence-jpa`) +**Depends on:** Task 10 + +**Files — create:** + +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/PostgreSqlNotificationClaimRepository.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/NotificationAdmissionGateStore.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/NotificationReceiptStoreAdapter.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/NotificationTechnicalSuppressionStoreAdapter.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/PostgreSqlNotificationCanonicalWriterFenceAdapter.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/PostgreSqlNotificationWriterCutoverAdapter.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/PostgreSqlNotificationWriterQuiescenceAttestationAdapter.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/NotificationOperationsSnapshotAdapter.java` +- `src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/notification/PostgreSqlNotificationClaimIntegrationTest.java` +- `src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/notification/NotificationAdmissionGateIntegrationTest.java` +- `src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/notification/NotificationReceiptStoreIntegrationTest.java` +- `src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/notification/NotificationCanonicalWriterFenceIntegrationTest.java` +- `src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/notification/NotificationWriterCutoverIntegrationTest.java` +- `src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/notification/NotificationWriterQuiescenceAttestationIntegrationTest.java` +- `src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/notification/NotificationWriterIrreversibleFenceIntegrationTest.java` +- `src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/notification/NotificationOperationsSnapshotIntegrationTest.java` + +- [ ] RED concurrency cases with at least two transaction contexts: + `SKIP LOCKED` single owner; bounded ordering/aging; exact token/version predicate; stale owner + cannot overwrite; gate generations locked in canonical order; only active generations may + commit `WIRE_AUTHORIZED`; one terminal exact result per execution token; late exact result may + fill its slot without stale projection overwrite. +- [ ] RED park/restart cases: + provider/account fault closes shared gate and parks backlog; another node sees it; restart + preserves it; resume increments generation and rechecks expiry/cancel/suppression; no hot loop + and no initial fallback. +- [ ] RED writer-fence/permit cases: + atomic route owner/generation/state CAS; all nodes observe one owner; stale legacy and canonical + generations both fail closed. Adapter/DB tests enumerate the closed transition matrix: + BEGIN only `ACTIVE/LEGACY@g -> DRAINING/LEGACY@g`, terminalize only unchanged + `DRAINING/LEGACY@g`, COMPLETE only + `DRAINING/LEGACY@g -> ACTIVE/CANONICAL@g+1`, ABORT only + `DRAINING/LEGACY@g -> ACTIVE/LEGACY@g+1`; every action from CANONICAL, target-owner input and + `DRAINING/CANONICAL` fail with mutation 0. Unique bounded permit acquire/release/expiry uses DB time and + exact token/version; `BEGIN_DRAIN` and concurrent acquire serialize so no new permit commits + after DRAINING; `COMPLETE_SWITCH` and the operations snapshot require ACTIVE legacy permit 0 + for the exact route across every historical fence generation, not merely the current + generation; `ABORT_DRAIN` emits a new LEGACY generation without hiding an older-generation + live permit. Prove `g` permit active -> abort to `g+1` -> drain again -> complete remains + blocked until the `g` permit is terminal. A canonical caller transaction holds a fence share + lock through its append commit, so concurrent `BEGIN_DRAIN` cannot commit first and reopen + legacy while a stale canonical append later commits; owner switch does not rewrite already + accepted canonical rows. +- [ ] RED timeout/attestation concurrency cases: + a proven hard-bound profile may CAS past-deadline ACTIVE to `EXPIRED_PROVEN`; an unproven + profile may only become `TIMED_OUT_UNPROVEN`. For current R0, COMPLETE fails even with permit + count 0 until an authenticated attestation committed after BEGIN_DRAIN is supplied. Permit + acquire locks the persisted ACTIVE registry row and freezes its proof class/evidence revision; + timeout, attestation and COMPLETE reject a permit whose frozen tuple differs or whose profile + is unknown. Mixed-profile fixtures prove `QUIESCENCE_REQUIRED + EXPIRED_PROVEN` and + `HARD_BOUND_PROVEN + TIMED_OUT_UNPROVEN` are rejected by both DB and runtime. Recording + BEGIN first verifies an independently signed exact environment/DB/route/PRE-artifact complete + node inventory and freezes its rows/count/digest atomically with the fence CAS. Missing/ + duplicate/extra node, unknown issuer, wrong environment/DB/artifact/profile and any historical + permit holder omitted by the manifest fail with mutation 0. Attestation recording + first requires ACTIVE 0, then locks/snapshots every-generation/multi-profile + `(permit token,generation,transport profile,state,rowVersion)` TIMED_OUT_UNPROVEN tuple and + distinct holder set and stores the bounded canonical set/count/digests plus the persisted route + registry and exact frozen BEGIN inventory digest. Its independently signed quiescence manifest + must list that exact node set, bind per-node retired/quiesced facts plus irreversible + deployment-generation/legacy-credential/egress fences, consumer inventory/count 0 + and provider-call ledger identity/open-count 0; caller digests are ignored/rejected. COMPLETE + requires the persisted per-node evidence key set and tombstone/revocation row-set digest to + exactly match the signed manifest, attestation summary and BEGIN inventory. Missing/extra/ + duplicate node evidence or a digest-only attestation fails. The retained BEGIN inventory + header, quiescence/attestation header and evidence trust snapshot persist the canonical signed + payload bytes, signature, issuer/key identity, bounded issuer public-key SPKI/digest, verified + trust/historical-key snapshot, issued/expires/verified times and pinned + skew/acceptance-margin profile, environment/DB/PRE-artifact identity, exact node/consumer + inventory identity and provider-ledger identity/snapshot. An exact zero-node inventory is + still an issuer-authorized + signed statement, never an unsigned empty shortcut. Java verifies Ed25519 and exact identity + at evidence admission and again at startup; database constraints enforce only immutable + shape, FK, count and digest structure. Expiry rejects new evidence admission but does not make + an already committed irreversible tombstone, credential revocation or egress revocation + reversible. + COMPLETE + locks fence, registry, BEGIN inventory, attestation and permit rows in canonical order, + recomputes exact equality, appends its attestation token/digest and fence CAS in one root + transaction. Cover missing/stale/wrong-route/wrong-drain-generation/wrong-profile-set, + unknown, extra or omitted active/retiring profile, catalog/persisted-registry drift, tampered + proof class/evidence revision/registry digest, partial multi-profile/node/holder coverage, + unsigned/wrong-key/wrong-identity evidence, nonzero facts, changed set after release/timeout, + token replay mismatch, concurrent attestation/permit terminal transition and commit + failure/result loss. + No TTL-only path may reach CANONICAL. +- [ ] RED the irreversible COMPLETE proof and stale-node resume race on the exact PostgreSQL 16 + profile: + COMPLETE requires the exact frozen BEGIN inventory, signed retained quiescence evidence, + irreversible deployment-generation tombstones, legacy credential revocations and egress + revocations, ACTIVE permit 0 and provider-ledger open-count 0 in one canonically locked + snapshot. Pause an old bridge process after it has cached its legacy credential, provider + client and connection but before provider I/O. Commit the irreversible evidence and + `COMPLETE_SWITCH`, then resume the stale process and prove provider I/O remains 0 because the + old deployment generation, credential and egress path are all unusable. Repeat after + application/JDBC connection recreation and process restart. Missing/reversible facts, + a changed inventory/ledger identity, nonzero permit/ledger state, unsigned or non-reverifiable + retained headers, and any stale node omitted from BEGIN are `NOT_QUALIFIED` and prohibit + COMPLETE/21C. Commit-success/result-loss replay returns the stored durable result without + weakening or refreshing the evidence. +- [ ] RED hard-bound pause/resume cases before any profile may use `HARD_BOUND_PROVEN`: + acquire root commit freezes `wire_deadline_at + finalize_margin <= expires_at`; commit before + provider I/O; the wrapper/client cannot begin network I/O after that absolute deadline and + cancellation/connection close completes by it. Pause immediately after acquire commit, let + wire deadline and permit expiry pass, terminalize/COMPLETE, then resume: provider call count is + 0. Resume just before the wire deadline: any started call ends by the same deadline. Include + commit-ack delay, scheduler pause and clock-skew/rollback bounds. Without all evidence the + catalog must classify the profile `QUIESCENCE_REQUIRED`. +- [ ] RED terminalizer execution cases independently of durable workers: + the read-only snapshot never mutates a permit; exact DRAINING fence + DB-time expiry + + persisted registry are required; a bounded batch scans all historical generations and CASes + each exact token/rowVersion once; concurrent release/terminalize and two terminalizers have one + terminal winner per permit. Operation header/route affected set digest and permit + terminalization FK commit all-or-none, commit-before-2xx is observable at Task 17, and + commit-success/result-loss same-token replay returns the stored affected set even when current + selection is empty. Different tokens consume successive bounded batches deterministically. + Idempotency lookup/recomputed `request_input_digest` precedes selection; same token with batch + bound `10` then `100`, changed actor/reason/route/drain generation or action conflicts with + mutation 0. + PURE_DISABLED has no terminalizer bean/thread; PRE bridge + and CUTOVER_WAIT compose the proxied operation without any scheduler or provider I/O. +- [ ] RED initialization/audit-journal cases: + two distinct concurrent batch initialization tokens have exactly one winner; a + same-token/same-route-set/input replay returns the stored full result; a two-route fixture + commits all fences/header/children and the full immutable transport-proof registry snapshot or + none; partial/extra/missing route/profile set and absent fences plus a nonempty row in any + control/data-plane journal, including an orphan registry row, reject without mutation; + initialization commit failure rolls back every fence, operation header/children and registry + row; + commit-success/result-loss is recoverable from the immutable operation journal; token + uniqueness is global and exact route-set/action/input mismatch fails closed. For initialization and + `BEGIN_DRAIN|TERMINALIZE_EXPIRED_PERMITS|COMPLETE_SWITCH|ABORT_DRAIN`, append the operation + header/route results and applicable fence/permit + mutations atomically, update each changed fence's `last_operation_token` to that header, + retain the full history after later operations, and replay an old token after newer operations. + Verify the fence pointer is only a latest-result integrity pointer and deleting/overwriting an + older operation or updating/deleting a registry row is impossible. Replaying initialization + returns the stored registry digest and cannot refresh it from a changed catalog. + The adapter assigns `operation_sequence`, and attestation its sequence from the same DB + sequence, only after the batch-init/global lock or exact route fence lock; committed route + history has one causal total order despite concurrent actions, while rollback/global gaps are + harmless. Populate operation/terminalization/attestation times only with post-lock + `clock_timestamp()`. Start a terminalizer transaction before BEGIN, block it on the fence, then + let BEGIN commit: its later sequence and timestamp must both follow BEGIN; a + `CURRENT_TIMESTAMP`/transaction-start implementation is a RED failure. Recompute + `route_set_digest`/`request_input_digest` from + persisted header/children on replay; orphan header/child, empty child set and digest mismatch + fail closed. + A fake port test is not accepted as evidence for empty-journal checking, concurrency or + physical commit semantics. +- [ ] RED receipt cases: + outer/semantic dedupe, orphan-before-accepted, later attach, conflict quarantine, and atomic + persistence of an explicit suppression mutation or explicit no-op supplied by application. + Persistence does not classify bounce/complaint policy. +- [ ] RED the bounded fresh writer snapshot needed by PRE activation: + exact route key set plus owner/state/generation and all-generation blocking permit aggregates + plus persisted transport-proof registry digest/profile aggregates are read through + `NotificationOperationsSnapshotAdapter`; stale/missing/partial rows are explicit results, not + silently cached success. This adapter is implemented in Task 11 so Task + 17 can compose only `NotificationOperationsSnapshotUseCase`, never a repository or outbound + port. +- [ ] Verify RED: + + ```bash + cd src && ./gradlew :adapter:outbound:persistence-jpa:test \ + --tests '*PostgreSqlNotificationClaimIntegrationTest' \ + --tests '*NotificationAdmissionGateIntegrationTest' \ + --tests '*NotificationReceiptStoreIntegrationTest' \ + --tests '*NotificationCanonicalWriterFenceIntegrationTest' \ + --tests '*NotificationWriterCutoverIntegrationTest' \ + --tests '*NotificationWriterQuiescenceAttestationIntegrationTest' \ + --tests '*NotificationWriterIrreversibleFenceIntegrationTest' \ + --tests '*NotificationOperationsSnapshotIntegrationTest' \ + --console=plain + ``` + +- [ ] Implement vendor SQL only in `.postgresql`; keep JPA entities/repositories adapter-local. +- [ ] Use DB time consistently for claim/lease comparisons and bounded batch sizes. +- [ ] Keep render/provider calls out of every repository transaction. +- [ ] Verify GREEN with the same command, then run + `cd src && ./gradlew :adapter:outbound:persistence-jpa:check --console=plain`. +- [ ] Acceptance claim: durable local primitives have real-PostgreSQL evidence; dispatcher fault + matrix is Task 12 and no provider card R2 is implied. + +**Rollback checkpoint:** pause new admission/worker first. Preserve all active attempt/gate revisions +and inspect accepted/indeterminate inventory before any code rollback. + +### Task 12: Prove the deterministic dispatcher/reaper fault matrix across application and PostgreSQL + +**Owner:** `app-bootstrap` integration harness (`:app-bootstrap`) +**Depends on:** Tasks 4, 7, 11 + +**Files — create:** + +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/notification/NotificationDispatcherPostgresContractTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/notification/NotificationDispatcherCrashMatrixTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/notification/NotificationDispatcherContainerSupport.java` + +- [ ] Build a deterministic fake provider with explicit barriers before/after + `WIRE_AUTHORIZED`, wire call, response and finalize; no actual network. +- [ ] RED matrix: + + | crash/fault point | expected restart result | + | --- | --- | + | before claim commit | eligible, no attempt | + | after claim before reserve | lease requeue, no provider call | + | after reserve before `WIRE_AUTHORIZED` | safe requeue, no provider call | + | `WIRE_AUTHORIZED` transaction fails to commit | authorization absent, provider call 0, safe requeue | + | authorization commit succeeds but caller loses commit result | current worker calls provider 0; only reaper acts after deadline/grace | + | after `WIRE_AUTHORIZED` before call | wait through deadline/grace, then reconcile or terminal indeterminate | + | after possible write before response | no blind retry/fallback | + | accepted response before finalize | late exact result or reconcile; duplicate risk explicit | + | stale worker finalize after new owner | exact fact may append once; projection CAS rejected | + | binding park racing authorization | pre-park authorization completes boundedly; later authorizations blocked | + +- [ ] Verify RED: + + ```bash + cd src && ./gradlew :app-bootstrap:test \ + --tests '*NotificationDispatcherPostgresContractTest' \ + --tests '*NotificationDispatcherCrashMatrixTest' \ + --console=plain + ``` + +- [ ] Wire real `TransactionPort`, store and application dispatcher manually in the test; do not + introduce production scheduler/composition yet. +- [ ] For lost commit-result ambiguity, prove the reaper reads the committed authorization only after + deadline/grace and chooses provider reconciliation when the exact card supports it, otherwise + terminal `INDETERMINATE`; it never treats the occurrence as definitely-not-sent. +- [ ] Verify provider invocation occurs outside actual transaction. +- [ ] Verify GREEN with the same command. +- [ ] Acceptance claim: provider-neutral durable protocol is locally verified; actual process-kill + evidence is Task 20 and Slack/SES cards remain unqualified. + +**Rollback checkpoint:** this is test-only integration. Production remains dark. + +### Wave C exit gate + +- [ ] Run: + + ```bash + cd src && ./gradlew :app-bootstrap:test \ + --tests '*Notification*' \ + --console=plain + cd src && ./gradlew :application-core:check \ + :adapter:outbound:persistence-jpa:check \ + --console=plain + cd src && ./gradlew verifyCleanArchitectureDependencies \ + verifyDependencyLocks --console=plain + ``` + +- [ ] Capture PostgreSQL version, container image digest, test seed and fault matrix results. +- [ ] Request durability/concurrency/crypto review before provider work. +- [ ] Update the LLM Wiki branch-note with Wave C evidence and an explicit derived-document decision. + +--- + +## Wave D — Slack and SES send providers + +### Task 13: Implement Slack Web API `chat.postMessage` protocol + +**Owner leaf:** `adapter-outbound-notification` (`:adapter:outbound:notification`) +**Depends on:** Tasks 5–7 + +**Files — create:** + +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/slack/webapi/SlackWebApiRuntimeProfile.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/slack/webapi/SlackWebApiCredentialHandle.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/slack/webapi/SlackWebApiAttemptClient.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/slack/webapi/SlackWebApiOutcomeMapper.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/slack/webapi/SlackWebApiCapabilityCards.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/slack/webapi/SlackWebApiReadinessProbe.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/slack/webapi/SlackWebApiRateAdmission.java` +- `src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/slack/webapi/SlackWebApiProtocolTest.java` +- `src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/slack/webapi/SlackWebApiReadinessTest.java` +- `src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/slack/webapi/SlackWebApiRateAdmissionTest.java` + +**Files — modify:** + +- `src/adapter/outbound/notification/build.gradle` +- `src/adapter/outbound/notification/gradle.lockfile` + +- [ ] First run a bounded dependency spike against the official Slack Java SDK. Prove endpoint, + TLS/proxy/timeouts, connection lifecycle and retry count are controllable. If not, stop and + amend the plan before adding a provider-local HTTP engine. +- [ ] RED loopback protocol cases: + exact `chat.postMessage` method/payload; one channel target; one physical request per + authorization; bearer secret redaction; success `(channel, ts)` -> accepted/conversation + reference; explicit `ok=false`; 429/`Retry-After`; auth/scope/account rejection -> park; + timeout/connection loss/undecodable success -> indeterminate; payload/Block Kit bounds. +- [ ] Verify RED: + + ```bash + cd src && ./gradlew :adapter:outbound:notification:test \ + --tests '*SlackWebApiProtocolTest' \ + --tests '*SlackWebApiReadinessTest' \ + --tests '*SlackWebApiRateAdmissionTest' \ + --console=plain + ``` + +- [ ] Add the minimum official SDK dependency, disable SDK retry, update the affected lockfile with + the repository lock workflow, and verify actual request count in every test. +- [ ] Regenerate and verify the exact notification leaf lock after the SDK declaration: + + ```bash + cd src && ./gradlew :adapter:outbound:notification:resolveAndLockAll \ + --write-locks --console=plain + cd src && ./gradlew :adapter:outbound:notification:verifyDependencyLocks \ + --console=plain + ``` + +- [ ] Implement bounded provider-local admission and a safe control-plane readiness probe using + Slack `auth.test`; verify workspace/token identity, scopes/card requirements and rate state + without logging token/channel/message content. +- [ ] Key Slack admission by exact `(workspaceBindingRevision, channelIdDigest, chat.postMessage)` + scope; cap `Retry-After` by the attempt deadline/retry horizon and prove concurrent token-bucket + bounds with an injected monotonic clock. +- [ ] Acquire/close `SlackWebApiCredentialHandle` per protocol/probe call and test wipe on success, + mapped exception, timeout and cancellation. +- [ ] Implement both descriptor cards: + `slack-web-api-inline-single-local-v1` and + `slack-web-api-durable-single-local-v1`; do not add webhook semantics to either. +- [ ] Document that response-loss without `ts` has no safe blind retry/native idempotency. +- [ ] Verify GREEN with the same command, then run + `cd src && ./gradlew :adapter:outbound:notification:check + :adapter:outbound:notification:verifyDependencyLocks --console=plain`. +- [ ] Acceptance claim: Slack local protocol R1; sandbox Task 19 is required for card R2. + +**Rollback checkpoint:** remove canonical Slack binding first so client/resources become zero; do not +send accepted/indeterminate durable intents through webhook fallback. + +### Task 14: Prove Slack inline and durable transaction semantics + +**Owner:** `app-bootstrap` integration harness (`:app-bootstrap`) +**Depends on:** Tasks 2, 12, 13 + +**Files — create:** + +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/notification/SlackInlineTransactionContractTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/notification/SlackDurableDispatchContractTest.java` + +- [ ] RED inline cases: + provider call only after root commit; root rollback/commit failure/ambient transaction rejection + -> call 0; returned `InlineCompleted` keeps target outcome; no durable retry claim. +- [ ] RED durable cases: + append joins business transaction; provider outside transaction; response loss becomes terminal + unknown/reconcile unsupported; no blind retry or fallback. +- [ ] Verify RED: + + ```bash + cd src && ./gradlew :app-bootstrap:test \ + --tests '*SlackInlineTransactionContractTest' \ + --tests '*SlackDurableDispatchContractTest' \ + --console=plain + ``` + +- [ ] Use the loopback Slack endpoint/client profile, not live Slack. +- [ ] Verify GREEN with the same command. +- [ ] Acceptance claim: mode-specific local protocol/config evidence; not sandbox R2. + +**Rollback checkpoint:** both modes remain unbound by default. No migration data is resent. + +### Task 15: Implement Amazon SES v2 one-recipient submission protocol + +**Owner leaf:** `adapter-outbound-notification` (`:adapter:outbound:notification`) +**Depends on:** Tasks 5–8 and Task 13, because both provider tasks modify the same +`build.gradle`/`gradle.lockfile` and must serialize those edits + +**Files — create:** + +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/email/ses/SesV2RuntimeProfile.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/email/ses/SesCredentialSourceProfile.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/email/ses/SesV2AttemptClient.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/email/ses/SesV2OutcomeMapper.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/email/ses/SesV2CapabilityCards.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/email/ses/SesV2ReadinessProbe.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/email/ses/SesV2RateAdmission.java` +- `src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/email/ses/SesV2ProtocolTest.java` +- `src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/email/ses/SesV2ReadinessTest.java` +- `src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/email/ses/SesV2RateAdmissionTest.java` + +**Files — modify:** + +- `src/adapter/outbound/notification/build.gradle` +- `src/adapter/outbound/notification/gradle.lockfile` + +- [ ] RED loopback protocol cases: + SES v2 `SendEmail`; exactly one recipient; local-rendered subject/text/HTML; reviewed + from-identity/configuration-set; fixed EmailTag `ca_attempt_v1` with opaque pre-send correlation; + SDK max physical attempt 1; `MessageId` -> provider accepted only; throttle/auth/account mapping; + timeout/connection loss -> indeterminate; no PII/credential in telemetry. +- [ ] Verify RED: + + ```bash + cd src && ./gradlew :adapter:outbound:notification:test \ + --tests '*SesV2ProtocolTest' \ + --tests '*SesV2ReadinessTest' \ + --tests '*SesV2RateAdmissionTest' \ + --console=plain + ``` + +- [ ] Add only required AWS SDK v2 SES/client modules under the existing BOM version. Disable SDK + retry for mutation sends and verify request count. +- [ ] Regenerate and verify the exact notification leaf lock after the AWS SDK declaration: + + ```bash + cd src && ./gradlew :adapter:outbound:notification:resolveAndLockAll \ + --write-locks --console=plain + cd src && ./gradlew :adapter:outbound:notification:verifyDependencyLocks \ + --console=plain + ``` + +- [ ] Implement bounded quota/send-rate admission and a safe SES control-plane probe for exact + account/region/sandbox/sending-enabled/quota/from-identity/configuration-set facts. Secret + credential values remain outside the readiness snapshot. +- [ ] Key SES admission by exact account/region/binding revision, intersect local token-bucket limits + with current provider quota/send-rate and cap waits by the attempt deadline. The resolved + credential source/generation is a readiness fact; only credential secret values are excluded. +- [ ] Implement only `aws-ses-v2-durable-single-local-sns-v1`; no multi-recipient, stored-template, + SMTP or Gmail aliases. +- [ ] Treat the EmailTag as correlation, never provider idempotency. +- [ ] Verify GREEN with the same command, then run + `cd src && ./gradlew :adapter:outbound:notification:check + :adapter:outbound:notification:verifyDependencyLocks --console=plain`. +- [ ] Acceptance claim: SES local submission protocol R1; SNS and sandbox evidence still required. + +**Rollback checkpoint:** remove binding before client/SDK rollback. Preserve correlation/message +references and never replay indeterminate sends automatically. + +### Wave D exit gate + +- [ ] Run: + + ```bash + cd src && ./gradlew :app-bootstrap:test \ + --tests '*Slack*' --tests '*Ses*' \ + --console=plain + cd src && ./gradlew :adapter:outbound:notification:check \ + --console=plain + cd src && ./gradlew :adapter:outbound:notification:verifyDependencyLocks \ + verifyCleanArchitectureDependencies --console=plain + ``` + +- [ ] Record actual loopback request counts and dependency/CVE/license review. +- [ ] Request provider protocol review before ingress/composition. +- [ ] Update the LLM Wiki branch-note with Wave D evidence and an explicit derived-document decision. + +--- + +## Wave E — SNS receipt, canonical composition and operations + +### Task 16: Implement verified SNS HTTPS ingress and normalized SES receipt mapping + +**Owner leaf:** `adapter-inbound-web` (`:adapter:inbound:web`), with the physical-commit integration +owned by `app-bootstrap` (`:app-bootstrap`) +**Depends on:** Tasks 4, 11, 15 + +**Files — create:** + +- `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/NotificationReceiptIngressProfile.java` +- `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/NotificationReceiptIngressDescriptor.java` +- `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/NotificationReceiptIngressCapabilityDescriptorSource.java` +- `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/SnsNotificationController.java` +- `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/SnsMessageEnvelope.java` +- `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/SnsSignatureV2Verifier.java` +- `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/SnsSigningCertificateLoader.java` +- `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/SesReceiptNormalizer.java` +- `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/VerifiedNotificationReceiptOperation.java` +- `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/NotificationReceiptIngressException.java` +- `src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/notification/SnsNotificationControllerTest.java` +- `src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/notification/SnsSignatureV2VerifierTest.java` +- `src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/notification/SesReceiptNormalizerTest.java` +- `src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/notification/NotificationReceiptIngressCapabilityDescriptorTest.java` +- `src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/notification/VerifiedNotificationReceiptOperationTest.java` + +**Files — modify:** + +- `src/.env` +- `docs/security/public-paths-snapshot.txt` + +- [ ] Fix the exact endpoint to `/webhooks/notifications/aws-ses-v1`; append it to the comma-separated + `SECURITY_PUBLIC_PATHS` SSOT in `src/.env`, then regenerate the reviewed snapshot with: + + ```bash + cd src && ./gradlew verifyPublicPathSnapshot \ + -PapprovePublicPathChange --console=plain + ``` + + `SecurityConfig` already reads `SecuritySettings.publicPaths`; do not hard-code a matcher in + Java. The endpoint bypasses JWT only and still requires SNS verification before + normalization/use-case invocation. +- [ ] RED cases: + bounded POST/content-type/body/depth; SignatureVersion 2 canonical string; signature/key + rotation; HTTPS allowlisted SNS cert host/path; DNS/IP/redirect/chain/expiry/SSRF rejection; + exact TopicArn account/region/name. Freeze the exact card's + `maxCallbackAge = SNS HTTP retry horizon + DLQ retention/redrive horizon + clock skew`. + `ses-notification-v1` fixes `1h + 7d + 5m = 7d1h5m`, outer tombstone `8d` and inner semantic + tombstone `30d`; the ingestion safety margin is `1h`. Arithmetic overflow or topology drift + fails composition. + A signed delayed retry just inside the bound is accepted/deduped, one just outside is rejected + 4xx with receipt/quarantine DB mutation 0. Retention refuses tombstone expiry while the window + is open; a forced-corruption fixture with a missing tombstone but retained semantic receipt + fact still cannot reapply because of the store unique invariant. An outside-window replay + remains age-rejected even after a deliberately expired tombstone. + Outer/semantic tombstones outlive max age plus safety margin; supported + event mapping and unknown schema/event quarantine remain covered. + A signed timestamp beyond the allowed future skew is also rejected without mutation. +- [ ] RED controller cases: + verified receipt transaction commit before 2xx; transient store/commit failure returns `503`; + bounded ingress overload returns `429` only before receipt admission and before any commit; + duplicate returns idempotent 2xx; authenticated but unsupported schema/event is durably + quarantined and then ACKed 2xx; invalid signature/topology is rejected 4xx without persistence; + subscription/unsubscribe confirmation never fetches arbitrary URL; raw body/header/DTO never + reaches application/log. Never return success for a receipt whose commit outcome is unknown. +- [ ] Verify RED: + + ```bash + cd src && ./gradlew :adapter:inbound:web:test \ + --tests '*SnsNotificationControllerTest' \ + --tests '*SnsSignatureV2VerifierTest' \ + --tests '*SesReceiptNormalizerTest' \ + --tests '*NotificationReceiptIngressCapabilityDescriptorTest' \ + --tests '*VerifiedNotificationReceiptOperationTest' \ + --console=plain + ``` + +- [ ] Implement cert retrieval with a bounded inbound-adapter-local JDK client and strict allowlist; + do not add a project edge to outbound httpclient/notification. +- [ ] Normalize only after authenticity/topology validation to + `NormalizedNotificationReceiptCommand`. +- [ ] Derive the application-owned ingress capability descriptor from the actual verifier, endpoint, + TopicArn/signature profile, ACK/DLQ contract, retry/DLQ/redrive horizons, max callback age and + tombstone retention. Do not rebuild “actual” ingress facts from expected bootstrap settings. +- [ ] Define those bounded durations in the checked-in `NotificationReceiptIngressProfile`, not an + unrestricted request/env override. Reject invalid arithmetic, overflow, tombstone + `<= maxCallbackAge + safety margin` and a topology descriptor that cannot prove the exact + horizons. An original envelope older than max age is never directly replayed. A separate + authenticated/approved operator procedure republishes the inner SES event through the exact + TopicArn to create a new signed outer envelope while preserving the inner semantic fingerprint; + it is allowed only while semantic dedupe retention remains. +- [ ] Compose `ApplyNotificationReceiptUseCase` manually behind the verified inbound controller so + provider signature authentication is not confused with JWT role authentication. The use case + still declares the required application capability/permission contract and performs its write + through `TransactionPort.inRootWrite`. +- [ ] The auto-scanned controller injects only inbound-local + `VerifiedNotificationReceiptOperation`, whose method accepts/returns application command/result + types. Task 17 supplies a non-advised lambda/implementation bean that captures a distinct + manually constructed `ApplyNotificationReceiptUseCase`; the use case itself is not a Spring + bean. Controller tests prove raw/unverified requests cannot reach this seam, and composition + tests prove it is not a method-security target and cannot be confused with the operator path. +- [ ] Add + `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/notification/SesSnsReceiptCommitAckContractTest.java` + using real PostgreSQL + MockMvc/test server. Prove 2xx is emitted only after physical commit; + commit failure/rollback invokes no success ACK, while a committed duplicate returns idempotent + 2xx. +- [ ] Run the cross-leaf RED/GREEN integration with: + + ```bash + cd src && ./gradlew :app-bootstrap:test \ + --tests '*SesSnsReceiptCommitAckContractTest' --console=plain + ``` +- [ ] Verify GREEN with the same command, then run + `cd src && ./gradlew :adapter:inbound:web:check --console=plain` and: + + ```bash + cd src && ./gradlew verifyPublicPathSnapshot \ + verifyCleanArchitectureDependencies --console=plain + ``` + +- [ ] Acceptance claim: offline verified ingress protocol; actual AWS SNS callback remains Task 19. + +**Rollback checkpoint:** before endpoint removal, pause event destination and inventory SNS retries, +DLQ and orphan receipts. Do not drop the inbox while retries are possible. + +### Task 17: Add canonical graph settings, an inactive cutover bridge and zero-resource disabled mode + +**Owner:** `app-bootstrap` plus the thin transitional `adapter-inbound-web` endpoint +**Depends on:** Tasks 5, 10–16 + +**Files — create:** + +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/notification/NotificationSettings.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/notification/NotificationCompositionConfig.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/notification/NotificationCompositionValidator.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/notification/NotificationSecretMaterialBridge.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/notification/NotificationWorkerRuntimeProfile.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/notification/NotificationWriterStartupMode.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/notification/NotificationWriterActivationGate.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/notification/NotificationSameDataSourceTopologyValidator.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/notification/NotificationDatabaseRoleSettings.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/notification/NotificationDatabaseRoleComposition.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/notification/NotificationDatabaseRoleTopologyValidator.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/notification/FencedLegacyNotificationPort.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/notification/NotificationCompositionTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/notification/NotificationZeroResourceTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/notification/NotificationSecretMaterialBridgeTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/notification/NotificationCanonicalSameTransactionCompositionTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/notification/NotificationCanonicalWriterFenceSetCompositionTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/notification/NotificationWriterActivationGateTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/notification/NotificationInternalTrustContextCompositionTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/notification/FencedLegacyNotificationPortTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/notification/NotificationCutoverAuthorizationCompositionTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/notification/NotificationDatabaseRoleCompositionTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/notification/NotificationFlywayRoleIsolationTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/notification/NotificationWriterOwnershipCommitAckContractTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/NotificationSecretEnvContractTest.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/cutover/Ed25519NotificationWriterInventoryEvidenceVerifier.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/cutover/NotificationWriterEvidenceTrustCatalog.java` +- `src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/cutover/Ed25519NotificationWriterInventoryEvidenceVerifierTest.java` +- `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/NotificationWriterOwnershipController.java` +- `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/NotificationWriterFenceInitializationRequest.java` +- `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/NotificationWriterFenceInitializationResponse.java` +- `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/NotificationWriterQuiescenceAttestationRequest.java` +- `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/NotificationWriterQuiescenceAttestationResponse.java` +- `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/NotificationWriterPermitTerminalizationRequest.java` +- `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/NotificationWriterPermitTerminalizationResponse.java` +- `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/NotificationWriterOwnershipRequest.java` +- `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/NotificationWriterOwnershipResponse.java` +- `src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/notification/NotificationWriterOwnershipControllerTest.java` + +**Files — modify:** + +- `src/app-bootstrap/src/main/resources/application.yml` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/startup/MigrationStartupConfig.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/startup/MigrationStartupRunner.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/startup/RequiredEnvironmentValidator.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/runtime/startup/MigrationStartupRunnerTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/runtime/startup/RequiredEnvironmentValidatorTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/FlywayMigrationCompatibilityContractTest.java` +- `src/.env` +- `src/build.gradle` +- `docs/registries/env-keys.yaml` +- `docs/registries/secrets-classification.yaml` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/SecretsClassificationRegistryTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/adapter/outbound/OptionalAdapterBeanGatingTest.java` +- `src/app-bootstrap/README.md` +- `src/app-bootstrap/CLAUDE.md` + +- [ ] RED settings/composition cases: + Model three disjoint states. `PURE_DISABLED` means canonical `disabled` plus legacy config absent + and has every notification runtime resource 0. `PRE_LEGACY_BRIDGE` means canonical `disabled` + plus exact legacy-only config in a structurally detected PRE artifact; canonical + provider/store/worker resources are 0 while the transitional operator/fence/permit and selected + legacy provider exist, with admission/call 0 before initialization. `CANONICAL_CONFIGURED` + requires exact binding-ID equality and no legacy key. Unknown/extra/missing + binding/property/provider/template/revision fails; expected-mode mismatch fails; exact send/store/ + ingress account-region-configuration-set-topic tuple plus retry/DLQ/max-callback-age/tombstone + profile; live/retained revision availability; + legacy+canonical conflict fails even when values agree. The bounded + `APP_NOTIFICATION_EXPECTED_WRITER_GENERATIONS` key set must equal the compiled route catalog; + each value is the reviewed canonical target and its legacy predecessor is `target - 1`. A dark + legacy bridge may start with no fences but admits 0 until batch initialization creates the + complete predecessor set. During route-by-route switching, an exact key set may legally mix + `ACTIVE/LEGACY@predecessor` (legacy admits), `DRAINING/LEGACY@predecessor` (neither admits) and + `ACTIVE/CANONICAL@target` (canonical admits); each node opens only its owner-matching route. + `REQUIRE_CANONICAL` final startup requires every route at the exact canonical target. + `CUTOVER_WAIT` is permitted only in a PRE artifact with canonical-only config and the same + closed predecessor/target state machine. Each predecessor route has admission, dispatcher + claim and provider call 0 and readiness reports bounded `CUTOVER_WAIT`; it activates only + after a committed fence read proves `ACTIVE/CANONICAL@target`. + Before initialization, absent fences plus an empty persisted proof registry remain dark. Once + any initialization row exists, the snapshot's route/profile/admission-role/proof-class/ + evidence-revision/digest set must exactly equal `NotificationCutoverRouteCatalog`; partial, + extra or drifted registry state closes readiness and every writer. + Absent/partial/extra set, unrelated generation/owner/state, mixed legacy+canonical config in + one process or caller override fails startup/activation. Runtime drift from the allowed + predecessor/target state closes that route and readiness before any new work. + `NotificationCompositionValidator` derives `CUTOVER_WAIT` only from the PRE structural stage, + canonical-only graph and persisted exact fence state; no request, environment key or generic + Spring property may select it. +- [ ] RED zero-resource cases: + PURE_DISABLED -> provider/store worker/client/executor/scheduler/probe/readiness + component/operator/application DML·table scan 0; no secret lookup, cert fetch or provider + health call. Run this assertion after release-owned Flyway migrations; V7/V8 DDL validation is + not feature-gated application activity. An exact-empty database remains + `AWAITING_SIGNED_FRESH_PROVISIONING`, with application admission, workers and provider clients + dark until the separate provisioning operation commits. + PRE_LEGACY_BRIDGE -> canonical resources 0, only exact transitional + legacy resources present, + provider call 0 before audited initialization, then every legacy call requires a committed + permit. CANONICAL_CONFIGURED -> legacy/transitional admission path closed as dictated by the + ownership state. `NotificationZeroResourceTest`, `NotificationCompositionTest` and + `FencedLegacyNotificationPortTest` each cover all applicable states and reject every + same-process legacy+canonical overlap. +- [ ] RED secret bridge cases: + `SecretSource` resolves references only in bootstrap; adapter-owned handles receive versioned + material; missing/blank/malformed secret fails without logging it; no raw key or provider + credential is retained in settings, application values, entities or adapter-owned immutable + records. This is not an end-to-end wipeability claim: the current `SecretSource`/ + `EnvironmentSecretSource` API and JDBC credential APIs necessarily expose short-lived Java + `String` values that cannot be wiped. Only the adapter-facing mutable copy is wiped on close. + Tests prove no logging/exception/settings/entity retention and minimize/shorten source-to-handle + copies; they do not claim erasure of an already-created JVM `String`. +- [ ] RED same-DB topology cases against the canonical Spring infrastructure: + `SpringTransactionPort`, the primary `PlatformTransactionManager`, JPA + `EntityManagerFactory`/physical `DataSource` identity and notification store must resolve to + one topology; configured durable startup fails closed on any mismatch. Do not require or + manufacture a production “representative business repository”: this template intentionally + has no production sample aggregate, and `sample-portfolio` must not leak into the runtime + graph. Keep the real business-row write plus notification append commit/rollback proof in the + Task 10 app-bootstrap integration fixture. Every adopted feature later adds its own + composition contract proving its business store joins this primary transaction manager. +- [ ] RED the complete but inactive transitional cutover surface before any qualification or + deployment. The authenticated batch + `POST /api/admin/notifications/writer-ownership/initialize-legacy` accepts only the reviewed + initial-generation map keyed by the server-known routes, reason and operation token. The + proxied operation loads `NotificationWriterRouteSet` derived from the compiled cutover + catalog, derives the canonical ordered set and digest server-side, rejects missing/extra keys, + and maps only to + `InitializeNotificationWriterFencesOperation`. Neither route revisions outside that catalog + nor a caller-provided route-set digest is authoritative. The separate + `POST /api/admin/notifications/routes/{routeId}/writer-ownership` accepts only + `BEGIN_DRAIN|COMPLETE_SWITCH|ABORT_DRAIN` for one route and maps only to + `SwitchNotificationWriterOwnershipOperation`. BEGIN additionally requires a bounded signed + deployment inventory manifest; the controller maps only its opaque bytes/key revision, while + the application verifier independently authenticates exact environment/DB/route/PRE artifact + and complete node inventory and derives every row/count/digest. A third authenticated + `POST /api/admin/notifications/routes/{routeId}/writer-quiescence-attestations` maps only to + `RecordNotificationWriterQuiescenceAttestationOperation`; request data supplies reviewed drain + generation, a bounded signed quiescence manifest and token, never authoritative zero-fact/ + old-node/permit digests. The verifier authenticates the exact frozen node inventory, each + node's retired/quiesced fact plus irreversible deployment-generation/credential/egress fence, + consumer inventory/count 0 and provider-call-ledger identity/open-count 0. The server uses + post-lock DB time to validate the signer's bounded issued/expires window and record verified + time, snapshots the locked persisted proof registry, permit/holder set and BEGIN + inventory, and derives actor. The exact least-privilege + mapping + is `notification-operator -> notification:cutover,notification:cutover-terminalize, + notification:cutover-attest`; default + `admin` receives none. Controller tests cover 401/403, validation, actor spoof rejection + and DTO/command mapping; composition tests prove distinct interface-based method-security + initializer/terminalizer/switch/attestation proxies and internal-delegate separation. The + switch request + accepts `quiescenceAttestationToken` only for COMPLETE on a + `QUIESCENCE_REQUIRED` route; it is required there and forbidden for + BEGIN/ABORT/HARD_BOUND_PROVEN. Caller-supplied actor, profile/permit/holder/node-set or + zero-fact digests are never authoritative. + The fourth authenticated + `POST /api/admin/notifications/routes/{routeId}/writer-permits/terminalize-expired` maps only + to `TerminalizeExpiredNotificationWriterPermitsOperation`; request fields are exact drain + generation, bounded batch, reason and token. Server derives actor, DB time, persisted registry + and affected permit set. It requires `notification:cutover-terminalize`, has provider I/O 0 + and cannot be invoked through a scheduler or read query. +- [ ] RED/GREEN the Ed25519 verifier before controller composition. Freeze + domain-separated `writer-inventory-manifest-v1` and + `writer-quiescence-manifest-v1` length-prefixed canonical encodings with sorted bounded node/ + fact rows. Cover valid current/retiring issuer key revisions, non-canonical order/encoding, + duplicate/unknown fields, oversized node set, signature/algorithm/key downgrade, wrong + environment/DB/route/artifact/generation/ledger identity, expiry and one-byte mutation. Only + opaque digests may represent node/environment/ledger identity; raw hostnames, credentials and + human PII are rejected from retained evidence. The checked-in closed trust catalog pins allowed + issuer key IDs, public-key digests and current/retiring windows; request/env data cannot + introduce a new trust anchor, and resolved public-key material must match the pinned digest. + The reviewed signed profile/closed catalog, not environment input, pins + `allowedClockSkew` and `acceptanceMargin`; the TTL env value may only tighten the maximum + issuance window and can never relax + `issuedAt - allowedClockSkew <= serverVerifiedAt <= expiresAt - acceptanceMargin`. + Persist the canonical signed payload and signature together with issuer/trust snapshot, + issued/expires/verified times and exact environment/DB/artifact/inventory/ledger identities. + Cover signed zero-node authority, write-time verification, persisted round-trip and startup + re-verification. SQL checks only immutable structure and digests; Java is the cryptographic + authority. Production configuration contains issuer public-key references only, never an + issuer private signing key. +- [ ] RED real-PostgreSQL + MockMvc initialization cases before this surface can be deployed: + exact physical commit before 2xx; commit failure/rollback never returns 2xx; concurrent + different-token initialization has one winner; same-token result-loss replay returns the + committed full route-set result; absent fences plus empty control/data-plane journals succeed, + while partial/existing fence sets, any-nonempty-journal and + route-set/digest/generation/token mismatch fail without mutation. Include a two-route fixture + proving all fences, operation children and every proof-registry row commit atomically, and + that replay returns the stored registry digest rather than refreshing from changed input. + These cases + live in `NotificationWriterOwnershipCommitAckContractTest`. The same test covers physical + commit/failure/result-loss replay and stale-generation rejection for + `BEGIN_DRAIN|TERMINALIZE_EXPIRED_PERMITS|COMPLETE_SWITCH|ABORT_DRAIN`, plus terminalizer and + attestation commit-before-2xx, signed inventory/quiescence issuer and set mismatch, 401/403, + idempotent replay and COMPLETE missing/stale/mismatched-token failures. It pauses an old bridge + after credential/client/connection acquisition but before provider I/O, commits COMPLETE from + an independent transaction, resumes the old bridge and proves provider I/O 0 because its + deployment generation, credential and egress route are irreversibly disabled. A fake + application port is not sufficient. +- [ ] Freeze this minimum env/property grammar before implementation: + + | env key | property/use | default/classification | required when | + | --- | --- | --- | --- | + | `APP_NOTIFICATION_EXPECTED_STATE` | `app.notification.expected-state` | `disabled`, public enum | always | + | `APP_NOTIFICATION_EXPECTED_BINDING_IDS` | exact binding ID CSV assertion | empty, public | configured | + | `APP_NOTIFICATION_EXPECTED_WRITER_GENERATIONS` | exact `route-revision:canonical-target-generation` set assertion | empty, public bounded CSV | legacy bridge or configured canonical | + | `APP_NOTIFICATION_CUTOVER_ATTESTATION_TTL` | maximum signed-evidence issuance/acceptance window; never a committed irreversible-proof lease | `5m`, public upper bound only | PRE cutover surface | + | `APP_NOTIFICATION_CUTOVER_INVENTORY_ISSUER_KEY_REFS` | trusted Ed25519 inventory/quiescence verifier public-key SPKI refs; never signing keys | empty, public verification-material bounded CSV | PRE cutover surface | + | `APP_NOTIFICATION_DB_EXPECTED_RUNTIME_ROLE` | exact runtime database principal | `notification_runtime`, public fixed value | notification schema present | + | `APP_NOTIFICATION_DB_EXPECTED_MIGRATOR_ROLE` | exact Flyway owner principal | `notification_migrator`, public fixed value | Flyway enabled | + | `APP_NOTIFICATION_DB_RUNTIME_USERNAME_REF` | runtime nonowner username reference | null, sensitive-config | notification schema present | + | `APP_NOTIFICATION_DB_RUNTIME_PASSWORD_REF` | runtime nonowner password reference | null, sensitive-config | notification schema present | + | `APP_NOTIFICATION_DB_MIGRATOR_USERNAME_REF` | Flyway owner username reference | null, sensitive-config | Flyway enabled | + | `APP_NOTIFICATION_DB_MIGRATOR_PASSWORD_REF` | Flyway owner password reference | null, sensitive-config | Flyway enabled | + | `APP_NOTIFICATION_DISPATCH_BATCH_SIZE` | bounded worker batch | `20`, public positive `<=100` | durable binding | + | `APP_NOTIFICATION_DISPATCH_CONCURRENCY` | bounded worker concurrency | `4`, public positive | durable binding | + | `APP_NOTIFICATION_CLAIM_LEASE` | claim lease | `30s`, public bounded duration | durable binding | + | `APP_NOTIFICATION_ATTEMPT_TIMEOUT` | absolute provider attempt budget | `10s`, public bounded duration | any binding | + | `APP_NOTIFICATION_FINALIZE_GRACE` | post-attempt drain/finalize grace | `30s`, public bounded duration | durable binding | + | `APP_NOTIFICATION_RECEIPT_RECONCILE_INTERVAL` | orphan/reconcile cadence | `30s`, public bounded duration | receipt binding | + | `APP_NOTIFICATION_RETENTION_INTERVAL` | redaction/purge cadence | `1h`, public bounded duration | durable binding | + | `APP_NOTIFICATION_SLACK_WORKSPACE_REF` | exact workspace identity reference | null, sensitive-config | Slack binding | + | `APP_NOTIFICATION_SLACK_DESTINATION_REF` | reviewed destination reference | null, sensitive-config | Slack binding | + | `APP_NOTIFICATION_SLACK_TOKEN_REF` | bound reference to a `SecretSource` key | null, sensitive-config | Slack binding | + | `APP_NOTIFICATION_SLACK_BOT_TOKEN` | env-backed secret-source-only material for baseline ref | null, secret | referenced Slack key | + | `APP_NOTIFICATION_SES_REGION` | exact AWS region | null, public enum/region grammar | SES binding | + | `APP_NOTIFICATION_SES_EXPECTED_CREDENTIAL_SOURCE` | workload credential mode assertion | null, public enum | SES binding | + | `APP_NOTIFICATION_SES_FROM_IDENTITY_REF` | verified identity reference | null, sensitive-config | SES binding | + | `APP_NOTIFICATION_SES_CONFIGURATION_SET` | exact event configuration set | null, sensitive-config | SES binding | + | `APP_NOTIFICATION_SES_TOPIC_ARN` | exact feedback TopicArn | null, sensitive-config | SES binding | + | `APP_NOTIFICATION_SES_DLQ_REF` | infrastructure DLQ identity | null, sensitive-config | SES binding | + | `APP_NOTIFICATION_PAYLOAD_AEAD_CURRENT_KEY_REF` | bound current AEAD key reference/version | null, sensitive-config | durable binding | + | `APP_NOTIFICATION_PAYLOAD_AEAD_RETIRING_KEY_REFS` | bounded retiring AEAD ref CSV | empty, sensitive-config | retained old ciphertext | + | `APP_NOTIFICATION_LOOKUP_HMAC_CURRENT_KEY_REF` | bound current HMAC key reference/version | null, sensitive-config | durable/receipt binding | + | `APP_NOTIFICATION_LOOKUP_HMAC_RETIRING_KEY_REFS` | bounded retiring HMAC ref CSV | empty, sensitive-config | rotating aliases | + | `APP_NOTIFICATION_PAYLOAD_AEAD_KEY_V1` | secret-source-only AES-256-GCM material | null, secret | selected v1 ref | + | `APP_NOTIFICATION_LOOKUP_HMAC_KEY_V1` | secret-source-only HMAC root material | null, secret | selected v1 ref | + + Binding IDs, kind/mode assertions, route/template/card revisions and ordered provider targets + remain checked-in closed YAML/code catalog entries; do not accept an unrestricted env map that + can invent them. Adding key version v2 means an additive versioned env/secret registry row, + never overwriting v1 while retained rows reference it. +- [ ] Add all public/sensitive keys to `env-keys.yaml` and `.env` with safe blank/default examples. + Add token/AEAD/HMAC and sensitive identity rows to `secrets-classification.yaml`. + Optional notification secrets use an explicit `required_when` condition and are validated only + by `NotificationCompositionValidator` when the matching binding is compiled; disabled mode + performs no secret lookup. +- [ ] Bind only provider/identity/key `*_REF` values into immutable `NotificationSettings`; raw + token/AEAD/HMAC bytes must never be an `application.yml` placeholder or settings field. The + bridge calls `SecretSource.resolve(ref)` only after the canonical graph selects that + provider/key revision. Database runtime/migrator references bind exclusively to + `NotificationDatabaseRoleSettings` and the dedicated runtime/Flyway data-source wiring, never + to application commands/records or `NotificationSettings`. Provisioner references are absent + from the normal application and exist only in the Task 21 provisioning source set. +- [ ] Extend `verifyEnvKeys` narrowly: an `.env` key without an `application.yml` placeholder is legal + only when `secrets-classification.yaml` registers it as `secret-source-only` with a + `required_when` condition. Unknown/orphan public keys still fail. Add the secrets registry as a + task input and tests for allowed secret-source-only, misspelled secret, disabled zero-lookup and + ordinary orphan rejection. +- [ ] Update `SecretsClassificationRegistryTest` so unconditional secret rows still match + `SecretSourceValidator.REQUIRED_PROD_SECRETS` 1:1, while `required_when` rows are excluded from + that global list and are covered by exact notification composition tests. Do not make optional + Notification secrets globally required in prod. +- [ ] Verify RED: + + ```bash + cd src && ./gradlew :app-bootstrap:test \ + --tests '*NotificationCompositionTest' \ + --tests '*NotificationZeroResourceTest' \ + --tests '*NotificationSecretMaterialBridgeTest' \ + --tests '*NotificationCanonicalSameTransactionCompositionTest' \ + --tests '*NotificationCanonicalWriterFenceSetCompositionTest' \ + --tests '*NotificationWriterActivationGateTest' \ + --tests '*NotificationInternalTrustContextCompositionTest' \ + --tests '*FencedLegacyNotificationPortTest' \ + --tests '*NotificationCutoverAuthorizationCompositionTest' \ + --tests '*NotificationWriterOwnershipCommitAckContractTest' \ + --tests '*NotificationSecretEnvContractTest' \ + --console=plain + cd src && ./gradlew :adapter:inbound:web:test \ + --tests '*NotificationWriterOwnershipControllerTest' --console=plain + cd src && ./gradlew :adapter:outbound:notification:test \ + --tests '*Ed25519NotificationWriterInventoryEvidenceVerifierTest' --console=plain + ``` + +- [ ] Bind canonical settings once, derive minimal outbound/inbound/persistence profiles, and pass + application-owned provider/store/ingress capability descriptors to the pure application + compatibility validator. +- [ ] At startup, have `NotificationSameDataSourceTopologyValidator` inspect the canonical + transaction port, primary transaction-manager/JPA resource and notification-store topology + descriptor and reject durable mode unless they share the same physical transaction + manager/data-source identity. Feature-specific business-store atomicity remains an explicit + feature composition test, not a fabricated bootstrap bean. This composition-root invariant + must not introduce a new project dependency edge. +- [ ] Wire exactly the three non-interchangeable PostgreSQL principals + `notification_migrator`, `notification_runtime`, `notification_provisioner` and reject any + additional notification-scoped owner/member/grantee. `notification_migrator` owns the + notification schema and is used only by the dedicated Flyway data source; + `notification_runtime` is a nonowner used by the application transaction manager; the + `notification_provisioner` credential is not composed here and is available only to the + explicit Task 21 provisioning source set. Resolve migrator/runtime credential references + separately, assert `current_user` and ownership/grants at startup, and fail on shared + credentials, owner runtime, role inheritance or unexpected membership. Tests prove runtime + cannot run DDL, mutate retained evidence/provenance directly, use transitional functions or + consume sequences beyond its exact runtime grants, except that the exact PRE artifact grants + runtime only its explicitly enumerated transitional function `EXECUTE` surface and FINAL + revokes that entire surface. Flyway cannot be reached through an application bean. +- [ ] Before resolving either runtime or migrator credentials, require the Task 9 external + DB-admin/IaC bootstrap revision and exact role-inventory/ownership probe from + `docs/runbooks/notification-database-role-bootstrap.md`. Application startup never creates, + alters or grants role membership. Missing/mismatched bootstrap evidence, shared credentials, + unexpected membership or an additional notification-scoped principal fails before Flyway or + runtime table access. +- [ ] Update the existing executable startup seam, not a parallel notification-only migration path. + `MigrationStartupConfig` builds Flyway from the dedicated migrator data source; + `MigrationStartupRunner` asserts its connection `current_user=notification_migrator` before + `migrate()` and never receives the primary runtime data source; + `RequiredEnvironmentValidator` requires the runtime credential refs always and migrator refs + exactly when startup migration is enabled. Keep the DB URL/schema locations common, but make + the credential binding choice explicit: `spring.datasource.*` remains the + `notification_runtime` application data source, while `spring.flyway.user` and + `spring.flyway.password` are intentionally unbound/forbidden so Boot cannot treat secret refs + as credentials or fall back to the runtime principal. The dedicated Flyway data source gets + resolved username/password bytes only from `NotificationDatabaseRoleSettings`; tests fail if + Flyway is constructed from the primary data source, if either role ref aliases the other, or + if migration-on-startup can run without the migrator refs. +- [ ] Invoke `NotificationCanonicalWriterFenceGuard` from canonical intent admission inside the same + business-write/append transaction; configured bindings require an exact checked-in route/fence + generation. A stale generation or non-canonical owner rolls back both business write and intent + append and prevents worker activation. Bootstrap never calls the guard or + `NotificationCanonicalWriterFencePort` directly. +- [ ] Freeze the trust-context wiring matrix: + scheduler/worker/health, feature-internal notification orchestration and legacy bridge use + manually composed non-bean application delegates; verified SNS ingress uses a distinct manually + composed receipt delegate after signature/topology authentication; neither path is subjected to + JWT method security or exposed to a normal controller. Only the transitional human initializer + and cutover operations are registered as Spring method-security beans behind + `InitializeNotificationWriterFencesOperation`, + `TerminalizeExpiredNotificationWriterPermitsOperation`, + `RecordNotificationWriterQuiescenceAttestationOperation` and + `SwitchNotificationWriterOwnershipOperation`. Never reuse any proxied target as an internal + delegate, and never publish the internal delegates as Spring use-case beans. Context tests + assert bean absence/identity separation so schedulers/SNS do not fail for missing + `Authentication` and operator calls cannot bypass AOP. +- [ ] Build the legacy fence wrapper and full operator endpoint in this task, but keep them dark: + no automatic fence initialization, no provider call while the fence is absent, and no rollout + before Wave F qualifies this exact artifact. This ordering is deliberate: Task 19/20 manifests + may say `PRE_CUTOVER_BRIDGE` only when the compiled bridge/operator/permit surface being + deployed in 21A is already present. Task 21A/21B perform human-controlled state transitions + and rerun the same tests; they do not add or alter production code before cleanup. +- [ ] `NotificationSettings` is the only `@ConfigurationProperties` binder. Provider and worker + `*RuntimeProfile` types are plain immutable derived slices with no binding annotation or + independent defaults. +- [ ] `NotificationWriterActivationGate` calls only + `NotificationOperationsSnapshotUseCase` for bounded committed refreshes. It never injects + `NotificationOperationsSnapshotPort`, a repository, entity, `EntityManager` or JDBC type. + Snapshot freshness gates readiness/worker admission, while + `NotificationCanonicalWriterFenceGuard` inside each business-write/append transaction remains + the final authoritative fence. Tests prove stale cache cannot authorize an append and no route + opens before a fresh exact committed snapshot. +- [ ] Collect actual descriptors only from the three adapter-owned descriptor sources. Keep expected + settings as a separate input and add negative tests that mutate each actual source + independently; a validator that derives expected and actual from the same settings is + tautological and must fail review. +- [ ] Default `application.yml` to explicit `disabled`; no blank-is-disabled ambiguity. +- [ ] Add every new env key with type/default/secret classification and safe example; no secret value + in YAML/docs/tests. +- [ ] Verify GREEN with the same command and: + + ```bash + cd src && ./gradlew :adapter:inbound:web:check --console=plain + cd src && ./gradlew verifyEnvKeys \ + verifyCleanArchitectureDependencies --console=plain + ``` + +- [ ] Acceptance claim: exact local composition and disabled resource safety; real provider state + remains unqualified. + +**Rollback checkpoint:** deploy canonical code/config dark. Never enable canonical and legacy for the +same route. Before `COMPLETE_SWITCH`, rollback begins by setting canonical expected-state disabled, +pausing workers and using `ABORT_DRAIN` only from DRAINING. After COMPLETE, use the forward-only +incident path in Task 21B; do not re-enable legacy. + +### Task 18: Add bounded workers, retention, observability and readiness truth + +**Owner:** `app-bootstrap` composition with adapter-owned operations +**Depends on:** Task 17 + +**Files — create:** + +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/notification/NotificationDispatcherScheduler.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/notification/NotificationReceiptReconcilerScheduler.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/notification/NotificationRetentionScheduler.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/notification/NotificationMetrics.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/notification/MeteredNotificationIntentAppendPort.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/notification/MeteredInlineNotificationAttemptPort.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/notification/MeteredNotificationProviderAttemptPort.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/notification/MeteredNotificationReceiptStorePort.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/notification/NotificationHealthIndicator.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/notification/NotificationReadiness.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/notification/NotificationLifecycleTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/notification/NotificationObservabilityPrivacyTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/notification/NotificationMeteredDecoratorTest.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/NotificationMaintenanceStoreAdapter.java` +- `src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/notification/NotificationRetentionIntegrationTest.java` + +- [ ] RED lifecycle cases: + bounded executor/queue; no unbounded scheduler overlap; graceful stop halts new claims then + drains bounded authorized attempts; stale lease recovery; readiness down without liveness + restart loop; disabled has zero threads. +- [ ] RED observability/privacy cases: + bounded tags only; no intent/recipient/provider message/tenant raw IDs; representative + PII/secret markers absent from log/span/metric/health/exception/DB plaintext; backlog and oldest + age expose only bounded route/card labels. Cover append, inline attempt, provider attempt + duration/outcome and receipt/orphan event counters, including synchronous inline and SNS paths. +- [ ] RED metering isolation cases: + each bootstrap-owned decorator delegates exactly once; timing/tag construction and meter + registry failures are swallowed into a bounded diagnostic and never change send/store/use-case + result, exception or transaction semantics. No Micrometer/bootstrap type may cross into + `application-core`, outbound notification, persistence or inbound web. +- [ ] RED retention/rotation cases: + payload redaction separated from dedupe tombstone; purge order honors FK; active/backlog/ + receipt window blocks key/template removal; old HMAC alias matches then upgrades to current; + indefinite suppression re-HMAC before ciphertext removal. SNS outer/semantic tombstones outlive + the checked-in max callback age plus ingestion safety margin, and semantic retention covers the + approved manual redrive horizon. Purge just before either bound fails; just after all bounds + and orphan/backup needs pass may succeed. +- [ ] Verify RED: + + ```bash + cd src && ./gradlew :app-bootstrap:test \ + --tests '*NotificationLifecycleTest' \ + --tests '*NotificationObservabilityPrivacyTest' \ + --tests '*NotificationMeteredDecoratorTest' \ + --console=plain + cd src && ./gradlew :adapter:outbound:persistence-jpa:test \ + --tests '*NotificationRetentionIntegrationTest' \ + --tests '*NotificationOperationsSnapshotIntegrationTest' \ + --console=plain + ``` + +- [ ] Implement scheduler beans only for compiled durable/receipt bindings; use bounded batch, + concurrency, retry and shutdown deadlines from reviewed settings caps. +- [ ] Schedulers invoke `NotificationDispatchUseCase`, receipt/reconciliation use cases and + `NotificationMaintenanceUseCase`; they never invoke repositories or persistence entities. +- [ ] Metrics, health and readiness obtain backlog/oldest-age/card facts only through + `NotificationOperationsSnapshotUseCase` and adapter-owned provider readiness probes. Bootstrap + never calls `NotificationOperationsSnapshotPort`, repositories or entities directly, and it + never calls Slack/AWS SDKs directly. +- [ ] Writer-cutover snapshot fields report ACTIVE permit count/max expiry by exact route and + legacy owner across every historical fence generation, plus + `TIMED_OUT_UNPROVEN` count/profile-set digest and only bounded attestation freshness/status + facts—never raw token/evidence. A timestamp-past ACTIVE row remains active until exact + token/version CAS; unproven timeout stays mechanically blocking without attestation. Tests + include an old-generation/multi-profile permit surviving `ABORT_DRAIN` and blocking the next + `COMPLETE_SWITCH`. +- [ ] Wrap application outbound ports only at the composition root with bootstrap-owned metered + decorators, following the existing `MeteredDistributedLockPort` pattern. Never author or wrap + an application inbound use case in bootstrap. Decorators observe only bounded application + result enums/card IDs and elapsed time; they neither own policy nor cause provider/store + retries. +- [ ] Complete the persistence binding matrix: + append/delivery store -> `NotificationStoreAdapter`, + receipt store -> `NotificationReceiptStoreAdapter`, + technical suppression -> `NotificationTechnicalSuppressionStoreAdapter`, + maintenance -> `NotificationMaintenanceStoreAdapter`, + safe operational projection -> `NotificationOperationsSnapshotAdapter`. +- [ ] Health/readiness must report exact card/profile and backlog state; fake/offline evidence cannot + make a provider card ready. +- [ ] Verify GREEN with the same commands. +- [ ] Acceptance claim: local operational safety/privacy evidence; sandbox/load evidence still + pending. + +**Rollback checkpoint:** pause new admission, stop schedulers, inventory in-flight/accepted/ +indeterminate rows, preserve all referenced revisions and prefer forward-fix. + +### Wave E exit gate + +- [ ] Run: + + ```bash + cd src && ./gradlew :adapter:inbound:web:check \ + :adapter:outbound:persistence-jpa:check \ + :app-bootstrap:check \ + verifyEnvKeys \ + verifyPublicPathSnapshot \ + verifyCleanArchitectureDependencies \ + --console=plain + ``` + +- [ ] Request inbound-security, configuration, operations and privacy review. +- [ ] Update the LLM Wiki branch-note with Wave E evidence and an explicit derived-document decision. + +--- + +## Wave F — Exact provider qualification + +### Task 19: Produce opt-in Slack/SES real-provider and SNS topology evidence + +**Owner:** `app-bootstrap` verification source sets (`:app-bootstrap`) +**Depends on:** Tasks 13–18 + +**Files — create:** + +- `src/app-bootstrap/src/notificationSlackReadiness/java/dev/caskeleton/bootstrap/notification/SlackNotificationReadinessTest.java` +- `src/app-bootstrap/src/notificationSesReadiness/java/dev/caskeleton/bootstrap/notification/SesNotificationReadinessTest.java` +- `src/app-bootstrap/src/notificationSesReadiness/java/dev/caskeleton/bootstrap/notification/SesSnsFeedbackReadinessTest.java` +- `src/app-bootstrap/src/notificationSesDlqReadiness/java/dev/caskeleton/bootstrap/notification/SesSnsDlqReadinessTest.java` +- `src/app-bootstrap/src/notificationReadinessSupport/java/dev/caskeleton/bootstrap/notification/NotificationReleaseStage.java` +- `src/app-bootstrap/src/notificationReadinessSupport/java/dev/caskeleton/bootstrap/notification/NotificationReleaseStageDetector.java` +- `src/app-bootstrap/src/notificationReadinessSupport/java/dev/caskeleton/bootstrap/notification/NotificationProductionEvidenceIssuerInput.java` +- `src/app-bootstrap/src/notificationReadinessSupport/java/dev/caskeleton/bootstrap/notification/NotificationProductionEvidenceIssuerClient.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/notification/NotificationReleaseStageDetectorTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/notification/NotificationReleaseStageLegacyMarkerAllowlistTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/notification/NotificationEvidenceManifestTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/notification/NotificationProductionEvidenceIssuerClientTest.java` +- `src/app-bootstrap/src/test/resources/notification/evidence/notification-evidence-schema-v1.json` + +**Files — modify:** + +- `src/app-bootstrap/build.gradle` +- `src/app-bootstrap/gradle.lockfile` +- `docs/registries/env-keys.yaml` +- `docs/registries/secrets-classification.yaml` +- `src/adapter/outbound/notification/README.md` +- `src/adapter/outbound/notification/CLAUDE.md` + +- [ ] Register proposed tasks: + `notificationSlackReadiness`, `notificationSesReadiness` and the separately authorized + `notificationSesDlqReadiness`, plus a shared `notificationReadinessSupport` source set consumed + by every readiness/qualification lane. Do not register the final aggregator here and do not + attach any real-provider task to ordinary `test` or `check`. +- [ ] Have the build generate an immutable artifact-structure input from the compiled production + JAR class/resource inventory, reviewed production dependency locks, source digest and artifact + digest. `NotificationReleaseStageDetector` derives + `PRE_CUTOVER_BRIDGE|FINAL_CLEANUP` from that input only; no property, environment variable, + test argument or caller may override it. Both stages require the additive V7 journal schema; + historical table/column/migration names are not executable legacy markers. + `PRE_CUTOVER_BRIDGE` requires the executable legacy notifier, + `FencedLegacyNotificationPort`, PRE cutover catalog/route set, initializer/switch/permit/ + terminalizer/quiescence-attestation classes/beans/controller, all three operator permissions + and absence of the + final cleanup migration. `FINAL_CLEANUP` requires those executable + legacy/bridge/operator/permit/terminalizer/attestation/`CUTOVER_WAIT` + classes/beans/config/role mappings + absent, the retained canonical route catalog/route set, canonical fence guard/adapter, + canonical-only `REQUIRE_CANONICAL` config, retained V7 + proof-registry/permit/operation history plus canonical signed BEGIN-inventory header, + quiescence/attestation header, evidence trust snapshot, + `NotificationWriterFinalizationDiscriminatorEntity`/ + `NotificationWriterFinalizationDiscriminatorJpaRepository`, fresh-provenance schema resource + and the exact closed discriminator states + `AWAITING_SIGNED_FRESH_PROVISIONING|FRESH_PROVISIONED|UPGRADE_VALIDATED`, reviewed V8 + validation/awaiting state and cleanup migration present. The structural detector rejects an + absent/unknown discriminator, a fresh token plus validated-history digest overlap, and any + state/resource combination outside the Task 9 XOR. It also + requires the explicit post-migration `notificationFreshProvisioning` task contract and rejects + migration-time provisioning. + Partial, contradictory, unknown or digest-mismatched + inventories fail before a provider side effect. +- [ ] Keep the explicit legacy marker names only in + `NotificationReleaseStageDetector` and its two reviewed detector tests. Do not obfuscate names + with string concatenation. `NotificationReleaseStageLegacyMarkerAllowlistTest` scans the + repository, requires the complete expected marker set in the detector, and fails on any + occurrence outside the exact path/symbol allowlist. Production consumer-zero hygiene is a + separate scan over every registered production leaf's `src/**/src/main` tree plus production + config. +- [ ] Before any real provider lane, prepare the isolated sandbox by the detector-derived stage; + fixture SQL and caller stage override are forbidden: + + - `PRE_CUTOVER_BRIDGE`: deploy the exact PRE artifact with legacy-only config and admission + closed; invoke authenticated batch `INITIALIZE_LEGACY`; start canonical-only instances of the + same artifact in `CUTOVER_WAIT` with the reviewed future generation set and prove their + admission/worker/provider-call count is 0. For every route, call the independent infrastructure + issuer through `NotificationProductionEvidenceIssuerClient`; it signs the complete + environment/DB/route/artifact old-node set and that manifest is bound to `BEGIN_DRAIN`. + Omitted/extra node or permit holder fails. If expired ACTIVE permits remain, invoke the + authenticated bounded terminalizer until the read-only snapshot reports ACTIVE 0. Then select + exactly one PRE proof arm. On current R0, `QUIESCENCE` proves every frozen instance has an + irreversible deployment-generation tombstone and legacy credential/egress revocation, + consumer inventory 0 and provider-ledger open-count 0; the issuer signs that exact manifest + and the operation root-commits its attestation before COMPLETE. Reverify the retained signed + headers/trust snapshot in Java and prove a paused old process with cached + credential/client/connection performs provider I/O 0 after COMPLETE. `HARD_BOUND` also + Java-reverifies the retained signed BEGIN inventory header/children before COMPLETE, proves + the exact all-hard-bound registry/evidence revision and safe terminal permits, keeps ACTIVE 0, + and forbids quiescence attestation and its child facts. Verify latest + operation/attestation results and that only exact + `ACTIVE/CANONICAL@g_final` routes activate on the waiting instances. Capture + commit-before-2xx, wait-to-active evidence and the immutable cutover operation-history + digest including BEGIN inventory plus exactly the selected arm: attestation + permit/holder/node sets, irreversible fence identities and consumer/provider-ledger zero + snapshot for `QUIESCENCE`, or registry/evidence revision and safe terminal permits for + `HARD_BOUND`. + - `FINAL_CLEANUP`: deploy the exact cleanup artifact either on the already-canonical upgrade + sandbox whose V8 validation preserves the route set/history, or on a clean-provisioning + V1..V8 sandbox. V8 must leave the latter + `AWAITING_SIGNED_FRESH_PROVISIONING`; obtain an independent issuer-signed database-birth + authorization through the production issuer client only after its control plane has + durably committed and independently observed the permanent no-legacy-authority fence. The + authorization and retained row must include the complete Task 9 + `notification_fresh_installation_provenance` field set and causal order, not a summarized + subset: enforcement revision/digest/activation/read-back precedes cached DB-session and + provider-flow termination; the causally later post-enforcement zero manifest includes + provider-ledger cut revision/time plus entry/open/indeterminate counts 0; the permanent + fence canonical payload/digest is seal-committed, marked irreversible and read back before + signing. Require the exact credential issuance/revocation set digests and completion facts, + ingress/egress denial policy digests and established-flow blocks, + `legacy_database_session_inventory_digest`, + `legacy_database_session_open_count=0`, + `legacy_database_session_termination_evidence_digest`, + `legacy_provider_connection_flow_inventory_digest`, + `legacy_provider_connection_flow_open_count=0`, and + `legacy_provider_connection_flow_termination_evidence_digest`. The issuer refuses a + pre-enforcement zero snapshot, cross-revision composition, sign-before-seal, unsigned, + uncommitted or reversible fence. Invoke the opt-in + `notificationFreshProvisioning` Gradle/CLI operation. It commits signed provenance, + `INITIALIZE_CANONICAL_FRESH` and the exact route fence set in one transaction, after which a + restart may enter `REQUIRE_CANONICAL`. Prove an unsigned/expired/wrong + environment/DB/artifact/inventory/ledger authorization, a nonempty partial fence set and an + existing-history fresh marker all fail. Pause/inject an old deployment generation, cached + legacy DB/provider credential, established DB session/provider connection and legacy egress + path before provisioning; prove both exact session/connection inventories are open-count 0, + both termination evidences are valid and both established-flow block facts are true, and + legacy DB I/O and provider I/O are both 0 before and after provisioning, including after + resume. Prove canonical + catalog/config/fence exact equality and transitional endpoint/class/role 0. Do not reference + or invoke deleted initializer, switch, permit, terminalizer or attestation types. Capture the + cleanup migration/validated-fence-set digest. + + Missing/partial fences, mixed-stage markers, direct SQL, a PRE operation in FINAL or a + legacy/canonical config overlap makes the sandbox ineligible. +- [ ] Make each real lane fail closed when explicitly invoked without required exact credentials, + sandbox destination/account/region/workspace/configuration set/topic/DLQ inputs. Do not convert + absent configuration to JUnit success/skip. +- [ ] Require an already deployed, isolated sandbox topology; a Gradle process on localhost is never + considered reachable by SNS: + the same release source/artifact revision runs behind public HTTPS + `/webhooks/notifications/aws-ses-v1`; an SES configuration-set event destination targets the + exact SNS TopicArn; the HTTPS subscription is confirmed and has an explicit redrive policy to + the reviewed DLQ; its bounded SNS HTTP `DeliveryPolicy` fixes retry count, min/max delay, + backoff function and total retry horizon; the deployed service and readiness runner observe the + same sandbox PostgreSQL notification journal/application store. Validate subscription + ARN/status, TopicArn, configuration set, endpoint, delivery-policy digest/horizon, DLQ/redrive + policy/retention/redrive horizon, derived max callback age, ingress tombstone retention and + deployed revision before sending. Actual values must equal the checked-in ingress profile. +- [ ] Freeze required readiness inputs, all registered/classified without logging their values: + `APP_NOTIFICATION_READINESS_HTTPS_BASE_URL`, + `APP_NOTIFICATION_READINESS_DEPLOYED_REVISION`, + `APP_NOTIFICATION_READINESS_JOURNAL_DB_REF`, + `APP_NOTIFICATION_READINESS_SES_SUBSCRIPTION_ARN`, + `APP_NOTIFICATION_READINESS_SES_DELIVERY_POLICY_DIGEST`, + `APP_NOTIFICATION_READINESS_SES_DLQ_REF`, + `APP_NOTIFICATION_READINESS_MAX_WAIT`, and + `APP_NOTIFICATION_READINESS_DLQ_DRILL_ENABLED`, + `APP_NOTIFICATION_READINESS_EVIDENCE_ISSUER_ENDPOINT_REF`, + `APP_NOTIFICATION_READINESS_EVIDENCE_ISSUER_CLIENT_CREDENTIAL_REF` and + `APP_NOTIFICATION_READINESS_DB_BIRTH_AUTHORIZATION_REF`, plus + `APP_NOTIFICATION_READINESS_NO_LEGACY_AUTHORITY_FENCE_REF`. + `NotificationProductionEvidenceIssuerClient` submits only bounded + environment/DB/artifact/route/node/consumer/provider-ledger identities and resolves client-auth + references outside logs. No production source, configuration, environment key, test resource + or artifact may contain an evidence issuer private signing key. Provider + account/workspace/destination and + credential refs remain the exact Task 17 settings, not a second defaulting configuration tree. +- [ ] Slack lane: + send a bounded sandbox probe with both exact mode profiles; capture `(channel, ts)` and actual + request count; verify token/workspace/channel scope, hidden retry 0, no sensitive artifact. +- [ ] SES lane: + send exactly one simulator/verified sandbox recipient with `ca_attempt_v1`; observe `MessageId`; + receive an authentic SNS HTTPS callback at the deployed endpoint; poll the same sandbox journal + for the committed correlation/receipt projection; verify TopicArn/configuration-set/tag + matching and commit-before-ACK without recording recipient/content. Use a unique correlation, + bounded wait and cleanup only through `NotificationMaintenanceUseCase`. +- [ ] Keep the DLQ drill separate and human-approved. With + `APP_NOTIFICATION_READINESS_DLQ_DRILL_ENABLED=true`, use an isolated copy of the same artifact + and SNS subscription whose notification database is deliberately unavailable, publish a + bounded authentic SNS probe, observe application `503`, SNS retry and eventual movement to the + exact DLQ, then restore the sandbox and purge only the correlated probe. Task 16 remains the + deterministic proof of commit-failure-to-503; this lane proves the deployed SNS retry/redrive + topology. Require `READINESS_MAX_WAIT` to exceed the queried bounded retry horizon plus a + reviewed observation margin while remaining below the task-wide safety cap. It must never run + against a production subscription. +- [ ] Emit a sanitized provider manifest for each lane with the complete comparison axes: + card and binding revision; channel/mode/strategy/route; template/render/serialization/escaping + revision; submission/correlation/idempotency/reconciliation profile; receipt/projection + profile; credential-source generation; account/region/workspace digest; SES configuration set, + TopicArn, subscription ARN, exact delivery-policy digest/retry horizon, DLQ/redrive and ingress + profile, max callback age and tombstone/manual-redrive retention; persistence + schema/crypto profile; + writer route-set digest and exact canonical generation-set digest; a closed + stage-discriminated ownership evidence union: + `PRE.QUIESCENCE` is signed BEGIN inventory + signed quiescence/attestation header and trust + snapshot + exact irreversible tombstone/credential/egress facts + ACTIVE permit 0 + consumer + inventory 0 + provider-ledger 0 + cutover history; `PRE.HARD_BOUND` is signed BEGIN inventory + + the exact all-hard-bound registry/evidence revision + safe terminal permits + ACTIVE permit + 0 and forbids a quiescence attestation. `FINAL.FRESH` is V8/cleanup structural digest + + discriminator `FRESH_PROVISIONED` with its fresh token and null validated-history digest + + signed DB birth certificate + the complete exact Task 9 fresh-provenance field set and + enforcement-read-back → cached-session/flow termination → post-enforcement zero manifest + (provider-ledger entry/open/indeterminate 0) → irreversible fence seal/read-back → signature + causal order + signed fresh provenance/trust snapshot + `INITIALIZE_CANONICAL_FRESH`; + `FINAL.UPGRADE` is V8/cleanup + structural digest + discriminator `UPGRADE_VALIDATED` with its complete retained-history + digest, null fresh token and absent fresh provenance. `AWAITING_SIGNED_FRESH_PROVISIONING` + cannot emit readiness evidence. Source/dependency/artifact/deployed revision; actual + request count; lane/run timestamp/expiry. + Include immutable `release_stage`; the schema rejects unknown/missing/overlapping union arms, + evidence forbidden by the selected discriminator, detector/manifest stage mismatch and + sensitive raw values. +- [ ] Task 19 only produces real-provider/callback/DLQ evidence. It does not aggregate local + durability qualification and by itself does not authorize an operational R2 claim. +- [ ] RED then GREEN the evidence schema before any live side effect. Cover every required axis, + unknown/missing fields, sensitive raw value rejection, lane/card mismatch, expiry, skipped + evidence and release-stage mismatch. RED/GREEN the structural detector for exact PRE, exact + FINAL, mixed, unknown, caller-override attempts and artifact digest mismatch. Exact FINAL must + include retained V7 proof-registry/permit/operation history and signed + inventory/quiescence/attestation headers, trust snapshots, the actual finalization + discriminator entity/repository with its three exact states/XOR, and a fresh-provenance schema + resource whose row is required exactly for `FRESH_PROVISIONED` and forbidden for + `AWAITING_SIGNED_FRESH_PROVISIONING|UPGRADE_VALIDATED`, plus V8 + awaiting/validation and + cleanup migration while all executable + permit/terminalizer/cutover classes/beans/config are absent; + broad text scanning that classifies historical V7 as PRE must fail. PRE tests remove + each required terminalizer or attestation operation, endpoint, permission and schema marker in + turn and require mixed/unknown. FINAL tests require executable terminalizer/attestation/role 0 + while retained history remains and enumerate only + `FRESH_PROVISIONED|UPGRADE_VALIDATED`; PRE tests enumerate only + `QUIESCENCE|HARD_BOUND`. The production issuer-client test rejects a response not signed by the + pinned public trust snapshot and asserts that no private-key input is bindable: + + ```bash + cd src && ./gradlew :app-bootstrap:test \ + --tests '*NotificationEvidenceManifestTest' \ + --tests '*NotificationReleaseStageDetectorTest' \ + --tests '*NotificationReleaseStageLegacyMarkerAllowlistTest' \ + --tests '*NotificationProductionEvidenceIssuerClientTest' \ + --console=plain + ``` + +- [ ] After registering the readiness source sets/tasks and all their dependencies, regenerate and + verify the exact app-bootstrap lock before resolving or executing any readiness task: + + ```bash + cd src && ./gradlew :app-bootstrap:resolveAndLockAll \ + --write-locks --console=plain + cd src && ./gradlew :app-bootstrap:verifyDependencyLocks --console=plain + ``` + +- [ ] Run only in an explicitly prepared sandbox: + + ```bash + cd src && ./gradlew :app-bootstrap:notificationSlackReadiness \ + --console=plain + cd src && ./gradlew :app-bootstrap:notificationSesReadiness \ + --console=plain + # Separate human-approved destructive sandbox drill only: + cd src && ./gradlew :app-bootstrap:notificationSesDlqReadiness \ + --console=plain + ``` + +- [ ] If these cannot run, record the exact blocker and keep the affected card + `NOT_QUALIFIED`; do not mark this Task complete. +- [ ] Acceptance claim: exact provider connectivity and callback/topology smoke evidence only; final + operational R2 eligibility is decided by Task 20 aggregation. + +**Rollback checkpoint:** readiness tests create external sandbox side effects. Use dedicated probe +destinations and retention cleanup; never run against arbitrary production recipients. + +### Task 20: Produce local qualification evidence and aggregate production readiness + +**Owner:** cross-leaf verification harness, aggregated by `app-bootstrap` +**Implementation depends on:** Task 18 +**Final aggregation depends on:** Task 19 provider manifests plus this task's local manifest + +**Files — create:** + +- `src/app-bootstrap/src/notificationQualification/java/dev/caskeleton/bootstrap/notification/NotificationLoadQualificationTest.java` +- `src/app-bootstrap/src/notificationQualification/java/dev/caskeleton/bootstrap/notification/NotificationRotationQualificationTest.java` +- `src/app-bootstrap/src/notificationQualification/java/dev/caskeleton/bootstrap/notification/NotificationRollingRevisionContractTest.java` +- `src/app-bootstrap/src/notificationQualification/java/dev/caskeleton/bootstrap/notification/NotificationProcessCrashRecoveryTest.java` +- `src/app-bootstrap/src/notificationQualification/java/dev/caskeleton/bootstrap/notification/NotificationCrashScenarioMain.java` +- `src/app-bootstrap/src/notificationQualification/java/dev/caskeleton/bootstrap/notification/NotificationAttemptLedgerServer.java` +- `src/app-bootstrap/src/notificationQualification/java/dev/caskeleton/bootstrap/notification/NotificationQualificationOwnershipSetup.java` +- `src/app-bootstrap/src/notificationQualification/java/dev/caskeleton/bootstrap/notification/TestOnlyNotificationWriterEvidenceIssuer.java` +- `src/app-bootstrap/src/notificationQualification/java/dev/caskeleton/bootstrap/notification/TestOnlyNotificationWriterEvidenceIssuerTest.java` +- `src/app-bootstrap/src/notificationQualification/java/dev/caskeleton/bootstrap/notification/NotificationProductionReadinessAggregationTest.java` +- `docs/runbooks/notification.md` + +**Files — modify:** + +- `src/app-bootstrap/build.gradle` +- `src/app-bootstrap/gradle.lockfile` +- `src/app-bootstrap/src/test/resources/notification/evidence/notification-evidence-schema-v1.json` + +- [ ] Define bounded steady/burst/throttle/callback/reconcile profiles and pass/fail thresholds before + running them. +- [ ] Make local qualification ownership setup stage-discriminated and fail closed: + + - `PRE_CUTOVER_BRIDGE` reaches canonical ownership only through the same application + root-transaction operations as Task 19: exact batch initialization, canonical-only + `CUTOVER_WAIT` instances with call/claim 0, route-specific BEGIN with trusted signed complete + node inventory, invoking the authenticated bounded terminalizer for expired ACTIVE permits + before ACTIVE 0 is claimed, and then exactly one proof arm. `QUIESCENCE` adds signed + node-retirement/credential/egress facts, consumer/ledger zero, attestation commit and stale-node + provider I/O 0. `HARD_BOUND` Java-reverifies the same retained signed BEGIN inventory, adds + only the all-hard-bound registry/evidence revision and safe terminal permits, and forbids + attestation. COMPLETE uses the selected exact proof and then + proves exact-generation activation. + - `FINAL_CLEANUP` uses either an exact validated canonical upgrade fixture or runs V1..V8 to + `AWAITING_SIGNED_FRESH_PROVISIONING` and then invokes the explicit + `notificationFreshProvisioning` operation with a signed database-birth authorization issued + only after the permanent irreversible no-legacy-authority fence is committed. The fresh + fixture must finish with discriminator `FRESH_PROVISIONED`, fresh token/provenance present, + validated-history digest absent, and retain the birth certificate plus the complete exact + Task 9 `notification_fresh_installation_provenance` field set and causal order, including + enforcement activation/read-back, cached-session/flow termination, the later + post-enforcement zero manifest, provider-ledger entry/open/indeterminate counts 0, and + irreversible fence seal/read-back before signature. The upgrade fixture must + finish with `UPGRADE_VALIDATED`, the complete retained-history digest present, fresh token and + provenance absent. `VerifyRetainedNotificationWriterEvidenceUseCase`, never a bootstrap + repository/entity read, verifies the selected arm from the actual + `NotificationWriterFinalizationDiscriminatorEntity` and bounded persistence read adapter; a + manifest-only enum is not evidence. Unsigned/uncommitted/reversible-fence, + provenance-less or ambiguous empty fixtures fail. Its qualification source must compile + and run after all + initializer/switch/permit/terminalizer/attestation types are deleted and must prove those + types/endpoints are absent. + + Both paths require route-set/generation-set plus the stage-specific ownership evidence digest + to match the selected release profile. Direct fence seed SQL outside Flyway, mixed markers, + PRE operation calls in FINAL and stage override all fail. +- [ ] Keep `TestOnlyNotificationWriterEvidenceIssuer` only in the + `notificationQualification` source set. It uses a deterministic local test key and signs the + same canonical inventory, quiescence and DB-birth authorization payloads for repeatable tests; + its manifests are explicitly `LOCAL_TEST`, have a lower evidence grade and cannot satisfy + Task 19 or `notificationProductionReadiness`. Production JAR/config/source sets contain + neither this class nor its private key. The final aggregator accepts production ownership + evidence only from the Task 19 independent issuer client. +- [ ] RED/qualification matrix includes: + claim/finalize contention; provider throttle; callback burst; response loss; DB finalize + outage; key/credential/template rotation; old/new writer HMAC aliases; rolling worker revisions; + park/resume restart; retention/redaction; cancellation/expiry racing wire authorization. +- [ ] Add an actual forked-JVM crash harness, distinct from Task 12's deterministic fault injection. + The parent owns PostgreSQL plus `NotificationAttemptLedgerServer`; each child reports a durable + phase marker and calls `Runtime.halt(91)` at the requested point. A fresh child then runs + recovery while the parent asserts journal state and physical request count. Cover claim, + reserve, immediately before/after committed `WIRE_AUTHORIZED`, possible provider write, + response and finalize; include authorization commit failure and commit-success/result-loss. + No in-process exception may be accepted as process-crash evidence. +- [ ] At every post-authorization ambiguous point, assert request count is at most the exact + card-specific bound, there is no blind retry/fallback, and recovery ends only in an exact + terminal fact, provider reconciliation, or explicit `INDETERMINATE`. +- [ ] Extend the manifest schema/test for the local qualification and final aggregate rows, then run + it GREEN before registering/executing qualification. Every local/provider/DLQ/aggregate row + carries the detector-derived immutable `release_stage`, and the aggregator requires all input + rows to match its own artifact stage. Model ownership evidence as a closed discriminated union: + `PRE.QUIESCENCE` requires only signed BEGIN inventory, signed + quiescence/attestation header + trust snapshot, irreversible node/credential/egress facts, + ACTIVE 0, consumer/ledger 0 and cutover history; `PRE.HARD_BOUND` requires only signed BEGIN + inventory, the all-hard-bound registry/evidence revision, safe terminal permits and ACTIVE 0, + and forbids the attestation. `FINAL.FRESH` requires only cleanup/V8 structural evidence, + discriminator `FRESH_PROVISIONED` with fresh token and null validated-history digest, signed DB + birth certificate, the complete exact Task 9 fresh-provenance field set and + enforcement-read-back → cached-session/flow termination → post-enforcement zero manifest + (provider-ledger entry/open/indeterminate 0) → irreversible fence seal/read-back → signature + causal order, signed fresh provenance/trust snapshot and `INITIALIZE_CANONICAL_FRESH`; + `FINAL.UPGRADE` requires only + cleanup/V8 structural evidence, discriminator `UPGRADE_VALIDATED` with complete retained-history + digest and null fresh token, and forbids fresh provenance. Awaiting, missing, overlapping or + cross-stage/arm evidence fails: + + ```bash + cd src && ./gradlew :app-bootstrap:test \ + --tests '*NotificationEvidenceManifestTest' \ + --tests '*NotificationReleaseStageDetectorTest' \ + --console=plain + ``` + +- [ ] First register `notificationQualification` and + `notificationProductionReadiness` in `src/app-bootstrap/build.gradle`. Keep long load/rotation/ + crash drills out of ordinary `test`/`check`. The first task emits a fresh local qualification + manifest; the second is only an aggregator and performs no provider send. +- [ ] After source-set/task/dependency registration and before executing either task, regenerate and + verify strict lock state: + + ```bash + cd src && ./gradlew :app-bootstrap:resolveAndLockAll \ + --write-locks --console=plain + cd src && ./gradlew :app-bootstrap:verifyDependencyLocks --console=plain + ``` + +- [ ] Run deterministic local qualification: + + ```bash + cd src && ./gradlew :app-bootstrap:notificationQualification --console=plain + ``` + +- [ ] Run real-provider portions only through Task 19 lanes; never embed live credentials/network in + these ordinary tests. +- [ ] Aggregate only fresh, schema-valid manifests for the release-selected exact card set: + the local qualification manifest plus each required Task 19 provider/callback/DLQ manifest. + Compare every frozen axis, source/dependency/artifact/deployed revision and expiry. Missing, + stale, extra, mismatched or skipped evidence fails `notificationProductionReadiness` with + `NOT_QUALIFIED`; never infer evidence from a passing unit test. +- [ ] After both evidence producers have run, execute: + + ```bash + cd src && ./gradlew :app-bootstrap:notificationProductionReadiness \ + --console=plain + ``` + +- [ ] Write runbook actions for backlog, indeterminate, bounce/complaint, provider outage, + credential/key/template rotation, SNS retry/DLQ and route pause/resume. +- [ ] Preserve the explicit no-exactly-once claim and card-specific duplicate risk. +- [ ] Require the structural detector to emit `PRE_CUTOVER_BRIDGE` for every Wave F manifest from the + exact Task 17 bridge artifact; never hardcode that label in a task or test fixture. Aggregator + success makes only that exact bridge artifact eligible for the bounded canary/switch decision; + it is not final R2 evidence because Task 21C changes production source, configuration, + dependencies, release stage and artifact revision. + +**Rollback checkpoint:** qualification itself does not authorize production rollout. Rollout remains +route-specific and human-controlled. + +### Wave F exit gate + +- [ ] Record the exact local/provider manifest digests, expiry, source/artifact revision and every + skipped/not-run lane. Never copy evidence between cards or environments. +- [ ] Request independent durability, provider-protocol, ingress-security and operations review. +- [ ] Update the LLM Wiki branch-note with Wave F evidence and an explicit derived-document decision. + +--- + +## Wave G — Canonical cutover, legacy removal and final verification + +### Task 21: Cut over the canonical graph and remove the R0 legacy path + +**Owner leaves:** application, notification, persistence-jpa, inbound-web, bootstrap +**Depends on:** Tasks 17–20 and route-specific human cutover decision + +**Delete only after `rg` proves production consumer 0 and canonical tests are GREEN:** + +- `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/application-core/src/test/java/dev/caskeleton/application/notification/NotificationPortContractTest.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/NotificationConfig.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/NotificationRoutesSettings.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/core/RoutingNotifier.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/email/google/GoogleEmailClient.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/email/google/GoogleEmailProvider.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/slack/webhook/SlackClient.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/slack/webhook/SlackNotificationAdapterConfig.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/slack/webhook/SlackWebhookProvider.java` +- `src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/core/NotificationAdapterTest.java` +- `src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/core/RoutingNotifierTest.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/catalog/NotificationCutoverRouteCatalog.java` +- `src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/catalog/NotificationCutoverRouteCatalogTest.java` +- `src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationLegacyWriterPermitCommand.java` +- `src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationLegacyWriterPermitResult.java` +- `src/application-core/src/main/java/dev/caskeleton/application/notification/TerminalizeExpiredNotificationWriterPermitsCommand.java` +- `src/application-core/src/main/java/dev/caskeleton/application/notification/TerminalizeExpiredNotificationWriterPermitsResult.java` +- `src/application-core/src/main/java/dev/caskeleton/application/notification/TerminalizeExpiredNotificationWriterPermitsOperation.java` +- `src/application-core/src/main/java/dev/caskeleton/application/notification/TerminalizeExpiredNotificationWriterPermitsUseCase.java` +- `src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationWriterCutoverPort.java` +- `src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationWriterRouteSet.java` +- `src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationWriterQuiescenceAttestationPort.java` +- `src/application-core/src/main/java/dev/caskeleton/application/notification/InitializeNotificationWriterFencesCommand.java` +- `src/application-core/src/main/java/dev/caskeleton/application/notification/InitializeNotificationWriterFencesResult.java` +- `src/application-core/src/main/java/dev/caskeleton/application/notification/InitializeNotificationWriterFencesOperation.java` +- `src/application-core/src/main/java/dev/caskeleton/application/notification/InitializeNotificationWriterFencesUseCase.java` +- `src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationLegacyWriterPermitUseCase.java` +- `src/application-core/src/main/java/dev/caskeleton/application/notification/RecordNotificationWriterQuiescenceAttestationCommand.java` +- `src/application-core/src/main/java/dev/caskeleton/application/notification/RecordNotificationWriterQuiescenceAttestationResult.java` +- `src/application-core/src/main/java/dev/caskeleton/application/notification/RecordNotificationWriterQuiescenceAttestationOperation.java` +- `src/application-core/src/main/java/dev/caskeleton/application/notification/RecordNotificationWriterQuiescenceAttestationUseCase.java` +- `src/application-core/src/main/java/dev/caskeleton/application/notification/SwitchNotificationWriterOwnershipCommand.java` +- `src/application-core/src/main/java/dev/caskeleton/application/notification/SwitchNotificationWriterOwnershipResult.java` +- `src/application-core/src/main/java/dev/caskeleton/application/notification/SwitchNotificationWriterOwnershipOperation.java` +- `src/application-core/src/main/java/dev/caskeleton/application/notification/SwitchNotificationWriterOwnershipUseCase.java` +- `src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationLegacyWriterPermitUseCaseTest.java` +- `src/application-core/src/test/java/dev/caskeleton/application/notification/TerminalizeExpiredNotificationWriterPermitsUseCaseTest.java` +- `src/application-core/src/test/java/dev/caskeleton/application/notification/RecordNotificationWriterQuiescenceAttestationUseCaseTest.java` +- `src/application-core/src/test/java/dev/caskeleton/application/notification/InitializeNotificationWriterFencesUseCaseTest.java` +- `src/application-core/src/test/java/dev/caskeleton/application/notification/SwitchNotificationWriterOwnershipUseCaseTest.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/PostgreSqlNotificationWriterCutoverAdapter.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/PostgreSqlNotificationWriterQuiescenceAttestationAdapter.java` +- `src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/notification/NotificationWriterCutoverIntegrationTest.java` +- `src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/notification/NotificationWriterQuiescenceAttestationIntegrationTest.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/notification/FencedLegacyNotificationPort.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/notification/FencedLegacyNotificationPortTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/notification/NotificationCutoverAuthorizationCompositionTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/notification/NotificationWriterOwnershipCommitAckContractTest.java` +- `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/NotificationWriterOwnershipController.java` +- `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/NotificationWriterFenceInitializationRequest.java` +- `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/NotificationWriterFenceInitializationResponse.java` +- `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/NotificationWriterQuiescenceAttestationRequest.java` +- `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/NotificationWriterQuiescenceAttestationResponse.java` +- `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/NotificationWriterPermitTerminalizationRequest.java` +- `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/NotificationWriterPermitTerminalizationResponse.java` +- `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/NotificationWriterOwnershipRequest.java` +- `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/NotificationWriterOwnershipResponse.java` +- `src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/notification/NotificationWriterOwnershipControllerTest.java` + +**Modify:** + +- `src/.env` +- `src/app-bootstrap/src/test/java/dev/caskeleton/adapter/outbound/OptionalAdapterBeanGatingTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/adapter/outbound/DisabledAdapterSentinelTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/DisabledAdapterArchitectureTest.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/notification/NotificationSettings.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/notification/NotificationCompositionConfig.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/notification/NotificationCompositionValidator.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/notification/NotificationWriterStartupMode.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/notification/NotificationWriterActivationGate.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/notification/NotificationDatabaseRoleSettings.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/notification/NotificationDatabaseRoleComposition.java` +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/notification/NotificationDatabaseRoleTopologyValidator.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/notification/NotificationCompositionTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/notification/NotificationZeroResourceTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/notification/NotificationInternalTrustContextCompositionTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/notification/NotificationWriterActivationGateTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/notification/NotificationCanonicalWriterFenceSetCompositionTest.java` +- `src/app-bootstrap/src/notificationQualification/java/dev/caskeleton/bootstrap/notification/NotificationQualificationOwnershipSetup.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/OptionalAdapterConditionalExecutionContractTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/support/conditional/EnabledIfEmailNotificationConfigured.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/support/conditional/EnabledIfSlackNotificationConfigured.java` +- `src/app-bootstrap/src/main/resources/application.yml` +- `src/app-bootstrap/build.gradle` +- `src/app-bootstrap/gradle.lockfile` +- `src/sample-portfolio/src/main/resources/application.yml` +- `src/adapter/outbound/support/src/test/java/dev/caskeleton/adapter/outbound/support/FailOpenDependencyLoggerTest.java` +- `src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationOperationsSnapshot.java` +- `src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationPortBoundaryTest.java` +- `src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationOperationsSnapshotUseCaseTest.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/NotificationOperationsSnapshotAdapter.java` +- `src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/notification/NotificationOperationsSnapshotIntegrationTest.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/repository/NotificationRouteWriterPermitJpaRepository.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/repository/NotificationWriterOperationJpaRepository.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/repository/NotificationWriterOperationRouteJpaRepository.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/repository/NotificationWriterTransportProofRegistryJpaRepository.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/repository/NotificationWriterQuiescenceAttestationJpaRepository.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/repository/NotificationWriterInventoryManifestJpaRepository.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/repository/NotificationWriterDrainNodeInventoryJpaRepository.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/repository/NotificationWriterQuiescenceManifestJpaRepository.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/repository/NotificationWriterQuiescenceNodeEvidenceJpaRepository.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/repository/NotificationWriterEvidenceTrustSnapshotJpaRepository.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/repository/NotificationFreshInstallationProvenanceJpaRepository.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/repository/NotificationWriterFinalizationDiscriminatorJpaRepository.java` +- `docs/registries/env-keys.yaml` +- `docs/registries/secrets-classification.yaml` +- `src/README.md` +- all affected README/CLAUDE files and the deep design implementation-status section + +**Transitional lifecycle inventory — create in Task 17, qualify in Wave F, then remove after +observation:** + +- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/notification/FencedLegacyNotificationPort.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/notification/FencedLegacyNotificationPortTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/notification/NotificationWriterOwnershipCommitAckContractTest.java` +- `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/NotificationWriterOwnershipController.java` +- `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/NotificationWriterFenceInitializationRequest.java` +- `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/NotificationWriterFenceInitializationResponse.java` +- `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/NotificationWriterQuiescenceAttestationRequest.java` +- `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/NotificationWriterQuiescenceAttestationResponse.java` +- `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/NotificationWriterPermitTerminalizationRequest.java` +- `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/NotificationWriterPermitTerminalizationResponse.java` +- `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/NotificationWriterOwnershipRequest.java` +- `src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/NotificationWriterOwnershipResponse.java` +- `src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/notification/NotificationWriterOwnershipControllerTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/notification/NotificationCutoverAuthorizationCompositionTest.java` + +**Create for 21C fresh canonical installation:** + +- `src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationRetainedWriterEvidenceQuery.java` +- `src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationRetainedWriterEvidenceSnapshot.java` +- `src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationRetainedWriterEvidenceQueryPort.java` +- `src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationRetainedWriterEvidenceVerifierPort.java` +- `src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationRetainedWriterEvidenceVerification.java` +- `src/application-core/src/main/java/dev/caskeleton/application/notification/VerifyRetainedNotificationWriterEvidenceUseCase.java` +- `src/application-core/src/test/java/dev/caskeleton/application/notification/VerifyRetainedNotificationWriterEvidenceUseCaseTest.java` +- `src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationFreshProvisioningAuthorization.java` +- `src/application-core/src/main/java/dev/caskeleton/application/notification/SignedNotificationFreshProvisioningAuthorization.java` +- `src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationFreshProvisioningAuthorizationVerifierPort.java` +- `src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationFreshProvisioningPort.java` +- `src/application-core/src/main/java/dev/caskeleton/application/notification/ProvisionFreshNotificationWriterFencesCommand.java` +- `src/application-core/src/main/java/dev/caskeleton/application/notification/ProvisionFreshNotificationWriterFencesResult.java` +- `src/application-core/src/main/java/dev/caskeleton/application/notification/ProvisionFreshNotificationWriterFencesOperation.java` +- `src/application-core/src/main/java/dev/caskeleton/application/notification/ProvisionFreshNotificationWriterFencesUseCase.java` +- `src/application-core/src/test/java/dev/caskeleton/application/notification/ProvisionFreshNotificationWriterFencesUseCaseTest.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/provisioning/Ed25519NotificationFreshProvisioningAuthorizationVerifier.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/provisioning/NotificationFreshProvisioningTrustCatalog.java` +- `src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/provisioning/Ed25519NotificationFreshProvisioningAuthorizationVerifierTest.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/evidence/Ed25519NotificationRetainedWriterEvidenceVerifier.java` +- `src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/evidence/Ed25519NotificationRetainedWriterEvidenceVerifierTest.java` +- `src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/postgresql/V8__validate_or_prepare_canonical_notification_writer_fence.sql` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/postgresql/evidence/PostgreSqlNotificationRetainedWriterEvidenceQueryAdapter.java` +- `src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/notification/postgresql/evidence/PostgreSqlNotificationRetainedWriterEvidenceQueryAdapterTest.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/postgresql/provisioning/PostgreSqlNotificationFreshProvisioningAdapter.java` +- `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/postgresql/provisioning/PostgreSqlNotificationProvisionerTransactionAdapter.java` +- `src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/notification/postgresql/provisioning/PostgreSqlNotificationFreshProvisioningIntegrationTest.java` +- `src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/notification/postgresql/provisioning/PostgreSqlNotificationProvisionerTransactionAdapterTest.java` +- `src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/notification/NotificationCanonicalFenceInitializationMigrationTest.java` +- `src/app-bootstrap/src/notificationFreshProvisioning/java/dev/caskeleton/bootstrap/notification/NotificationFreshProvisioningCli.java` +- `src/app-bootstrap/src/notificationFreshProvisioning/java/dev/caskeleton/bootstrap/notification/NotificationFreshProvisioningComposition.java` +- `src/app-bootstrap/src/notificationFreshProvisioning/java/dev/caskeleton/bootstrap/notification/NotificationFreshProvisioningSettings.java` +- `src/app-bootstrap/src/notificationFreshProvisioning/java/dev/caskeleton/bootstrap/notification/NotificationProvisionerDataSourceConfig.java` +- `src/app-bootstrap/src/notificationFreshProvisioningTest/java/dev/caskeleton/bootstrap/notification/NotificationFreshProvisioningCliIntegrationTest.java` +- `src/app-bootstrap/src/notificationFreshProvisioningTest/java/dev/caskeleton/bootstrap/notification/NotificationFreshProvisioningSettingsTest.java` +- `src/app-bootstrap/src/notificationFreshProvisioningTest/java/dev/caskeleton/bootstrap/notification/NotificationProvisionerTransactionCompositionTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/notification/NotificationRetainedWriterEvidenceCompositionTest.java` +- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/notification/NotificationRetainedWriterEvidenceStartupTest.java` + +- [ ] Before deletion, run: + + ```bash + rg --hidden -n \ + 'NotificationPort|RoutingNotifier|slack-webhook|google-email|app\.notification\.routes|APP_NOTIFICATION_(SLACK|EMAIL)_PROVIDER|APP_NOTIFICATION_SLACK_WEBHOOK_URL' \ + src docs/registries -g '!**/build/**' -g '!**/.git/**' + ``` + +#### Task 21A — bridge release, no deletion + +- [ ] Freeze and test this per-node/per-database truth table. A single process may never contain both + legacy and canonical keys; rolling nodes may temporarily use different rows only because the + shared database owner/generation makes one side fail closed: + + | phase/node config | legacy keys | canonical expected state/binding | DB owner | legacy admits | canonical admits | + | --- | --- | --- | --- | --- | --- | + | 21A before audited initialization | exact legacy-only | `disabled` / none | absent | no | no | + | 21A bridge | exact legacy-only | `disabled` / none | `LEGACY@g` | yes, with committed permit | no | + | 21B canonical-ready node before switch | absent | `configured` / derived `CUTOVER_WAIT`, exact future set | `LEGACY@g` | no | no | + | 21B old bridge node after switch | exact legacy-only | `disabled` / none | route set `CANONICAL@g_final` | no | no | + | 21B canonical node after switch | absent | `configured` / exact `g_final` set | route set `CANONICAL@g_final` | no | yes | + | 21C cleanup | absent/unknown | canonical-only exact `g_final` set | route set `CANONICAL@g_final` | path absent | yes | + | 21C exact-empty after V8 | absent/unknown | `AWAITING_SIGNED_FRESH_PROVISIONING` | absent | path absent | no | + | 21C after signed fresh provisioning | absent/unknown | canonical-only reviewed initial set | provenance-bound route set `CANONICAL@initial` | path absent | yes | + + Rows are evaluated per route. During 21B, the exact route key set may contain a reviewed mix + of `LEGACY@target-1`, `DRAINING@target-1` and `CANONICAL@target`; owner-mismatched nodes reject + that route without preventing other routes from continuing. + Any same-process legacy+canonical combination fails startup. After 21C every legacy key is + unknown and fails startup. +- [ ] Before bridge admission opens, deploy the exact Wave F-qualified + `PRE_CUTOVER_BRIDGE` artifact, which already contains the inactive transitional operator + controller and exact least-privilege mapping + `notification-operator -> notification:cutover,notification:cutover-terminalize, + notification:cutover-attest`; default `admin` + inherits none. Its + authenticated `INITIALIZE_LEGACY` action derives actor from + `AuthenticatedPrincipal` and calls only the method-security-proxied + `InitializeNotificationWriterFencesOperation`. A human supplies the reviewed initial + predecessor generations (`configured canonical target - 1`) for the server-disclosed exact + route set, reason and operation token; the + operation derives the ordered route set/digest from the compiled catalog. The root + transaction inserts the entire `ACTIVE/LEGACY@predecessor` set only for absent fences + empty + control/data-plane journals and atomically freezes the exact current+retiring proof registry, + is idempotent for the same token/set/registry, fails on + partial/mismatched/nonempty state, and reports success only after physical commit. Direct SQL, + sequential per-route or automatic bootstrap initialization is forbidden. +- [ ] Re-run the already implemented Task 17 `FencedLegacyNotificationPort` tests without changing + the qualified source/artifact. The wrapper calls + `NotificationLegacyWriterPermitUseCase` to reject ambient transactions and root-commit a + bounded permit before provider I/O, then root-commit release afterward. Acquire commit failure + means provider call 0; release failure leaves the lease visible and blocks switch until guarded + recovery/expiry. Canonical guard failure rolls back business state and intent append together. +- [ ] A permit expiry becomes `EXPIRED_PROVEN` only when the catalog transport profile proves an + acquire-committed DB-time absolute wire deadline, network-start refusal after it, connection + close/cancellation by it and + `wire deadline + finalize margin <= permit expiry` in an integration evidence revision, + including acquire-commit→process-pause→expiry→resume call 0. Current R0 is + `QUIESCENCE_REQUIRED`, so timeout becomes `TIMED_OUT_UNPROVEN`; stop 21B until a post-BEGIN + authenticated attestation verifies a trusted signed manifest over the exact BEGIN-frozen + complete old-node inventory, per-node irreversible retirement/credential/egress fencing, + consumer inventory/count 0, + provider-call-ledger identity/open-count 0 and the server-derived persisted-registry/ + TIMED_OUT_UNPROVEN/holder sets. COMPLETE mechanically + requires that token even when the set is empty; never infer call completion from TTL or a + caller digest/runbook checkbox alone. COMPLETE instead requires the already accepted signed + retained header plus exact irreversible deployment-generation/credential/egress facts, + ACTIVE 0 and provider-ledger 0. A crashed/release-failed ACTIVE permit is changed only after BEGIN by + the authenticated `notification:cutover-terminalize` operation; repeat bounded batches until + the read-only snapshot reports ACTIVE 0. Neither snapshot nor COMPLETE performs this mutation. +- [ ] Run bridge RED/GREEN: + + ```bash + cd src && ./gradlew :application-core:test \ + --tests '*NotificationCanonicalWriterFenceGuardTest' \ + --tests '*InitializeNotificationWriterFencesUseCaseTest' \ + --tests '*NotificationLegacyWriterPermitUseCaseTest' \ + --tests '*TerminalizeExpiredNotificationWriterPermitsUseCaseTest' \ + --tests '*RecordNotificationWriterQuiescenceAttestationUseCaseTest' \ + --tests '*SwitchNotificationWriterOwnershipUseCaseTest' \ + --console=plain + cd src && ./gradlew :adapter:outbound:persistence-jpa:test \ + --tests '*NotificationCanonicalWriterFenceIntegrationTest' \ + --tests '*NotificationWriterCutoverIntegrationTest' \ + --tests '*NotificationWriterQuiescenceAttestationIntegrationTest' \ + --tests '*NotificationWriterIrreversibleFenceIntegrationTest' \ + --console=plain + cd src && ./gradlew :app-bootstrap:test \ + --tests '*FencedLegacyNotificationPortTest' \ + --tests '*NotificationCutoverAuthorizationCompositionTest' \ + --tests '*NotificationDatabaseRoleCompositionTest' \ + --tests '*NotificationFlywayRoleIsolationTest' \ + --tests '*MigrationStartupRunnerTest' \ + --tests '*RequiredEnvironmentValidatorTest' \ + --tests '*FlywayMigrationCompatibilityContractTest' \ + --tests '*NotificationWriterOwnershipCommitAckContractTest' \ + --console=plain + cd src && ./gradlew :adapter:inbound:web:test \ + --tests '*NotificationWriterOwnershipControllerTest' --console=plain + ``` + +- [ ] Human gate: after the qualified artifact and operator endpoint are deployed dark, execute the + audited `INITIALIZE_LEGACY` action where the fence is absent; then open bridge admission on + every old/new node, keep `LEGACY@g`, and prove no pre-bridge node remains before continuing. + The initialization cases above must be GREEN before this mutation. + Canonical code stays dark; do not delete any legacy code/config in 21A. Any code/config/lock + change after Wave F invalidates its manifest and requires all Task 19/20 PRE lanes to rerun + before deployment. + +#### Task 21B — route-specific ownership switch and observation + +- [ ] Reuse the Task 17/Wave F-qualified authenticated + batch initialization, expired-permit terminalization, route ownership and route + quiescence-attestation endpoints; do not add or extend production code between PRE + qualification and this switch. None is a public path. + Existing JWT/method-security enforcement requires `notification:cutover` for initialization + and switch, `notification:cutover-terminalize` for terminalization, and + `notification:cutover-attest` for attestation. The thin controller maps the + reviewed generation map/reason/token only to `InitializeNotificationWriterFencesOperation`; + that operation, not request data, supplies the compiled exact route set/digest. The controller + validates exact route/action (`BEGIN_DRAIN|COMPLETE_SWITCH|ABORT_DRAIN`), expected + generation, reason and operation token before mapping switch actions only to + `SwitchNotificationWriterOwnershipOperation`. BEGIN also maps a bounded signed inventory + manifest, never a caller-authored node digest; the application verifier derives and freezes the + exact server-trusted set. It derives the audited actor from + `AuthenticatedPrincipal`, never request data; target/expected owner is not a request field. + Direct SQL/repository access and bootstrap + handlers are forbidden. COMPLETE on a `QUIESCENCE_REQUIRED` route must carry the exact + `quiescenceAttestationToken`; the attestation endpoint maps a signed quiescence manifest, and + other actions/profiles reject those fields. +- [ ] Reconfirm the exact 21A least-privilege role mapping and the already registered distinct + interface-based initializer, terminalizer, attestation and switch targets as method-security + proxied Spring + beans; + the controller has no duplicate permission annotation and cannot obtain the manual internal + delegate. `NotificationCutoverAuthorizationCompositionTest` proves proxy creation, authorized + initializer/terminalizer/attestation/switch success, admin/missing-role 403, internal no-auth + delegates + still work, and + no final-class/proxy startup failure. Controller tests cover unauthenticated 401, validation, + actor spoof rejection and DTO/command mapping only: + + ```bash + cd src && ./gradlew :adapter:inbound:web:test \ + --tests '*NotificationWriterOwnershipControllerTest' --console=plain + cd src && ./gradlew :app-bootstrap:test \ + --tests '*NotificationCutoverAuthorizationCompositionTest' --console=plain + ``` + +- [ ] Re-run the Task 17 app-bootstrap real-PostgreSQL + MockMvc + `NotificationWriterOwnershipCommitAckContractTest`. Prove `BEGIN_DRAIN`, + `TERMINALIZE_EXPIRED_PERMITS`, `COMPLETE_SWITCH`, `ABORT_DRAIN` and `INITIALIZE_LEGACY` commit + failure/rollback never return + 2xx; success response is written only after physical root commit; stale generation conflicts; + and commit-success/result-loss replay with the same operation token is idempotent. Also prove + terminalizer commit/replay/401/403, signed inventory/attestation commit/replay/401/403, + omitted/extra node or permit holder and COMPLETE missing/stale/wrong + token/digest/profile-set failures before 2xx. Pause an old node with cached + credential/client/connection before provider I/O, commit COMPLETE from another transaction, + resume the old node and prove provider I/O 0 under revoked deployment generation, credential + and egress. The inbound leaf never imports persistence to make this claim: + + ```bash + cd src && ./gradlew :app-bootstrap:test \ + --tests '*NotificationWriterOwnershipCommitAckContractTest' --console=plain + ``` + +- [ ] Roll canonical-only config nodes while the shared owner remains `LEGACY@g`; PRE composition + derives `CUTOVER_WAIT`. Those nodes are liveness-healthy but readiness reports + `CUTOVER_WAIT`, and their admission/claim/provider-call counts remain 0 while bridge nodes may + still acquire legacy permits. No node has both config grammars. +- [ ] After canonical-ready nodes are liveness-healthy in `CUTOVER_WAIT`, a human first invokes + `BEGIN_DRAIN` with an independently issued short-lived manifest of the complete + environment/DB/route/artifact old-writer node set. The root transaction verifies its signature, + rejects any known permit holder omitted from the inventory, freezes every node row/count/digest, + and closes new legacy permit acquisition. Poll active permit count/max expiry through + `NotificationOperationsSnapshotUseCase` outside a transaction. The count includes ACTIVE + legacy permits for the route across all old/current fence generations and does not ignore a + row merely because `expires_at` passed; only an exact terminal CAS removes it from the count. + For expired ACTIVE rows, call the authenticated terminalizer in bounded batches. It checks the + exact DRAINING generation and persisted registry, records the affected immutable set in the + operation journal and commits before 2xx; poll again until ACTIVE 0. + For the current `QUIESCENCE_REQUIRED` R0 profile, only after that committed BEGIN and ACTIVE + count 0, obtain an independently signed manifest that lists the exact frozen node set, every + node's retired/quiesced fact plus deployment-generation tombstone and legacy + credential/egress revocation, production consumer inventory/count 0 and provider-call-ledger + identity/open-count 0. Call the authenticated quiescence-attestation endpoint with that opaque + manifest. Its root transaction verifies the issuer/environment/DB/artifact/generation, derives + the catalog-equality-checked persisted profile set and locks/snapshots every-generation + `TIMED_OUT_UNPROVEN` `(token,generation,profile,state,rowVersion)` plus distinct permit-holder + sets, then requires exact node equality/holder subset and commits the derived evidence. + Invoke `COMPLETE_SWITCH` with that exact attestation token; it locks and recomputes the same + sets and rejects missing/mismatched evidence, reversible or absent + tombstone/credential/egress facts, a changed consumer/provider-ledger identity, nonzero ledger + state or any ACTIVE permit. Before either COMPLETE arm, Java reverifies the retained signed + BEGIN inventory canonical payload, signature, issuer public verification material, trust + snapshot, issued/expires/verified acceptance and header/child semantic equality. + QUIESCENCE_REQUIRED additionally reverifies the retained signed attestation bundle. Expiry + controls admission of new signed evidence, while accepted irreversible facts remain durable. + For a genuinely `HARD_BOUND_PROVEN` profile, every permit must instead be + `RELEASED|EXPIRED_PROVEN`, the COMPLETE request forbids an attestation token, and the valid + retained signed BEGIN remains mandatory. + After the applicable proof, invoke `COMPLETE_SWITCH`; its + `inRootWrite` CAS to route-specific `CANONICAL@g_final/ACTIVE` must physically commit before + success is reported. Missing durable evidence or a stale-node resume that can reach a provider + leaves the route DRAINING and makes the rollout `NOT_QUALIFIED`. `ABORT_DRAIN` is the only + rollback operation and emits a new LEGACY + generation, so one or more aborts make `g_final` greater than the naïve `g+1`; none of these + operations waits or sleeps inside the use case. Any abort makes the prior expected-generation + profile and PRE manifests stale. Update + `APP_NOTIFICATION_EXPECTED_WRITER_GENERATIONS`, reproduce the resulting generation set through + the same audited sandbox operations, and rerun all required Task 19/20 PRE lanes before the + next expansion/switch decision. +- [ ] On each committed `COMPLETE_SWITCH`, `NotificationWriterActivationGate` opens only the matching + route on canonical `CUTOVER_WAIT` nodes after a fresh committed read; its notification + readiness becomes ready only when every configured route is at its exact target. Old bridge + nodes observe the canonical owner and keep that route closed. No restart/config watcher may + infer activation before the database fact. +- [ ] Define observation abort thresholds before the switch: any duplicate occurrence, any new + unexpected `INDETERMINATE`, oldest-backlog age over the route SLO, receipt lag over the + callback SLO, non-zero unplanned DLQ depth, or parked-gate count above the reviewed bound pauses + admission and aborts expansion. Do not automatically resend while diagnosing. +- [ ] Keep both code paths packaged during the reviewed observation window, but treat a committed + `COMPLETE_SWITCH` as forward-only. `ABORT_DRAIN` is valid only while the route is + `DRAINING/LEGACY`; there is no CANONICAL→LEGACY CAS or legacy re-enable after COMPLETE. + On a post-COMPLETE threshold breach, close canonical admission and workers through the shared + gate, inventory accepted/indeterminate/in-flight work, avoid replay and forward-fix. Any future + reverse handoff requires a separate canonical-drain/backlog/provider-result protocol, + duplicate policy, design approval and provider requalification. + +#### Task 21C — cleanup release + +- [ ] Before reserving `V8`, rescan every Flyway location; if occupied, use the next global version + and update the plan first. V8 is validation/preparation only and has two closed outcomes: + + - `UPGRADE_VALIDATED`: V8 establishes the Task 9 singleton discriminator in this exact state with a + server-canonical digest of the complete validated retained history, null fresh token and + absent signed fresh provenance. V8 inserts no fence or cutover history and preserves the + existing canonical fence/history/evidence rows byte-for-byte. The persisted route key set + exactly matches the reviewed set; every fence is + `ACTIVE/CANONICAL@g_final`, its latest pointer resolves to a matching committed + `COMPLETE_SWITCH`, and the complete legacy initialization→switch history passes the + structural validation below. A route with no complete history fails even when notification + data happens to be empty. + - `AWAITING_SIGNED_FRESH_PROVISIONING`: only an exact-empty V1..V8 notification + data/control/fence/history/evidence inventory may reach this state. V8 inserts no canonical + fence, provenance or initialization operation and writes a discriminator having neither + fresh token nor validated-history digest. Any nonempty inventory with a missing, partial or + noncanonical fence set fails; an awaiting marker on existing history is tampering. + Application admission, claim, worker and provider resources remain dark. + + Fresh initialization is an explicit post-migration operation, never Flyway lifecycle work. + Register the opt-in `:app-bootstrap:notificationFreshProvisioning` Gradle task backed by + `NotificationFreshProvisioningCli`; `NotificationFreshProvisioningSettings` binds only the + narrow authorization/public-key/provisioner refs listed below, and the CLI composes + `ProvisionFreshNotificationWriterFencesOperation` and the PostgreSQL implementation only in + the dedicated source set and is not attached to `test`, `check`, application startup or + Flyway. Register a separate `notificationFreshProvisioningTest` source set/task for its CLI + integration tests; that test task also stays out of ordinary `test`/`check`. The independent + infrastructure issuer signs a domain-separated + `notification-fresh-provisioning-v1` canonical payload binding authorization nonce/token, + environment, a database birth certificate with DB-system/database/schema identity and birth + token/revision/digest/`committedAt`, final source/artifact digest, exact canonical + route/generation set, and every exact Task 9 + `notification_fresh_installation_provenance` field. The issuer control plane must first + commit/read back the irreversible enforcement revision and all deployment-generation, + credential-issuance/revocation, DB-ingress and provider-egress deny facts; then terminate + cached legacy DB sessions and provider connections/flows; then observe the causally later + post-enforcement workload/business-consumer/node/session/flow zero manifest and provider + ledger settled cut with entry/open/indeterminate counts 0; then seal-commit/read back the + permanent irreversible fence; and only then sign. The canonical payload retains the exact + enforcement/fence canonical payloads, revisions, digests and activation/commit/read-back + times, post-enforcement manifest payload/digest/revision/time, ledger cut revision/time, + credential and denial-policy digests/booleans, + `legacy_database_session_inventory_digest`, + `legacy_database_session_open_count=0`, + `legacy_database_session_termination_evidence_digest`, + `legacy_provider_connection_flow_inventory_digest`, + `legacy_provider_connection_flow_open_count=0`, + `legacy_provider_connection_flow_termination_evidence_digest`, and both + established-flow-block facts. Every source evidence binds the same fence token and + enforcement revision. The independent issuer refuses pre-enforcement zero, cross-revision + composition, sign-before-seal, unsigned, uncommitted or reversible evidence. The retained + provenance stores those canonical + payload bytes, Ed25519 signature, bounded issuer public-key SPKI, key ID/digest, closed trust + snapshot and issued/expires/server-verified times. The signed profile pins + `allowedClockSkew` and `acceptanceMargin`; Java admits it only when + `issuedAt - allowedClockSkew <= serverVerifiedAt <= expiresAt - acceptanceMargin`, verifies the + stored payload/signature/SPKI, and requires the historical-key digest to remain allowed and + non-revoked in the current closed catalog. Unknown/duplicate fields, noncanonical encoding, + algorithm/key downgrade, wrong identity, nonzero authority, missing/reversible fence, stale + birth certificate or token mismatch fail. + + `ProvisionFreshNotificationWriterFencesUseCase` rejects an ambient transaction and owns the + orchestration inside `TransactionPort.inRootWrite`. The dedicated provisioning composition + creates a provisioner-only `DataSource`, `PlatformTransactionManager` and + persistence-owned `PostgreSqlNotificationProvisionerTransactionAdapter` implementing + `TransactionPort`; none is a normal runtime/Flyway bean and the adapter proves its connection + has `current_user=notification_provisioner`. App-bootstrap owns only the dedicated data-source/ + transaction-manager composition and does not implement the transaction adapter. + `NotificationFreshProvisioningPort` exposes exactly two methods: + `snapshotAndReadLock(...)` and `applyVerifiedProvisioning(...)`. In one physical provisioner + connection/transaction the use case (a) calls `snapshotAndReadLock` for the exact + awaiting/empty inventory, server DB clock and DB identity, (b) passes that authoritative + snapshot and signed bytes to + `NotificationFreshProvisioningAuthorizationVerifierPort`, then (c) sends only the verified + facts to `applyVerifiedProvisioning`. The two calls may not use a second connection, nested + transaction, autocommit or a runtime/migrator transaction manager. + `PostgreSqlNotificationFreshProvisioningAdapter`, under `.postgresql.provisioning`, implements + only those structural lock/snapshot/CAS/insert operations; it never calls the verifier or owns + application policy. V8 exposes exactly two migrator-owned `SECURITY DEFINER` functions, + `notification_fresh_provisioning_snapshot_and_lock(...)` and + `notification_fresh_provisioning_apply(...)`, one for each port method. The apply function + proves the first function ran in this same physical transaction by checking its + transaction-local lock/snapshot proof, rechecks that those discriminator/inventory locks are + still held, and compares the first-stage DB-computed snapshot digest. It then rechecks + `AWAITING_SIGNED_FRESH_PROVISIONING`, store emptiness, current DB identity and the canonical + payload semantic digest from authoritative inputs, obtains a fresh `clock_timestamp()` and + rechecks the signed issued/expires/skew/acceptance window. A direct apply without the exact + first-stage lock ownership and snapshot digest fails before DML. It persists that apply-time DB value as + `server_verified_at`; a Java pause that crosses expiry yields mutation 0 even if the first + snapshot was valid. The root transaction inserts immutable signed provenance, exactly one + `INITIALIZE_CANONICAL_FRESH` header with the complete ordered route children, every + `ACTIVE/CANONICAL@initial` fence and CASes the discriminator to `FRESH_PROVISIONED` with its + fresh token and null validated-history digest. The operation returns its result only after + physical commit. A committed same-token/same-payload replay is a read-only result-recovery + branch: both functions lock/recompute the retained discriminator/provenance/init/fence + equality, Java reverifies the stored signature/trust/semantic facts and original + `server_verified_at` acceptance, and apply returns the persisted result without DML. It does + not apply current wall-clock expiry to that already accepted irreversible fact. A missing + result, different token/input/identity/digest, partial state or attempted new mutation must + take the fresh-time new-mutation branch or fail closed. Thus commit-success/result-loss is + recovered without a second initialization. Startup + remains dark until this transaction is durably committed, then a fresh startup re-verifies the + retained signed provenance in Java and may enter `REQUIRE_CANONICAL`. SQL enforces only + structural shape/count/digest/FK/immutability; it is never the Ed25519 authority. Authorization + expiry after accepted provisioning does not reverse the committed provenance or fences. + + Before V8, external DB-admin/IaC has already created the exact three-role set from Task 9. + V8 validates that set, ownership and grants and never executes `CREATE ROLE` or creates the + principal running itself. `notification_migrator` owns the schema, Flyway + history, migration objects and the two narrowly scoped provisioning functions. + `notification_runtime` is a nonowner. `notification_provisioner` has no table DML, sequence, + ownership, role-membership or DDL privilege and receives only `EXECUTE` on those exact two + functions. Both `SECURITY DEFINER` functions are migrator-owned, use + `SET search_path = pg_catalog` plus fully qualified objects, contain no dynamic SQL or + caller-selected object/action, check the exact caller role and lock their state/token/payload + rows; `PUBLIC` and runtime execute are revoked. PRE runtime retains only its separately + enumerated transitional `EXECUTE` grants until FINAL, when V8/cleanup revokes them. Tests reject + owner substitution, inherited membership, search-path shadowing and direct DML/sequence access. + Apply becomes state-closed after success; retained same-token replay is the only allowed result + path. + + On upgrade, replay the full causal journal by the operation and attestation values allocated + from the same DB sequence after the global/route fence lock: + `INITIALIZE_LEGACY -> (BEGIN -> TERMINALIZE* -> ABORT)* -> + BEGIN -> TERMINALIZE* -> COMPLETE`. Each non-init child has the exact drain-BEGIN FK; + expected/result owner/state/generation matches the closed matrix, terminalizer is unchanged + DRAINING, and COMPLETE is the last mutation. CANONICAL→BEGIN/TERMINALIZE/ABORT/ + second-COMPLETE, cross-table sequence collision/reversal, missing predecessor or + replay/fence/latest-pointer mismatch fails. `recorded_at`, `terminalized_at` and `observed_at` + must be post-lock `clock_timestamp()` values but are only sanity evidence; sequence/FK is the + causal SSOT. + + Reject orphan header/child, empty child set, action mismatch and any server-canonical + `route_set_digest`/`request_input_digest` recomputation mismatch, including terminalizer batch + bound. Both INITIALIZE actions have the exact reviewed all-route child set; every non-init + header has exactly one child. The retained immutable transport-proof registry, not the deleted + PRE catalog or caller digest, is upgrade proof authority: exact route/current+retiring profile + set, one ACTIVE profile, shared route digest and initialization-child FK; every frozen permit, + attestation and COMPLETE profile/proof/evidence/digest matches it. ACTIVE permits fail. + All-HARD_BOUND history requires the retained signed BEGIN header, an exact all-hard-bound + registry/evidence revision and permits only `RELEASED|EXPIRED_PROVEN`; ACTIVE permit 0 is + mandatory and quiescence attestation/children are forbidden. + + Every terminal permit composite-references its exact unchanged-DRAINING terminalizer child, + satisfies `expires_at <= terminalized_at` and + `BEGIN.operation_sequence < terminalizer.operation_sequence < + first-closing.operation_sequence`, and participates in the recomputed affected count/set + digest. QUIESCENCE_REQUIRED history additionally requires: BEGIN's signed complete-node + count/set/manifest digest exactly equals every immutable node row; all distinct permit holders + are included; attestation references that BEGIN and exact registry/permit/holder/node sets plus + independently signed per-node irreversible retirement/credential/egress fencing, consumer + inventory/count 0 and provider-call-ledger identity/open-count 0. The retained inventory and + quiescence/attestation headers and trust snapshots structurally bind the canonical signed + payload/signature, issuer public-key SPKI/key digest, issued/expires/verified fields and exact + environment/DB/artifact/inventory/ledger identities. Causal validity is + `BEGIN.operation_sequence < attestation.attestation_sequence < + COMPLETE.operation_sequence`, not a pre-commit timestamp. Current cleanup time is irrelevant. + A superseded/unselected attestation is allowed only with the same BEGIN and sequence before + the first closing ABORT/COMPLETE. Missing/extra/forged inventory, holder, attestation, + irreversible fact, provider-ledger snapshot or sequence fails. V8 performs structural + validation only; before activation Java re-verifies every stored payload/signature/SPKI, + applies the historical-key allow/non-revoked decision from the current closed catalog and + rejects a malformed acceptance window. Evidence accepted inside its pinned window remains + durable after expiry. Preserve fence, registry, permit, node inventory, per-node quiescence + evidence, signed headers/trust snapshots, attestation, provenance and operation rows + byte-for-byte. +- [ ] Implement the retained FINAL read path as a separate bounded application contract. + `NotificationRetainedWriterEvidenceQuery` supplies only expected environment/DB/artifact, + canonical route/generation set and reviewed per-collection bounds; + `NotificationRetainedWriterEvidenceQueryPort` returns one immutable + `NotificationRetainedWriterEvidenceSnapshot`. + `PostgreSqlNotificationRetainedWriterEvidenceQueryAdapter` performs read-only, deterministic + ordered reads and fails on truncation, extra rows, duplicate identities or any bound breach. + For `FRESH_PROVISIONED` it reads the discriminator, fresh provenance, exact + `INITIALIZE_CANONICAL_FRESH` header/children and exact canonical fence set. For + `UPGRADE_VALIDATED` it reads the discriminator plus the complete operation/route-child, + transport-proof registry, permit, BEGIN inventory/header/child, quiescence/attestation + header/child, trust-snapshot and exact canonical-fence snapshot needed to recompute the stored + validated-history digest. Awaiting or an arm overlap is never activation evidence. + `VerifyRetainedNotificationWriterEvidenceUseCase` is the only application-facing verifier. It + passes each bounded canonical payload/signature/SPKI/trust bundle to + `NotificationRetainedWriterEvidenceVerifierPort`; + `Ed25519NotificationRetainedWriterEvidenceVerifier` owns canonical decoding, Ed25519 + verification, issuer SPKI/digest, pinned trust snapshot and current closed-catalog + allow/non-revoked checks, and returns only bounded typed verified facts. The use case owns + branch policy: it compares those facts for exact semantic equality with the relational + projection, then checks discriminator XOR/token/history digest and exact catalog/generation + equality. Neither adapter makes branch/activation decisions, and the inventory-only + `NotificationWriterInventoryEvidenceVerifierPort` is not reused as if it covered fresh + provenance or the full FINAL snapshot. + App-bootstrap startup and readiness inject only this use case; composition/startup tests forbid + direct injection/import of a retained repository, JPA entity, `EntityManager` or JDBC type and + prove both exact arms activate only after successful Java verification. +- [ ] Register the following provisioning-source-set-only inputs; none is a + `NotificationSettings` field or normal runtime/Flyway input: + + | env key | purpose | classification | + | --- | --- | --- | + | `APP_NOTIFICATION_FRESH_PROVISIONING_AUTHORIZATION_REF` | externally issued signed DB-birth + committed irreversible-fence authorization bytes | sensitive reference | + | `APP_NOTIFICATION_FRESH_PROVISIONING_ISSUER_PUBLIC_KEY_REFS` | bounded verifier public-key SPKI refs matching the closed trust catalog | public verification-material refs | + | `APP_NOTIFICATION_DB_EXPECTED_PROVISIONER_ROLE` | exact callable principal | fixed `notification_provisioner` | + | `APP_NOTIFICATION_DB_PROVISIONER_USERNAME_REF` | dedicated provisioner username reference | sensitive reference | + | `APP_NOTIFICATION_DB_PROVISIONER_PASSWORD_REF` | dedicated provisioner password reference | sensitive reference | + + The CLI never logs or retains credentials in settings/application records/entities and wipes + only adapter-facing mutable copies on close. Do not claim end-to-end erasure: current + `SecretSource`/`EnvironmentSecretSource` and JDBC username/password APIs necessarily create + unavoidable short-lived immutable Java `String` values. Minimize copies and lifetime, run the + CLI as a dedicated forked process, assert `current_user`, immediately close the provisioner + `DataSource` and process after commit/failure, prohibit heap dumps for that process, rotate + short-TTL credentials, and prefer workload identity or certificate authentication where the + JDBC/runtime platform supports it. Tests cover no logging/exception/settings/entity retention, + minimum bridge copies, immediate close and mutable-copy wipe; residual JVM `String` exposure is + explicitly recorded rather than represented as wiped. The CLI carries no signing capability: + a production private key is forbidden in source, artifact, environment registry, test resource + and material source. Supplying these refs to an upgrade/runtime process, omitting one for + explicit fresh provisioning, sharing provisioner/runtime/migrator credentials or mismatching + the pinned SPKI digest fails closed. Retained provenance keeps the canonical payload, signature + and public verification/trust snapshot needed for future Java re-verification, never a private + key or database password. +- [ ] After registering `notificationFreshProvisioning` and + `notificationFreshProvisioningTest` with their exact application/outbound/persistence + classpaths, make the production provisioning configurations inherit only app-bootstrap's + already-governed main `implementation`/`runtimeOnly` configurations and its test + configurations inherit only the governed test configurations. Add an exact project-edge + assertion that scans every provisioning production/test configuration and rejects any direct + or inherited project dependency outside app-bootstrap's registry allowlist. + Register a non-mutating `notificationFreshProvisioningCheck` aggregate that compiles the + production provisioning source set with the repository's Java/Error Prone/static-analysis + policy and runs the project-edge and dependency/lock assertions, but does not run + `notificationFreshProvisioningTest`, invoke the provisioning CLI or reach a database. The + separately invoked `notificationFreshProvisioningTest` owns the Testcontainers/CLI integration + cases. Keep both outside ordinary `check` while requiring both explicitly in Task 22. + Regenerate and verify the app-bootstrap lock before compiling or invoking either task: + + ```bash + cd src && ./gradlew :app-bootstrap:resolveAndLockAll \ + --write-locks --console=plain + cd src && ./gradlew :app-bootstrap:verifyDependencyLocks --console=plain + cd src && ./gradlew :app-bootstrap:notificationFreshProvisioningCheck --console=plain + ``` + +- [ ] RED/GREEN real-PostgreSQL migration cases: + a clean V1..V8 two-route database ends only in + `AWAITING_SIGNED_FRESH_PROVISIONING`, with fence/provenance/initialization rows 0 and application + provider resources 0. Invoking `notificationFreshProvisioning` with valid authorization then + creates the complete reviewed canonical initial set, exact retained provenance and one + `INITIALIZE_CANONICAL_FRESH` batch in one transaction and transitions the discriminator to + `FRESH_PROVISIONED` with fresh token present/validated-history digest absent. A valid canonical + upgrade ends only in `UPGRADE_VALIDATED` with complete-history digest present/fresh token and + provenance absent; every XOR/state violation is rejected. V8 rejects every nonempty/partial + notification inventory without an exact canonical fence set; the operation rejects + manually inserted/spoofed/expired/wrong-DB/wrong-environment/wrong-artifact/wrong-route-set/ + nonzero-node, nonzero-consumer or nonzero provider-ledger entry/open/indeterminate-count + authorization, missing/wrong DB birth certificate, every missing/mutated Task 9 enforcement, + post-enforcement zero-manifest, fence-read-back, credential, denial-policy, session or + connection-flow axis, and awaiting-state mutation. Prove the external issuer refuses + pre-enforcement zero, cross-revision evidence and signing before the exact enforcement → + termination → post-enforcement settled-zero → permanent fence seal/read-back chain completes. + Pause/inject an old deployment generation, + cached legacy DB/provider credential, established DB session/provider connection and legacy + egress path before provisioning; require both signed inventory digests/open counts 0, + termination evidences and ingress/egress established-flow block facts, mutate each exact field + independently as a RED case, then prove legacy DB I/O and provider I/O are both 0 before and + after provisioning and after resume. Prove boundary failures around both allowed + clock skew and acceptance margin. Assert both port calls/functions use the same + physical connection/root transaction; pause Java verification beyond expiry and require apply + mutation 0, invoke the apply function directly after expiry and require mutation 0, and cover + rollback after each write stage plus commit-success/result-loss recovery. A failed provisioning + transaction leaves awaiting state unchanged; same-token/same-payload retry is exact and + mismatch retry fails rather than reclassifying a partial database or blessing an upgrade. + A committed same-token replay after current authorization expiry reverifies the stored + signature/trust/semantic facts and original `server_verified_at`, returns the stored result + with DML 0 and never refreshes acceptance time. Direct apply without the first-stage + transaction-local lock/snapshot proof fails with mutation 0. Also invoke both functions + directly outside the CLI/use-case seam with forged signature or + forged typed facts: regardless of any structurally written row, FINAL startup/readiness Java + verification must detect payload/SPKI/trust/semantic inequality and remain dark/ + `NOT_QUALIFIED`. A valid signed authorization may reach direct apply only when every + apply-time DB-clock/fence/state/identity condition still holds, and provider I/O remains 0 + until a subsequent startup successfully re-verifies the retained evidence. + A multi-route upgrade with different valid `g_final` values preserves every fence and + proof-registry/permit/node-inventory/quiescence-node-evidence/signed-header/trust-snapshot/ + attestation/ + operation-history row + byte-for-byte. Include a valid + multi-profile + QUIESCENCE_REQUIRED upgrade whose attestation is expired now but was admitted inside its signed + acceptance window and whose irreversible facts remain valid. + Include successful re-attestation after a permit row-version change, and + BEGIN→attestation→ABORT→new BEGIN→new attestation→COMPLETE history; the superseded rows remain + byte-identical. BEGIN→ABORT→forged late attestation and + BEGIN→COMPLETE→forged late unselected attestation fail without mutation. + A pre-BEGIN-expired old-generation ACTIVE permit terminalized after BEGIN then completed passes + V8; expiry is not required to follow BEGIN. + Add rogue histories that end canonical but contain + COMPLETE→BEGIN→ABORT→BEGIN→COMPLETE, terminalizer outside DRAINING, sequence + duplicate/cross-table-collision/reversal or a missing drain-BEGIN predecessor; each fails + without mutation. Start a terminalizer transaction before BEGIN and release its fence wait + after BEGIN; post-lock `clock_timestamp()` and later sequence must make this valid, proving + transaction-start time is not used. Add orphan + header/child, empty/extra/partial init child set, zero/two-child non-init header, route-set + digest, request-input digest and stored batch-bound corruption fixtures. + Missing/extra/partial route sets, LEGACY/DRAINING/mixed owners, latest-COMPLETE mismatch, + ACTIVE old-generation permit, stale-at-COMPLETE/missing/wrong token or set/profile digest, + unknown/omitted/extra registry profile, tampered proof class/evidence revision/registry digest, + either invalid proof-class/state pairing, orphan/wrong-action/wrong-route/wrong-set + terminalization, terminalized-before-expiry/after-close, omitted/extra inventory node or permit + holder, unsigned/wrong-issuer/wrong-identity quiescence manifest, missing/extra/wrong-action/ + wrong-attestation/wrong-profile, malformed stored payload/signature/SPKI/trust snapshot, + revoked historical key, invalid issued/expires/verified window, nonzero facts, BEGIN-less + attestation-only nonempty + store and every other + nonempty-without-fence fixture all fail without mutation. An app-bootstrap composition test + also proves exact equality between the compiled canonical route catalog, + `APP_NOTIFICATION_EXPECTED_WRITER_GENERATIONS` and the persisted fence set. The retained-reader + tests cover bounded/truncated/extra child snapshots, both valid arms, every semantic + payload-to-row mismatch, malformed/revoked SPKI/trust state, discriminator XOR and digest + mismatch, and prove bootstrap has no direct repository/entity access: + + ```bash + cd src && ./gradlew :application-core:test \ + --tests '*ProvisionFreshNotificationWriterFencesUseCaseTest' \ + --tests '*VerifyRetainedNotificationWriterEvidenceUseCaseTest' --console=plain + cd src && ./gradlew :adapter:outbound:notification:test \ + --tests '*Ed25519NotificationFreshProvisioningAuthorizationVerifierTest' \ + --tests '*Ed25519NotificationRetainedWriterEvidenceVerifierTest' \ + --console=plain + cd src && ./gradlew :adapter:outbound:persistence-jpa:test \ + --tests '*PostgreSqlNotificationFreshProvisioningIntegrationTest' \ + --tests '*PostgreSqlNotificationProvisionerTransactionAdapterTest' \ + --tests '*PostgreSqlNotificationRetainedWriterEvidenceQueryAdapterTest' \ + --tests '*NotificationRetainedEvidenceNoSaveArchitectureTest' \ + --tests '*NotificationWriterFinalizationDiscriminatorIntegrationTest' \ + --tests '*NotificationCanonicalFenceInitializationMigrationTest' --console=plain + cd src && ./gradlew :app-bootstrap:test \ + --tests '*NotificationCanonicalWriterFenceSetCompositionTest' \ + --tests '*NotificationRetainedWriterEvidenceCompositionTest' \ + --tests '*NotificationRetainedWriterEvidenceStartupTest' \ + --tests '*NotificationDatabaseRoleCompositionTest' \ + --tests '*NotificationFlywayRoleIsolationTest' \ + --console=plain + cd src && ./gradlew :app-bootstrap:notificationFreshProvisioningTest \ + --tests '*NotificationFreshProvisioningCliIntegrationTest' \ + --tests '*NotificationFreshProvisioningSettingsTest' \ + --tests '*NotificationProvisionerTransactionCompositionTest' --console=plain + cd src && ./gradlew :app-bootstrap:notificationFreshProvisioningCheck --console=plain + ``` + +- [ ] Only after the human observation gate passes, delete the listed legacy and bridge files. + Remove legacy selector rows from `src/.env`, both application YAML files and `env-keys.yaml`; + remove `APP_NOTIFICATION_SLACK_WEBHOOK_URL` from the secret registry only after the legacy + webhook code and all references are gone. Update disabled/sentinel/conditional/fail-open tests + and root documentation in the same RED/GREEN step. +- [ ] Delete the transitional operator controller/DTO/test, proxied switch operation/use case, + legacy permit, expired-permit terminalizer and quiescence-attestation + write port/operation/use-case/write adapter, + bridge and the `notification:cutover`/`notification:cutover-terminalize`/ + `notification:cutover-attest` operator mappings in the + same cleanup release. Delete + `NotificationWriterCutoverPort`, `PostgreSqlNotificationWriterCutoverAdapter`, + `PostgreSqlNotificationWriterQuiescenceAttestationAdapter` and their integration tests; remove + ACTIVE/TIMED_OUT_UNPROVEN permit, attestation and max-expiry fields/read paths from + `NotificationOperationsSnapshot`, its application test and persistence adapter/test. Keep the + additive transport-proof-registry/permit/operation/BEGIN-node-inventory/ + quiescence-node-evidence/signed inventory/quiescence/attestation headers, trust snapshots, + fresh-provenance/finalization-discriminator table history, all corresponding + entities and repositories as retained projections, and + `PostgreSqlNotificationRetainedWriterEvidenceQueryAdapter`; these are not deletion targets. + Keep the Task 9 never-Java-written repositories on their original marker-only contract. + After deleting write adapters, narrow every remaining retained transitional Spring Data + repository to the marker `Repository` plus only explicitly bounded read methods; none may extend + `CrudRepository`/`JpaRepository` or declare `save`, `saveAll`, `delete` or `flush`. + `NotificationRetainedEvidenceNoSaveArchitectureTest` scans this contract and the read adapter + for a write surface. + Keep the separate + `NotificationCanonicalWriterFencePort`/adapter/guard, retained inventory and FINAL Ed25519 + evidence verifiers and + explicit fresh-provisioning operation, but + expose no runtime operation capable of selecting `LEGACY` or mutating retained audit rows; + configured startup requires the persisted owner to be the exact canonical generation. Remove + every bridge/permit/terminalizer/proxied-switch/controller-support bean from + `NotificationCompositionConfig` and update composition, zero-resource and trust-context tests + so no deleted transitional type remains reachable. FINAL grants revoke generic + `INSERT|UPDATE|DELETE` on retained cutover audit/control tables, cutover-sequence use and every + PRE transitional function `EXECUTE` from runtime/PUBLIC; runtime retains only exact SELECT and + fence read-lock access there. This does not revoke the exact DML/SELECT needed for the active + intent/claim/finalize/receipt operational journal. The provisioner retains only the exact + two state-closed provisioning-function `EXECUTE` grants, never direct audit-table DML or + sequence access. App-bootstrap may reach retained evidence only through + `VerifyRetainedNotificationWriterEvidenceUseCase`; repository/entity injection or direct JDBC + is a composition-test failure. +- [ ] Rewrite `NotificationQualificationOwnershipSetup` in the cleanup source tree as a FINAL-only + migration/validated-fence-set setup. It must not import, reflectively load or string-reference + any deleted initializer/switch/permit/terminalizer/attestation/controller type. Run the + compile/test after + deletion so Task 22 can requalify the final artifact without a PRE-only setup path. Its fresh + lane must run V8 to `AWAITING_SIGNED_FRESH_PROVISIONING` and invoke only the signed + `notificationFreshProvisioning` Gradle/CLI path, ending only in `FRESH_PROVISIONED` with the + retained DB birth certificate/no-legacy-authority fence and fresh-token XOR arm; its upgrade + lane may only validate an existing complete canonical history and end in + `UPGRADE_VALIDATED` with the retained-history-digest XOR arm. +- [ ] Delete every `CUTOVER_WAIT` and PRE production branch from `NotificationWriterStartupMode`, + settings, composition, activation gate, YAML/env registry and tests. Retain only the closed + FINAL modes `AWAITING_SIGNED_FRESH_PROVISIONING|REQUIRE_CANONICAL`. + `NotificationWriterActivationGate` keeps admission/claim/call 0 while awaiting; in + `REQUIRE_CANONICAL`, absent, predecessor, DRAINING, partial, extra or wrong-generation fences + fail startup and admit/claim/call 0. Both fresh and upgrade startup re-read the retained signed + headers/provenance through `VerifyRetainedNotificationWriterEvidenceUseCase`, require the + matching actual finalization-discriminator arm, and reverify payload/signature/SPKI, semantic + equality plus current historical-key status before activation. Awaiting, unknown, + discriminator/provenance/history overlap or direct-SQL forged state stays dark. + Detector fixtures may retain the explicit marker only under their exact verification + allowlist; remove `APP_NOTIFICATION_CUTOVER_ATTESTATION_TTL` and rename the PRE public verifier + input to `APP_NOTIFICATION_RETAINED_EVIDENCE_ISSUER_PUBLIC_KEY_REFS`. Retain that read-only + runtime trust input and the provisioning-source-set-only authorization/public-key/provisioner + references in their narrow registry paths. The compiled runtime configuration contains no + CUTOVER_WAIT, terminalizer or attestation-write support, but does retain read-only signed + evidence verification. +- [ ] Delete PRE-only `NotificationCutoverRouteCatalog` and transitional + `NotificationWriterRouteSet` with all legacy alias/transport-proof consumers. Final + composition retains only + `NotificationCanonicalRouteCatalog -> NotificationCanonicalWriterRouteSet` plus the exact + runtime target-generation map; its key set must match V8 and persisted fences. Detector tests + require executable legacy alias/proof classes/beans/config 0 while allowing immutable V7 + proof-registry history and V8 SQL validation references. +- [ ] Remove `spring-web` direct dependency if no new notification code uses it, regenerate only + affected locks, and re-run dependency verification. +- [ ] Never backfill old generic outbox/log events or automatically resend accepted/indeterminate + legacy occurrences. +- [ ] Verify the 21C canonical-only artifact: + + ```bash + cd src && ./gradlew :app-bootstrap:test \ + --tests '*Notification*' \ + --tests '*DisabledAdapterSentinelTest' \ + --tests '*OptionalAdapter*' \ + --console=plain + cd src && ./gradlew :application-core:check \ + :adapter:outbound:notification:check \ + --console=plain + cd src && ./gradlew verifyEnvKeys \ + verifyDependencyLocks \ + verifyCleanArchitectureDependencies \ + --console=plain + ``` + +- [ ] Acceptance: exactly one canonical activation graph remains; legacy names have no production + source/config consumer and occur only in the reviewed release-stage detector/test allowlist; + optional webhook/Gmail/SMTP can return only as separately designed exact cards. The cleanup + artifact remains `NOT_QUALIFIED` until Task 22 final-artifact requalification. + +**Rollback checkpoint:** after legacy deletion, do not restore it for rows already accepted or +indeterminate. Pause admission, retain schema/revisions and forward-fix unless an exact route inventory +proves zero duplicate risk. + +### Task 22: Synchronize truth, run full gates, independent review and LLM Wiki capture + +**Owner:** repository-wide verification/documentation +**Depends on:** all preceding tasks required by the selected release scope + +**Files — modify:** + +- `docs/superpowers/specs/2026-07-28-notification-production-capability-design.md` +- `docs/superpowers/plans/2026-07-28-notification-production-capability.md` +- `src/application-core/README.md` +- `src/application-core/CLAUDE.md` +- `src/adapter/outbound/notification/README.md` +- `src/adapter/outbound/notification/CLAUDE.md` +- `src/adapter/outbound/persistence-jpa/README.md` +- `src/adapter/outbound/persistence-jpa/CLAUDE.md` +- `src/adapter/inbound/web/README.md` +- `src/adapter/inbound/web/CLAUDE.md` +- `src/app-bootstrap/README.md` +- `src/app-bootstrap/CLAUDE.md` +- `docs/runbooks/notification.md` +- `docs/runbooks/notification-database-role-bootstrap.md` +- LLM Wiki branch-note and only genuinely derived raw documents + +- [ ] Update implementation status from actual source/test/evidence only. Keep every unexecuted + provider/load/rotation lane visibly `NOT_QUALIFIED`. +- [ ] Run focused owner gates first: + + ```bash + cd src && ./gradlew :application-core:check \ + :adapter:outbound:notification:check \ + :adapter:outbound:persistence-jpa:check \ + :adapter:inbound:web:check \ + :app-bootstrap:check \ + --console=plain + ``` + +- [ ] Run the exact database-role/provisioning gates and require + exactly three roles: `notification_migrator` owner/Flyway-only, + `notification_runtime` nonowner with exact FINAL operational grants and every PRE transitional + `EXECUTE` revoked, and `notification_provisioner` execute-only on exactly + `notification_fresh_provisioning_snapshot_and_lock` and + `notification_fresh_provisioning_apply`. Any additional notification-scoped + owner/member/grantee, direct provisioner DML/sequence privilege or PUBLIC/runtime execution + of either function fails. Require the external DB-admin/IaC bootstrap revision/digest and + prove V7/V8 contain no role creation: + + ```bash + cd src && ./gradlew :adapter:outbound:persistence-jpa:test \ + --tests '*NotificationDatabaseRoleIsolationIntegrationTest' \ + --tests '*PostgreSqlNotificationFreshProvisioningIntegrationTest' \ + --tests '*PostgreSqlNotificationProvisionerTransactionAdapterTest' \ + --tests '*PostgreSqlNotificationRetainedWriterEvidenceQueryAdapterTest' \ + --tests '*NotificationRetainedEvidenceNoSaveArchitectureTest' \ + --tests '*NotificationWriterFinalizationDiscriminatorIntegrationTest' \ + --console=plain + cd src && ./gradlew :app-bootstrap:test \ + --tests '*NotificationDatabaseRoleCompositionTest' \ + --tests '*NotificationFlywayRoleIsolationTest' \ + --tests '*NotificationRetainedWriterEvidenceCompositionTest' \ + --tests '*NotificationRetainedWriterEvidenceStartupTest' \ + --console=plain + cd src && ./gradlew :app-bootstrap:notificationFreshProvisioningTest \ + --tests '*NotificationFreshProvisioningCliIntegrationTest' \ + --tests '*NotificationFreshProvisioningSettingsTest' \ + --tests '*NotificationProvisionerTransactionCompositionTest' --console=plain + cd src && ./gradlew :app-bootstrap:notificationFreshProvisioningCheck --console=plain + ``` + +- [ ] Run repository gates: + + ```bash + cd src && ./gradlew test --console=plain + cd src && ./gradlew check --console=plain + cd src && ./gradlew verifyDependencyLocks --console=plain + cd src && ./gradlew verifyCleanArchitectureDependencies --console=plain + cd src && ./gradlew verifyPublicPathSnapshot --console=plain + cd src && ./gradlew verifyEnvKeys --console=plain + ``` + +- [ ] Run documentation/source hygiene: + + ```bash + git diff --check + if rg --hidden -n 'slack-webhook|google-email|\bNotificationPort\b|\bRoutingNotifier\b|APP_NOTIFICATION_SLACK_WEBHOOK_URL' \ + src docs/registries \ + -g '**/src/main/**/*.java' \ + -g '**/src/main/**/*.yml' \ + -g '**/src/main/**/*.yaml' \ + -g '**/src/main/**/*.properties' \ + -g '**/build.gradle' -g '.env' -g '*.yml' -g '*.yaml' \ + -g '!**/build/**' -g '!**/.git/**'; then + echo 'legacy notification production consumer/config references remain' >&2 + exit 1 + fi + cd src && ./gradlew :app-bootstrap:test \ + --tests '*NotificationReleaseStageLegacyMarkerAllowlistTest' \ + --tests '*NotificationReleaseStageDetectorTest' \ + --console=plain + ``` + +- [ ] Perform independent reviews for: + Clean Architecture/module boundary; transaction/durability/concurrency; callback/security; + provider protocol; privacy/operations. Completion requires blocker 0 and high 0. Any unresolved + blocker/high finding keeps this task incomplete and the card `NOT_QUALIFIED`. +- [ ] Freeze the final cleanup source tree, docs and dependency locks. Because commits are + human-only, a human creates the candidate commit; CI builds the final artifact from that exact + commit and records its artifact digest. The agent never stages, commits or pushes. +- [ ] Treat every Task 19/20 `PRE_CUTOVER_BRIDGE` manifest as stale for this final artifact. Deploy + the exact cleanup artifact to the isolated sandbox. Before any lane, require + `NotificationReleaseStageDetector` to derive `FINAL_CLEANUP` from the built artifact; a + caller-supplied stage, PRE, mixed or unknown inventory fails. Re-run both FINAL ownership setup + variants first. `FRESH` runs V1..V8 to `AWAITING_SIGNED_FRESH_PROVISIONING`, proves every + runtime side effect 0, commits the external permanent no-legacy-authority fence, and only then + obtains a production issuer-signed database-birth authorization and invokes the explicit task + below. It must finish with actual discriminator `FRESH_PROVISIONED`, fresh token present and + validated-history digest absent; wrong/absent authorization or an + unsigned/uncommitted/reversible fence remains dark. Its retained evidence includes the DB + birth certificate and the complete exact Task 9 fresh-provenance field set and causal order: + enforcement commit/read-back, cached DB-session/provider-flow termination, causally later + post-enforcement zero manifest with provider-ledger entry/open/indeterminate counts 0, then + irreversible fence seal/read-back before signature. A paused old generation with cached + credential/session/connection proves legacy DB I/O and provider I/O are both 0 before and + after provisioning. `UPGRADE` validates complete canonical history byte-for-byte without fresh + provenance, never invokes the task and must finish with actual discriminator + `UPGRADE_VALIDATED`, complete-history digest present and fresh token absent. Both paths call + `VerifyRetainedNotificationWriterEvidenceUseCase` to reverify retained payload/signature/SPKI, + semantic equality and closed-catalog historical-key status in Java before activation; + bootstrap reads no repository/entity directly: + + ```bash + # FRESH ownership setup only; never run on UPGRADE: + cd src && ./gradlew :app-bootstrap:notificationFreshProvisioning --console=plain + cd src && ./gradlew :app-bootstrap:notificationQualification --console=plain + cd src && ./gradlew :app-bootstrap:notificationSlackReadiness --console=plain + cd src && ./gradlew :app-bootstrap:notificationSesReadiness --console=plain + # Required only when the release-selected SES card requires a fresh scheduled/manual DLQ drill: + cd src && ./gradlew :app-bootstrap:notificationSesDlqReadiness --console=plain + cd src && ./gradlew :app-bootstrap:notificationProductionReadiness --console=plain + ``` + + The final aggregator compares the human candidate source commit, production-source/dependency + digest, deployed artifact digest, detector-derived `FINAL_CLEANUP` stage and every exact + card/topology axis. It must reject all bridge manifests, any hardcoded/caller-overridden stage + and any mismatched/expired final lane. Every selected FINAL lane must carry only the cleanup + migration/V8 structural digest and exactly one closed arm: + `FINAL.FRESH` carries discriminator `FRESH_PROVISIONED` with fresh-token XOR, the signed DB + birth certificate, the complete exact Task 9 fresh-provenance field set and + enforcement-read-back → cached-session/flow termination → post-enforcement settled-zero + manifest → irreversible fence seal/read-back → signature causal order, signed fresh + provenance/trust snapshot and `INITIALIZE_CANONICAL_FRESH`; `FINAL.UPGRADE` carries + discriminator `UPGRADE_VALIDATED` with + complete retained-history-digest XOR and forbids fresh provenance. + `AWAITING_SIGNED_FRESH_PROVISIONING`, a PRE ownership arm or reference to a deleted + cutover type fails aggregation. If a scheduled destructive DLQ manifest is + still fresh under the exact artifact/card policy it may be selected; otherwise the + human-approved DLQ lane is rerun. No code/config/docs change is allowed after this build without + invalidating the final evidence and repeating this gate. +- [ ] Only successful final-artifact aggregation permits the selected exact card to be called an + operational R2 candidate. If sandbox credentials/topology or the human candidate commit are not + available, record the blocker and keep the card `NOT_QUALIFIED`; do not reuse pre-cutover + evidence. +- [ ] Before Wiki capture, read the configured vault's `AGENTS.md`, `CLAUDE.md`, and relevant + `rules/`, `.agents/`, `.claude/`, `.codex/` instructions. Then update exactly + `/home/donghyeon/workspace/ai-tool/llm-wiki-private/raw/branch-notes/.md` with + implementation, files, decisions, commands/results/failures, evidence grade and remaining + risks. +- [ ] Add `raw/errors`, `raw/interviews`, `raw/blog-topics` only when honestly derived; link each child + upward and the branch-note cluster back to each child. Otherwise record “없음” explicitly. +- [ ] Do not mark this task complete if the canonical Wiki path is unavailable; record the precise + capture blocker. +- [ ] A repository-wide gate that cannot run because of environment/tooling/authorization is a + recorded blocker, not a pass. State the exact command and residual risk; do not claim Task 22 + complete. +- [ ] Final handoff must list changed files, core behavior, exact verification results, not-run/failed + commands, Wiki capture, evidence level and follow-up risks. + +**Rollback checkpoint:** documentation must describe the deployed/evidenced truth, not the preferred +rollback story. Do not rewrite evidence after a failed rollout. + +--- + +## 7. Task dependency graph + +```text +1 +└─ 2 + └─ 3 + └─ 4 + ├─ 5 ─ 6 ─ 7 ────────────────┐ + └─ 8 ─ 9 ─ 10 ─ 11 ─ 12 ────┤ + └─ 13 ─┬─ 14 + └─ 15 ─ 16 ─ 17 ─ 18 ─ 19 ─ 20 ─ 21 ─ 22 +``` + +Tasks within one owner leaf may be implemented sequentially by one agent. Parallel work is safe only +after the shared application contracts are GREEN: + +- Task 13 must finish before Task 15 because both change the notification leaf dependency declaration + and lock; Task 14 may run in parallel with Task 15 only after Task 13's card/client seam is stable. +- Task 16 may start after the normalized receipt contract and SES event profile are frozen. +- Persistence Tasks 9–11 must not run concurrently against the same migration/store files. +- Composition Task 17 starts only after provider, persistence and ingress descriptors are stable. +- Legacy deletion Task 21 is never parallelized with provider/composition work. +- Task 20 local harness implementation may begin after Task 18, but its final aggregator cannot run + until Task 19 has emitted every selected provider manifest. + +## 8. Minimum implementation completion matrix + +| requirement | proving task | +| --- | --- | +| canonical binding/expected state, legacy conflict | 5, 17, 21 | +| feature-specific semantic pattern | 3–4 | +| frozen intent/template/route | 3, 5–6, 10 | +| best-effort/durable separation | 2–4, 14 | +| same-DB journal | 9–12 | +| token/version claim/finalize | 11–12 | +| encrypted PII/HMAC/retention | 8–10, 18 | +| indeterminate/reconcile/fallback safety | 4, 11–12, 14–16 | +| Slack exact cards | 13–14, 19–20 | +| SES/SNS exact card | 15–16, 19–20 | +| zero-resource disabled | 17–18 | +| bounded deadlines/concurrency/amplification | 4–7, 11–15, 18–20 | +| health/metrics/traces/runbook | 18, 20 | +| independent review | 22 | +| LLM Wiki capture | 22 | + +`minimum implementation R2`는 표의 required task가 실제로 GREEN이고 selected exact card의 +no-skip evidence가 fresh할 때만 사용할 수 있다. 일부 task만 끝났다면 해당 evidence row의 +좁은 표현만 사용한다. + +## 9. Final non-negotiable assertions + +- provider accepted와 recipient delivered/read는 다르다. +- `WIRE_AUTHORIZED` 이후 unknown은 definite-not-sent가 아니다. +- Slack/SES send API에는 이 설계가 의존할 exactly-once idempotency가 없다. +- shared admission park는 durable state이며 process-local circuit breaker가 아니다. +- config는 application mode/admission/business policy를 선택하지 않는다. +- callback authenticity와 same-transaction receipt commit 전에는 SNS success ACK를 반환하지 + 않는다. +- disabled와 configured-but-broken은 다른 상태다. +- fake/loopback/PostgreSQL evidence만으로 real provider card를 R2라고 부르지 않는다. +- agent는 stage/commit/amend/push하지 않는다. diff --git a/docs/superpowers/specs/2026-07-28-notification-production-capability-design.md b/docs/superpowers/specs/2026-07-28-notification-production-capability-design.md new file mode 100644 index 00000000..7917eb1b --- /dev/null +++ b/docs/superpowers/specs/2026-07-28-notification-production-capability-design.md @@ -0,0 +1,4860 @@ +# Notification Production Capability Deep Design + +- 작성일: 2026-07-28 +- 상태: 상세 설계 승인, 구현 계획 작성, 구현 미착수 +- 기준: Java 21, Spring Boot 4.0.0, Gradle 멀티모듈 Clean Architecture +- 대상 leaf: `adapter-outbound-notification` +- 상위 문서: + [Production Capability Platform Design](2026-07-26-production-capability-platform-design.md) +- 비교 기준: + [Redis Production Capability Deep Design](2026-07-26-redis-production-capability-design.md), + [Fileserver Production Capability Deep Design](2026-07-26-fileserver-production-capability-design.md), + [HTTP Client Production Capability Deep Design](2026-07-27-httpclient-production-capability-design.md) + +## 0. 문서 상태와 구현 상태 + +이 문서는 Notification capability의 승인된 상세 설계다. 2026-07-28에 §36의 다섯 gate를 +사용자가 승인했으며, 실제 구현 순서는 +[Notification Production Capability Implementation Plan](../plans/2026-07-28-notification-production-capability.md)을 +정본으로 사용한다. 구현 및 production readiness는 아직 주장하지 않는다. + +2026-07-28 현재 구현된 범위: + +- `application-core`의 `Channel`, raw `Notification`, `NotificationPort`; +- `(channel, route) -> providerId list` fan-out router; +- channel 안의 중복 provider ID 및 configured route가 존재하지 않는 provider ID를 참조하는 + 경우의 construction 검증; +- route 미설정 시 silent no-op 대신 `AdapterDisabledException`; +- provider exception을 기록하되 호출자에게 전파하지 않는 global fail-open decorator; +- PII인 recipient와 body를 dependency log에 넣지 않는 단위 테스트; +- `google-email`, `slack-webhook` provider/client extension seam; +- provider/client fake를 이용한 routing, fan-out, fail-open 단위 테스트. + +아직 구현되지 않은 범위: + +- 실제 Google email 또는 Slack client; +- versioned template, locale, typed parameter schema와 rendering; +- best-effort와 durable delivery mode의 명시적 분리; +- provider-neutral submission outcome과 recipient outcome; +- durable notification intent/delivery/attempt/receipt store; +- claim owner token, retry horizon, expiry, reconciliation과 unknown outcome; +- consent, preference, quiet-hours와 business suppression; +- hard bounce, complaint와 technical suppression; +- provider별 quota, rate limit, retry, idempotency 및 receipt capability; +- canonical binding/expected-state 설정과 zero-resource 비활성 계약; +- Slack Web API와 Amazon SES v2 reference provider; +- provider callback verification 및 deduplication; +- production readiness lane과 real-provider evidence. + +현재 코드는 R0 extension seam과 local routing skeleton이다. 테스트가 통과하더라도 email 또는 +Slack notification이 실제로 전송된다는 증거가 아니며, durable/critical notification의 +근거도 아니다. + +## 1. 설계 판정 + +현재 Notification 구현의 가장 큰 문제는 provider가 없다는 사실만이 아니다. 다음 의미가 +하나의 `void notify(...)` 호출에 섞여 있다. + +1. 업무상 알림을 만들어도 되는가; +2. 어떤 template과 locale을 쓸 것인가; +3. inline으로 시도할 것인가 durable하게 저장할 것인가; +4. 어떤 provider에 몇 번 시도할 것인가; +5. provider가 요청을 받았는가; +6. recipient system까지 도착했는가; +7. 실패를 무시해도 되는가; +8. 응답을 잃었을 때 재전송해도 되는가. + +현재 global fail-open은 provider가 던진 모든 예외를 삼키므로 호출자는 성공, 실패, +indeterminate를 구분할 수 없다. 반대로 route list는 항상 fan-out으로 해석되어 ordered +fallback과 single provider가 구분되지 않는다. raw recipient/subject/body는 template +version, locale, parameter schema, idempotency, expiry, consent snapshot을 표현하지 못한다. + +이번 설계의 목표는 범용 `send(channel, recipient, body)` SDK가 아니다. + +> feature-specific application policy가 생성한 versioned notification intent를, 검토된 +> route와 template에 따라 bounded하게 계획하고, best-effort inline 또는 durable async +> mode로 실행하며, provider submission과 recipient outcome을 분리해 추적·재시도·복구하는 +> outbound capability + +선택한 핵심 구조는 다음과 같다. + +1. Business use case는 `PasswordResetNotificationRequestFactory` 같은 feature-specific + application policy/factory와 명시적 outbound port를 사용한다. +2. `application-core`에는 framework-free notification intent, plan, append/store와 dispatch + use case 계약만 둔다. +3. `adapter-outbound-notification`은 route/template catalog, rendering, provider attempt와 + provider-specific reconciliation을 소유한다. +4. business consent, preference, quiet-hours, notification 필요성은 domain/application이 + 소유한다. +5. technical bounce/complaint suppression은 notification lifecycle의 기술 상태로 분리한다. +6. `BEST_EFFORT_INLINE`과 `DURABLE_ASYNC`를 별도 계약으로 두며 application code의 + `NotificationKindPolicy`만 mode를 결정한다. +7. durable mode는 source business DB와 같은 transaction에서 recipient 1명의 intent를 append하고, + 별도 dispatcher가 short claim transaction 뒤 DB transaction 밖에서 provider를 호출한다. +8. intent, provider delivery leg, physical attempt, provider receipt를 서로 다른 identity/state로 + 관리한다. +9. timeout이나 ACK loss 뒤의 결과는 일반 retryable failure가 아니라 `INDETERMINATE`로 + 모델링한다. +10. 외부 provider 호출과 local DB commit 사이의 exactly-once는 주장하지 않는다. +11. Slack 초기 R2 reference provider는 Web API `chat.postMessage`, email은 Amazon SES v2 + API로 선택한다. +12. 기존 `slack-webhook`과 `google-email` seam은 legacy R0 best-effort compatibility로만 + 취급하며 durable/critical/receipt-required route에 binding하지 않는다. +13. binding이 없으면 provider client, scheduler, callback subscription, health probe를 만들지 + 않는다. +14. provider별 production readiness는 정확한 effective capability tuple과 real-provider + evidence로 판정한다. + +## 2. 기존 심화 설계에서 재사용할 패턴과 재사용하지 않을 패턴 + +### 2.1 재사용할 공통 패턴 + +| 기존 설계 | Notification에 재사용할 결정 | +| --- | --- | +| Redis | semantic port, capability별 failure policy, typed activation, exact readiness card, bounded resource | +| Fileserver | provider-neutral request/receipt, configured guarantee와 achieved guarantee 분리, `INDETERMINATE`, reconciliation 우선 | +| HTTP Client | typed ID/catalog, expected-state binding, logical call과 physical attempt 분리, retry amplification budget, PII-safe telemetry | + +세 문서에서 공통으로 채택한 다음 원칙도 그대로 적용한다. + +- application에 provider SDK나 transport 타입을 노출하지 않는다; +- arbitrary provider/endpoint/credential을 caller가 선택하지 않는다; +- code catalog가 허용한 operation/template/route만 config가 활성화한다; +- config는 code에 검토된 상한을 강화할 수 있지만 완화할 수 없다; +- no binding은 zero side effect다; +- timeout과 response loss는 성공/실패 이분법으로 축소하지 않는다; +- fake test만으로 production provider readiness를 주장하지 않는다; +- metric tag에 high-cardinality 또는 PII 값을 쓰지 않는다; +- legacy alias와 canonical 설정이 동시에 존재하면 precedence를 추론하지 않고 실패한다. + +### 2.2 Notification에 복사하지 않을 capability-specific 패턴 + +- Redis key/hash slot, Lua/Function, topology semantics를 notification dedupe나 lock에 + 재사용하지 않는다. +- Fileserver의 staging/rename/journal을 notification delivery journal에 그대로 투영하지 + 않는다. +- HTTP method idempotency나 URI/DNS/pool 정책을 provider-neutral notification 의미로 + 노출하지 않는다. +- application outbox의 현재 generic row/publisher를 곧바로 notification delivery store로 + 간주하지 않는다. +- broker publish 성공을 recipient delivery로 간주하지 않는다. +- Slack message timestamp나 SES message ID를 application-wide idempotency key로 사용하지 + 않는다. + +### 2.3 Normative decision ledger + +긴 문서에서 결정을 다시 추론하지 않도록 구현과 리뷰는 다음 위치를 정본으로 사용한다. + +| 결정 | 정본 | +| --- | --- | +| capability/readiness 용어 | §7 | +| 모듈 소유권과 의존성 | §8, §31 | +| application 계약과 typed 값 | §9–§10 | +| mode와 state machine | §11–§12 | +| planning/routing/fan-out/fallback | §13 | +| template/rendering/localization | §14 | +| provider attempt와 retry 의미 | §15 | +| durable DB workflow와 concurrency | §16 | +| Slack/Email reference provider | §18–§19 | +| receipt/reconciliation/suppression | §20 | +| 설정·activation·zero-resource | §21–§22 | +| deadline/resource/amplification | §23 | +| 보안·개인정보·보존 | §24 | +| 관측성·health·lifecycle | §25–§27 | +| 테스트·CI·evidence | §29 | +| migration과 completion | §32–§34 | + +예시 YAML, Java shape 또는 migration alias가 이 표의 정본보다 우선하지 않는다. + +## 3. 증거 기반 현재 상태 + +### 3.1 application contract가 delivery 의미를 표현하지 못한다 + +현재 application contract는 다음 세 타입뿐이다. + +```text +Channel = EMAIL | SLACK +Notification = recipient + subject + body +NotificationPort.notify(channel, route, notification) -> void +``` + +이 계약에는 다음 필드가 없다. + +- intent ID와 idempotency/fingerprint; +- feature/notification kind; +- template ID/version과 locale; +- typed template parameter; +- delivery mode와 policy revision; +- not-before, expiry, retry horizon; +- tenant, correlation, causation; +- recipient reference와 consent/preference evidence; +- submission outcome 또는 receipt. + +`void` 반환과 global fail-open을 조합하면 caller는 provider가 실행되지 않은 경우도 성공한 +호출과 구분할 수 없다. 이 shape는 non-critical telemetry-like best-effort compatibility +외에는 정확한 업무 계약이 될 수 없다. + +### 3.2 route list가 fan-out 의미로 고정된다 + +`RoutingNotifier`는 route의 provider ID list를 순서대로 모두 호출한다. + +```text +app.notification.routes..=provider-a,provider-b +``` + +이 list가 의미하는 바는 현재 무조건 `FAN_OUT_ALL`이다. 다음을 구분할 필드가 없다. + +- 정확히 하나만 호출하는 `SINGLE`; +- definite failure 때만 다음 provider로 넘어가는 `ORDERED_FALLBACK`; +- 모든 provider에 독립 delivery를 만드는 `FAN_OUT_ALL`. + +각 provider는 호출 전에 `FailOpenNotificationProvider`로 감싸져 outcome을 잃는다. 따라서 +router는 fallback 결정을 할 수도 없고 provider별 delivery 상태를 남길 수도 없다. + +### 3.3 설정의 activation source가 서로 어긋난다 + +현재 bootstrap/sample YAML은 다음 selector를 노출한다. + +```text +app.notification.slack.provider = APP_NOTIFICATION_SLACK_PROVIDER +app.notification.email.provider = APP_NOTIFICATION_EMAIL_PROVIDER +``` + +env registry와 optional contract test도 이 두 selector를 기준으로 한다. 그러나 provider +configuration은 다음 legacy boolean을 조건으로 사용한다. + +```text +app.notification.slack-webhook.enabled=true +app.notification.google-email.enabled=true +``` + +실제 route binding은 default YAML에 없다. 즉 문서/환경 SSOT가 말하는 active provider와 bean +activation이 같은 graph를 만들지 않는다. 이 상태에서 selector가 채워졌다는 사실은 provider가 +생성되거나 route가 usable하다는 증거가 아니다. + +### 3.4 provider는 실제 client가 아니다 + +`SlackClient`와 `GoogleEmailClient`는 extension interface이며 production 구현이 없다. +`SlackWebhookProvider`와 `GoogleEmailProvider`는 이 client를 호출하는 wrapper다. build +dependency에도 Slack SDK, AWS SDK, Gmail SDK 또는 SMTP client가 없다. + +따라서 현재 provider ID는 다음과 같이 해석해야 한다. + +| provider ID | 현재 의미 | production 보장 | +| --- | --- | --- | +| `slack-webhook` | injected fake/client seam | 없음 | +| `google-email` | injected fake/client seam | 없음 | + +### 3.5 durable workflow가 없다 + +현재 `NotificationPort` 호출과 함께 저장되는 intent가 없고 dispatcher/claim/attempt journal도 +없다. process crash, timeout, provider ACK loss, DB update 실패 뒤에 다음을 판별할 근거가 없다. + +- 전송을 시작하지 않았는가; +- provider가 거부했는가; +- provider는 받았지만 응답을 잃었는가; +- provider message ID를 받았으나 local commit 전에 죽었는가; +- 다시 보내면 중복이 되는가. + +기존 generic application outbox는 event publication을 위한 mutable row와 generic publisher +shape다. notification은 recipient별 fan-out, template snapshot, provider attempt, +indeterminate/reconciliation, feedback event, encrypted PII 보존이 필요하므로 그 row를 그대로 +재사용하지 않는다. + +### 3.6 production consumer가 없다 + +repository의 production source에서 `NotificationPort`를 호출하는 feature use case가 없다. +현재 테스트는 routing skeleton의 local behavior만 증명한다. 이 설계는 sample feature를 +억지로 consumer로 만들지 않고 먼저 reusable capability contract를 확정한다. + +### 3.7 문서도 현재 코드와 일부 어긋난다 + +notification README의 module guidance와 실제 leaf의 `CLAUDE.md`, selector 설명과 legacy enabled +condition, route activation 설명 사이에 drift가 있다. Phase 0에서 코드 변경 전 현재 truth를 +한 표로 정리하고 서로 다른 activation source를 동시에 유지하지 않는다. + +주요 근거 파일: + +- `src/application-core/src/main/java/dev/caskeleton/application/notification/Channel.java` +- `src/application-core/src/main/java/dev/caskeleton/application/notification/Notification.java` +- `src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationPort.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/core/RoutingNotifier.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/core/FailOpenNotificationProvider.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/core/NotificationProvider.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/email/google/GoogleEmailNotificationAdapterConfig.java` +- `src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/slack/webhook/SlackNotificationAdapterConfig.java` +- `src/app-bootstrap/src/main/resources/application.yml` +- `src/config/architecture/modules.json` + +### 3.8 independent design review + +2026-07-28에 architecture boundary, 기존 Redis/Fileserver/HTTP Client 설계 일관성, +durability/receipt 세 관점으로 독립 read-only review와 correction re-review를 수행했다. + +- 최초 review의 mode policy leak, sibling compiler, recipient/provider-leg identity, transaction + root, crypto/HMAC, exact card, SES/SNS topology, wire authorization/lease, receipt projection, + admission park 지적을 본문에 반영했다; +- correction re-review 결과 세 관점 모두 blocker 0, high 0이다; +- 이는 design consistency evidence이며 구현/real-provider R2 evidence가 아니다. + +baseline verification: + +- `./gradlew :adapter:outbound:notification:test --rerun-tasks --console=plain` + → 19 tests, failures/errors/skipped 0; +- `./gradlew verifyCleanArchitectureDependencies --console=plain` + → build success. + +## 4. 범위와 명시적 비범위 + +### 4.1 최소 R2 baseline에 포함 + +- `EMAIL`, `SLACK` channel; +- feature-specific application request factory/policy와 outbound notification port; +- versioned notification kind, route, template, locale와 typed parameter; +- `BEST_EFFORT_INLINE`, `DURABLE_ASYNC` mode; +- `SINGLE` provider R2와 `FAN_OUT_ALL`, `ORDERED_FALLBACK` R1 kernel; +- one-recipient logical intent, N provider leg, M physical attempt model; +- provider-neutral attempt outcome과 indeterminate state; +- source DB transaction과 함께 저장되는 durable intent; +- PostgreSQL/JPA 기반 claim, attempt journal, retry, expiry와 reconciliation; +- checked-in local template rendering; +- Slack Web API `chat.postMessage`; +- Amazon SES v2 `SendEmail`; +- Slack conversation post reference와 SES message ID 저장; +- SES bounce/complaint/delivery/delivery-delay feedback intake. rendering failure는 provider-stored + template optional card에서만 지원; +- technical suppression과 application-owned consent/preference 분리; +- canonical provider binding과 zero-resource disabled behavior; +- deadline, concurrency, queue, retry와 fan-out 상한; +- PII direct-AEAD encryption/redaction/retention; +- startup/readiness/metrics/traces/runbook; +- fake/local protocol test와 explicit real-provider qualification lane. + +### 4.2 R2 뒤에 열어둘 optional capability + +- Slack incoming webhook compatibility provider; +- Gmail API provider; +- SMTP provider; +- provider-stored SES template; +- SMS, push, mobile/web inbox; +- cross-region active/active dispatcher; +- broker wake-up/partitioning; +- user-facing notification preference center; +- provider message update/delete; +- Slack event-based conversation reconciliation; +- marketing analytics/open/click tracking; +- multi-recipient batch provider API. + +optional provider는 동일한 capability name 아래 자동 호환으로 간주하지 않는다. 각 provider +card가 정확한 submission, idempotency, receipt, sandbox, quota, reconciliation 보장을 선언하고 +요구 profile을 통과해야 한다. + +### 4.3 이번 범위에서 제외 + +- notification 내용을 결정하는 domain business rule; +- controller/filter/settings/mapper의 notification policy; +- arbitrary raw email/Slack body 전송 SDK; +- caller-supplied Slack webhook URL/channel ID 또는 email provider credential; +- provider SDK type를 application에 반환하는 API; +- external send와 DB commit의 distributed transaction; +- exactly-once delivery 또는 exactly-once user visibility; +- email inbox 도착, 열람 또는 Slack 사용자 읽음 보장; +- provider별 마케팅 캠페인 orchestration; +- production leaf에서 sample WorkLog 개념 사용; +- notification leaf가 persistence/messaging/httpclient sibling leaf를 직접 의존하는 구조; +- 일반-purpose cron/job framework; +- business unsubscribe를 bounce suppression table로 대체하는 구조. + +## 5. HARD invariants + +다음 조건은 구현 편의를 위해 낮출 수 없다. + +1. `domain-core`에 Spring, JPA, Slack/AWS/Gmail/SMTP, JSON, HTTP 타입을 넣지 않는다. +2. controller는 notification provider, repository, persistence entity를 직접 사용하지 않는다. +3. inbound DTO를 application notification command나 template parameter로 재사용하지 않는다. +4. notification adapter가 consent, preference, quiet-hours 또는 “누구에게 알려야 하는가”를 + 결정하지 않는다. +5. business use case가 provider ID, webhook URL, channel ID, AWS region, credential을 선택하지 + 않는다. +6. provider SDK request/response/exception을 application contract에 노출하지 않는다. +7. critical/durable route를 caller flag로 best-effort에 downgrade하지 않는다. +8. `void` + exception swallow를 durable 또는 critical success로 표현하지 않는다. +9. provider accepted와 recipient delivered/read를 같은 상태로 표현하지 않는다. +10. timeout, connection loss, ACK loss를 definite-not-sent로 간주하지 않는다. +11. `INDETERMINATE` attempt를 근거 없이 blind retry하지 않는다. +12. fallback은 `submissionCertainty=DEFINITELY_NOT_APPLIED`에서만 진행한다. unknown duplicate + tolerance/escalation은 별도 application intent로 표현한다. +13. 외부 send와 local DB update 사이 exactly-once를 주장하지 않는다. +14. hidden SDK retry가 physical-attempt budget 밖에서 실행되지 않게 한다. +15. route/provider/template revision을 진행 중 intent에 조용히 재해석하지 않는다. +16. raw recipient, body, subject, template parameter, token, provider message ID를 metric tag로 + 사용하지 않는다. +17. secret/webhook URL/access token을 log, exception message, receipt 또는 callback payload + snapshot에 남기지 않는다. +18. durable queue의 recipient/content를 plaintext로 무기한 저장하지 않는다. +19. provider feedback의 technical suppression을 business consent의 정본으로 사용하지 않는다. +20. route binding이 없으면 client, scheduler, thread, connection, probe, callback subscription을 + 만들지 않는다. +21. notification adapter가 persistence, messaging, inbound 또는 HTTP client adapter에 + project dependency를 추가하지 않는다. +22. real-provider evidence 없이 provider R2를 주장하지 않는다. +23. Slack `ts`를 user delivery/read receipt로 표현하지 않는다. +24. SES `MessageId`와 `DELIVERY` event를 inbox/read receipt로 표현하지 않는다. +25. callback은 signature/authenticity, expected account/topic/workspace와 replay/deduplication을 + 검증하기 전 application command로 승격하지 않는다. +26. retry, target, fallback과 provider call의 총 amplification 상한이 없는 route를 활성화하지 + 않는다. +27. provider quota, backpressure 또는 DB backlog가 무한 queue/worker 생성으로 이어지지 않는다. +28. stale claim owner가 새 owner의 attempt를 finalize하지 못하게 한다. +29. 같은 business operation의 반복 요청이 새 intent인지 retry인지 stable identity 없이 + 추론되지 않게 한다. +30. notification capability가 business transaction commit 전에 외부 provider를 먼저 호출하지 + 않는다. + +## 6. 대안 검토 + +### 6.1 현재 `void NotificationPort` 확장 + +장점: + +- 변경량이 가장 작다; +- 현재 router/fan-out code를 유지할 수 있다. + +탈락 이유: + +- outcome, durable append, attempt, receipt를 표현할 수 없다; +- raw recipient/body가 template와 policy boundary를 우회한다; +- global fail-open을 feature별 failure policy로 바꿀 수 없다. + +판정: R0 legacy compatibility에만 유지한다. + +### 6.2 provider가 DB/outbox에 직접 기록 + +장점: + +- notification leaf 안에서 durable workflow가 한곳에 보인다. + +탈락 이유: + +- registry가 notification -> persistence project edge를 허용하지 않는다; +- application transaction boundary가 adapter에 역전된다; +- provider implementation이 business workflow와 storage orchestration을 소유하게 된다. + +판정: 채택하지 않는다. + +### 6.3 message broker를 durability의 정본으로 사용 + +장점: + +- worker scale-out과 wake-up이 쉽다; +- retry/topic partition tooling을 활용할 수 있다. + +탈락 이유: + +- business DB commit과 broker publish 사이 dual-write가 생긴다; +- provider fan-out/attempt/receipt/PII retention을 broker event 하나로 해결하지 못한다; +- notification leaf -> messaging edge도 registry에 없다. + +판정: R2 baseline은 same-source DB intent가 정본이다. 이후 broker에는 opaque intent ID만 +outbox로 발행해 wake-up hint로 사용할 수 있다. DB claim/state가 계속 정본이다. + +### 6.4 기존 generic application outbox row를 그대로 재사용 + +장점: + +- 테이블과 scheduler 수가 적다; +- 기존 polling/publish 흐름을 재사용할 수 있다. + +탈락 이유: + +- 현재 outbox에는 recipient별 delivery/attempt/receipt identity가 없다; +- provider response loss와 reconciliation 상태가 없다; +- encrypted payload, consent snapshot, route/template revision 보존 계약이 없다; +- generic publisher의 성공/실패와 notification recipient outcome이 다르다. + +판정: scheduler/claim pattern은 참고할 수 있지만 notification 전용 aggregate/table과 port를 +만든다. 공통화는 두 capability의 invariant가 검증된 뒤 별도 설계로 진행한다. + +### 6.5 provider 자체 retry와 idempotency에 전적으로 의존 + +장점: + +- application coordinator가 단순하다. + +탈락 이유: + +- Slack과 SES의 baseline send API에는 운영상 의존할 문서화된 native idempotency key가 없다; +- SDK hidden retry는 physical calls와 duplicate risk를 가린다; +- provider accepted 뒤 local commit 실패를 해결하지 못한다. + +판정: coordinator가 logical retry와 budget을 소유한다. SDK retry는 disable하거나 모든 wire +attempt가 같은 journal/budget에 계수된다는 evidence가 있을 때만 허용한다. + +### 6.6 Slack Incoming Webhook을 baseline으로 사용 + +장점: + +- payload와 인증이 단순하다; +- 작은 고정 채널 알림에 적합하다. + +탈락 이유: + +- URL이 destination과 secret을 함께 결합한다; +- 요청에서 목적지를 바꿀 수 없다; +- 성공 응답에 message `ts`가 없어 reconciliation이 약하다; +- update/delete와 dynamic route 요구가 제한된다. + +판정: `chat.postMessage`를 R2 reference로, incoming webhook은 고정 목적지 legacy compatibility +provider로 둔다. + +### 6.7 Gmail API를 generic email baseline으로 사용 + +장점: + +- Gmail/Workspace mailbox identity에 자연스럽다; +- Gmail message resource와 mailbox 기능을 활용할 수 있다. + +탈락 이유: + +- per-user quota와 OAuth/domain-wide delegation 운영 복잡도가 baseline에 결합된다; +- generic transactional email feedback/bounce lifecycle의 정본이 아니다; +- 현재 `google-email` 이름이 Gmail API인지 SMTP인지도 명확하지 않다. + +판정: Amazon SES v2를 초기 R2 reference로 선택한다. Gmail API는 별도 exact provider card가 +필요한 optional provider다. + +## 7. capability와 evidence 용어 + +### 7.1 readiness level + +| Level | 의미 | 허용 표현 | +| --- | --- | --- | +| R0 | interface, fake, skeleton 또는 legacy seam | “extension seam”, “설계/골격” | +| R1 | local deterministic behavior와 contract test | “local behavior verified” | +| R2 | 선택된 real provider profile의 운영 필수 보장과 failure evidence | “provider/profile R2” | +| R3 | production-like scale/failover/rotation/운영훈련 evidence | “해당 topology/profile R3” | + +readiness는 module 전체의 단일 숫자가 아니다. + +```text +NotificationCapabilityCard = + card ID/revision + + provider binding ID/revision + + channel + + application policy mode + + route strategy + + template/render/serialization revision + + submission semantics + + pre-send correlation/native idempotency + + reconciliation lookup mode + + receipt transport/projections + + credential source/account/region/workspace profile + + tested evidence revision/maturity +``` + +예: + +```text +aws-ses-v2-durable-single-local-sns-v1 +/ aws-ses-primary@v3 / EMAIL / DURABLE_ASYNC / SINGLE +/ LOCAL_RENDERED@v4 + canonical-email-v2 +/ API_ACCEPTED_MESSAGE_ID / ATTEMPT_TAG_NO_NATIVE_IDEMPOTENCY +/ PRE_SEND_CORRELATION_EVENT_LOOKUP / SNS_HTTPS@v1 +/ account+ap-northeast-2+WEB_IDENTITY / evidence-2026-07-28 / R1 +``` + +다른 provider, region, template mode, callback topology 또는 credential mode로 일반화하지 +않는다. + +### 7.2 normative state wording + +| 표현 | 정확한 의미 | +| --- | --- | +| `APPENDED` | local durable intent transaction이 commit됨 | +| `WIRE_AUTHORIZED` | eligibility 재검사를 통과하고 physical provider call 권한을 durable commit함 | +| `PROVIDER_ACCEPTED` | provider API가 요청 수락을 응답함 | +| `POSTED_TO_CONVERSATION` | Slack conversation에 message reference가 생성됨 | +| `DELIVERED_TO_RECIPIENT_MTA` | email recipient 측 MTA가 수락했다는 provider feedback | +| `BOUNCED` | provider가 bounce feedback을 보고함 | +| `COMPLAINED` | provider가 complaint feedback을 보고함 | +| `TERMINAL_INDETERMINATE` | reconcile horizon 뒤에도 provider side effect 여부를 확정할 수 없음 | + +`DELIVERED`, `SUCCESS`, `SENT` 같은 단독 표현은 provider/card 문맥 없이 terminal 상태 이름으로 +사용하지 않는다. + +## 8. 모듈 소유권과 dependency direction + +현재 `src/config/architecture/modules.json`에 따르면 notification leaf의 허용 production +dependency는 다음뿐이다. + +```text +adapter-outbound-notification + -> domain-core + -> application-core + -> shared-contract + -> adapter-outbound-support +``` + +이 registry를 유지한 상태에서 소유권을 다음처럼 나눈다. + +| 책임 | 소유 모듈 | 금지 | +| --- | --- | --- | +| notification eligibility와 business invariant | `domain-core` 또는 feature application | adapter/config에서 결정 | +| feature-specific request factory/policy와 use case | `application-core` | transport/provider DTO | +| framework-free intent/plan/store/dispatch 계약 | `application-core` | Spring/JPA/SDK 타입 | +| route/template catalog, renderer | `adapter-outbound-notification` | business consent | +| provider request/response mapping | `adapter-outbound-notification` | persistence entity | +| durable notification table/repository adapter | `adapter-outbound-persistence-jpa` | provider SDK | +| raw callback auth/transport mapping | 현재 `adapter-inbound-web` | provider send implementation | +| scheduler, worker bean, provider/store composition | `app-bootstrap` | business policy | +| optional broker wake-up | messaging/outbox 관련 owner leaf | DB state 대체 | +| sample WorkLog notification consumer | `sample-portfolio` | production leaf로 역의존 | + +### 8.1 application orchestration + +application service는 다음 port를 조합할 수 있다. + +```text +Feature use case + -> FeatureNotificationRequestFactory + NotificationKindPolicy + -> InlineNotificationAttemptPort + or NotificationIntentAppendPort + +NotificationDispatchUseCase + -> NotificationDeliveryStorePort + -> NotificationProviderAttemptPort + -> NotificationReceipt/ReconciliationPort +``` + +application policy/factory에는 `Port` 이름을 붙이지 않는다. interface의 최종 분할은 구현 +계획에서 package cohesion을 검증하되, 하나의 giant +`NotificationPort`로 plan/store/send/receipt를 다시 합치지 않는다. + +### 8.2 callback ownership + +SES/SNS, Slack event 또는 provider webhook의 raw HTTP signature 검증은 inbound adapter +책임이다. 현재 registry는 inbound web -> notification adapter edge를 허용하지 않으므로 inbound +adapter는 provider SDK event object를 넘기지 않는다. + +```text +HTTP callback + -> inbound signature/account/topic/workspace verification + -> framework-free NormalizedNotificationReceiptCommand + -> application receipt use case + -> persistence receipt/delivery state port +``` + +provider callback 종류가 커지고 inbound lifecycle이 독립 배포/의존성을 요구하면 +`adapter:inbound:notification` leaf 추가를 registry migration으로 별도 제안한다. outbound leaf에 +controller/listener를 넣지 않는다. + +## 9. application-facing 계약 + +### 9.1 feature-specific port가 우선이다 + +business code가 generic channel/body를 직접 조립하지 않도록 feature별 application policy/factory를 +둔다. + +개념 예: + +```java +public final class PasswordResetNotificationRequestFactory { + NotificationIntentDraft create(PasswordResetNotice notice); +} +``` + +`PasswordResetNotice`에는 business 의미와 이미 검증된 opaque recipient reference만 있고 Slack, +SES, HTML, Block Kit, webhook URL은 없다. application-owned factory와 +`NotificationKindPolicy`가 notification kind/route/template/mode/admission class를 closed +catalog에서 고른다. + +`PasswordResetNotificationRequestFactory`는 outbound port가 아니며 이름에 `Port`를 붙이지 +않는다. 실제 외부 side effect와 저장은 application-core가 선언한 +`InlineNotificationAttemptPort`, `NotificationIntentAppendPort`, +`NotificationDeliveryStorePort` 같은 outbound port 뒤에 둔다. generic foundation은 feature +application policy를 구현하기 위한 내부 capability이며, 모든 use case가 raw template ID와 +parameter map을 자유롭게 호출하는 public utility로 제공하지 않는다. + +### 9.2 intent command의 최소 의미 + +framework-free command는 개념적으로 다음 값을 갖는다. + +```text +NotificationIntentDraft + intentId + notificationKind + channel + routeId + templateRef(id, version) + locale + recipientRef + typedParameters + mode + idempotencyScope + sourceOperationId + tenant/correlation/causation + notBefore + expiresAt + policyRevision + admissionClass +``` + +정확한 Java record 분할은 다음 원칙을 따른다. + +- 모든 ID는 bounded value object다; +- `Map`와 raw JSON string은 사용하지 않는다; +- inbound request DTO를 생성자 인자로 받지 않는다; +- recipient는 email address/Slack channel을 한 raw string union으로 만들지 않는다; +- address/channel lookup이 필요한 경우 opaque `RecipientReference`와 application resolver port를 + 사용한다; +- `Clock`/time policy는 testable application dependency이며 adapter가 expiry를 임의 결정하지 + 않는다; +- mode와 admission class는 application의 `NotificationKindPolicy`가 고정한다. +- 최소 R2에서 intent 하나는 logical recipient를 정확히 한 명만 갖는다. bulk는 별도 + `RecipientDelivery` dimension을 설계하기 전까지 허용하지 않는다. + +### 9.3 request result + +request 결과는 delivery 성공을 뜻하지 않는다. + +```text +NotificationRequestResult = + InlineCompleted(bounded TargetAttemptOutcome list) + | AppendedDurably(intentReference) + | DuplicateExistingIntent(intentReference) + | RejectedByBusinessPolicy(reasonCode) + | RejectedInvalidRequest(reasonCode) + | CapabilityUnavailable(reasonCode) +``` + +`InlineCompleted`는 `SINGLE`뿐 아니라 bounded `FAN_OUT_ALL`의 부분 성공/실패를 target ordinal별로 +표현한다. `TargetAttemptOutcome`은 §15.3의 직교 outcome을 사용한다. + +각 결과는 bounded reason code와 opaque intent/reference를 가질 수 있다. provider ID, raw +address, SDK error 또는 persistence entity를 반환하지 않는다. + +### 9.4 business policy snapshot과 recheck + +enqueue 전 application은 적어도 다음을 판단한다. + +- notification이 business적으로 필요한가; +- recipient가 누구인가; +- legal/consent/preference가 허용하는가; +- quiet hours/not-before가 적용되는가; +- expiry 이후 가치가 남는가; +- 같은 source operation에서 이미 요청했는가. + +시간이 긴 durable marketing notification은 dispatch 직전 consent/preference 재확인이 필요할 +수 있다. 이 여부와 recheck port는 notification kind policy가 고정한다. + +```text +ConsentCheckMode = + SNAPSHOT_AT_APPEND + RECHECK_BEFORE_EACH_DELIVERY +``` + +security/password-reset처럼 법적 근거와 urgency가 다른 종류를 marketing default로 묶지 않는다. + +## 10. identity, fingerprint와 revision + +### 10.1 identity 계층 + +| ID | 범위 | 용도 | +| --- | --- | --- | +| `NotificationIntentId` | logical business notification | append dedupe, 조회, correlation | +| `NotificationDeliveryId` | 한 provider leg/technical target | fan-out/fallback 상태 | +| `NotificationAttemptId` | 한 physical provider call | journal, budget, latency | +| `NotificationReceiptEventId` | normalized provider feedback | callback dedupe | +| `NotificationKindId` | business 의미 | policy/catalog lookup | +| `NotificationRouteId` | logical technical route | binding lookup | +| `NotificationTemplateId` + version | immutable content contract | rendering/replay | +| provider message reference | provider-local opaque reference | reconcile/feedback | + +provider message reference는 `(provider, account/workspace, providerMessageId)`처럼 provider +namespace와 함께 저장하며 application aggregate ID로 사용하지 않는다. + +최소 R2에서 `NotificationDeliveryId`는 recipient가 아니라 한 provider leg의 identity다. +intent의 logical recipient는 정확히 한 명이고, `SINGLE/FAN_OUT_ALL/ORDERED_FALLBACK`이 여러 +provider leg를 만들 수 있다. 향후 bulk notification은 +`Intent -> RecipientDelivery -> ProviderLeg -> Attempt` 계층을 별도 card로 도입해야 한다. + +### 10.2 source operation과 idempotency + +caller가 자유로운 idempotency string을 만드는 대신 feature가 stable source operation ID와 +closed scope를 제공한다. + +```text +fingerprint = HMAC-SHA-256( + purpose = intent-fingerprint, + hmacKeyVersion, + length-prefixed( + tenant, + notificationKind, + sourceOperationId, + recipientCanonicalDigest, + semanticParameterDigest, + policyRevision + ) +) +``` + +원칙: + +- delimiter concatenation을 사용하지 않는다; +- raw address/content를 fingerprint column에 넣지 않는다; +- secret이 아닌 plain SHA만으로 low-entropy email을 역추측할 수 있게 하지 않는다; +- retry마다 새 random intent ID만 생성해 dedupe를 우회하지 않는다; +- fingerprint version과 HMAC key version을 저장한다; +- 같은 source operation에서 의도적으로 여러 알림이 필요하면 bounded occurrence ID를 + semantic input으로 명시한다. + +HMAC은 purpose별 key와 version을 사용한다. lookup은 `current + bounded retiring keys`의 +digest를 계산한다. rolling rotation에서 old writer가 남아 있는 동안 새 owner write는 current와 +모든 retiring digest alias를 같은 transaction에 insert한다. 따라서 old/new writer가 경쟁해도 +공통 retiring alias unique constraint가 한 owner만 허용한다. old writer drain 뒤에는 current +alias만 쓰고, match된 retiring digest는 같은 transaction에서 current-key alias로 승격한다. + +alias table은 `(scope, purpose, key_version, digest)`와 +`(owner_type, owner_id, purpose, key_version)`를 각각 unique로 두며 하나의 alias가 서로 다른 +semantic owner를 가리키면 startup/runtime conflict로 막는다. + +old HMAC key는 suppression, source dedupe, provider-event dedupe, orphan receipt, message-reference +lookup과 tombstone이 모두 만료되었거나 current-key alias/re-HMAC migration을 마친 뒤에만 +retire한다. 무기한 suppression은 recipient ciphertext를 지우기 전에 current key로 re-HMAC해야 +한다. email canonicalization은 local part를 보존하고 domain의 case/IDNA normalization만 exact +version으로 정의한다. Gmail식 dot 제거 또는 plus suffix 제거를 generic email에 적용하지 않는다. + +### 10.3 frozen plan revision + +append된 intent에는 다음 immutable snapshot/digest를 보존한다. + +- notification kind policy revision; +- route plan revision; +- template ID/version/checksum; +- renderer/canonical serialization/escaping revision; +- locale/fallback decision; +- target count와 target opaque reference; +- delivery mode, strategy, attempt/fallback limit; +- consent check mode와 expiry; +- rendering parameter schema version. + +provider credential 값이나 full physical endpoint는 snapshot에 저장하지 않는다. 그러나 진행 +중 intent가 새 config로 자동 재해석되지 않도록 모든 live/retained intent가 참조하는 plan, +provider binding, template, renderer, canonical serialization과 escaping revision을 유지한다. + +단순 N/N-1 규칙으로 N-2 backlog를 제거하지 않는다. 삭제하려는 revision에 live/retained intent가 +있으면 startup 또는 rollout guard가 차단한다. + +## 11. delivery mode + +### 11.1 `BEST_EFFORT_INLINE` + +정확한 계약: + +- business transaction commit 뒤 또는 transaction이 없는 명시적 boundary에서 호출한다; +- process crash recovery가 없다; +- durable retry/receipt를 보장하지 않는다; +- provider attempt가 실패해도 feature 정책에 따라 business 결과를 유지할 수 있다; +- provider attempt outcome은 관측 가능하게 반환한다; +- route가 없으면 fail-fast하며 silent no-op하지 않는다; +- critical/durable kind에는 binding할 수 없다. + +현재 `FailOpenNotificationProvider`처럼 exception을 삼킨 뒤 `void`로 끝내지 않는다. +best-effort 결과는 `InlineCompleted(bounded TargetAttemptOutcome list)`이며 각 target outcome은 +§15.3의 submission certainty, retry disposition, fault scope를 그대로 보존한다. + +best-effort caller가 실패를 business 응답에 반영하지 않더라도 metric/log/audit outcome은 잃지 +않는다. 이 mode는 “실패를 무시한다”가 아니라 “delivery를 durable하게 추적하지 않는다는 +명시적 선택”이다. + +### 11.2 `DURABLE_ASYNC` + +정확한 계약: + +- source business write와 같은 transaction에서 intent append가 성공해야 한다; +- append 실패 시 critical feature policy에 따라 business transaction도 실패한다; +- append method가 자체 `REQUIRES_NEW` transaction으로 원자성을 깨지 않는다; +- commit 뒤 dispatcher가 claim한다; +- external send는 DB transaction 밖에서 수행한다; +- provider별 attempt와 outcome을 durable하게 기록한다; +- retry/expiry/reconciliation/receipt/retention 정책을 가진다; +- backlog와 terminal outcome을 query/operate할 수 있다. + +`APPENDED_DURABLY`는 provider accepted 또는 recipient outcome을 뜻하지 않는다. + +### 11.3 mode 선택 권한 + +mode는 application code의 `NotificationKindPolicy`만 정한다. + +```text +PASSWORD_RESET_EMAIL -> DURABLE_ASYNC +SECURITY_ALERT_SLACK -> DURABLE_ASYNC +LOW_VALUE_DEV_HINT_SLACK -> BEST_EFFORT_INLINE +``` + +inbound request의 `durable=false`, query parameter, route catalog, settings 또는 arbitrary +application boolean으로 mode를 선택하거나 바꾸지 않는다. config의 `expected-mode`는 application +policy와 일치하는지 검증하는 assertion일 뿐이며 mismatch는 startup failure다. config가 +best-effort를 durable로 “강화”하는 것도 transaction sequencing과 business failure 의미를 +바꾸므로 금지한다. mode 변경은 application policy revision과 해당 feature use case의 transaction +sequence를 함께 변경하고 검증해야 한다. + +### 11.4 transaction sequence와 use case capability + +현재 `TransactionPort.inWrite`는 `PROPAGATION_REQUIRED`이므로 이미 열린 outer transaction에 +참여할 수 있다. 따라서 단순히 `inWrite`가 반환했다는 사실을 physical commit 완료로 간주하면 +안 된다. synchronous `InlineCompleted`를 유지하는 최소 R2는 기존 application +`TransactionPort`에 `inRootWrite` 계약을 추가한다. + +이 port의 persistence implementation은 시작 전에 ambient physical transaction이 없음을 +검사하고, 있으면 business write나 provider call 전에 typed +`NestedRootTransactionRejectedException`으로 fail-fast한다. ambient transaction이 없을 때만 root REQUIRED +transaction을 열고 physical commit이 끝난 뒤 반환한다. + +```java +CommittedBusinessResult committed = + tx.inRootWrite( + () -> { + BusinessResult saved = repository.save(command); + return CommittedBusinessResult.of(saved, factory.create(saved)); + }); + +// physical root commit이 성공한 뒤에만 실행한다. +InlineCompleted inline = + inlineAttemptUseCase.handle(committed.inlineDraft()); +``` + +durable kind는 기존 `TransactionPort.inWrite` 안에서 business write와 +`NotificationIntentAppendPort.append`를 함께 실행해 caller의 ambient transaction에도 +의도적으로 참여한다. append adapter는 `REQUIRES_NEW`를 사용하지 않는다. + +root transaction rollback/commit failure 또는 ambient transaction rejection이면 best-effort +provider call은 0이어야 한다. `outer inWrite -> best-effort feature use case -> outer rollback` +통합 테스트는 provider call 0과 root-boundary rejection을 증명한다. after-commit registration +방식을 향후 도입하면 synchronous `InlineCompleted`를 그대로 재사용하지 않고 별도 +`ScheduledAfterCommit` 계약/card로 설계한다. + +`inRootWrite`의 `TransactionMode`는 여전히 `WRITE`, propagation은 `REQUIRED`, +isolation은 `READ_COMMITTED`다. `NEVER` mode/propagation을 추가하지 않고 adapter가 transaction +시작 전 actual ambient transaction precondition을 검사한다. 구현 시 application-core와 +persistence-jpa의 `CLAUDE.md`/README, fake port와 transaction fitness/integration test를 함께 +갱신해야 한다. + +dispatcher는 `CommandUseCase`를 구현하고 다음 fitness contract를 선언한다. + +```java +@UseCaseCapability( + transactionMode = TransactionMode.WRITE, + idempotency = Idempotency.IDEMPOTENT, + repositoryAccess = RepositoryAccess.WRITE_REPOSITORY, + externalOutboundAllowed = true) +``` + +claim과 finalize는 각각 짧은 `TransactionPort.inWrite` 안에서 실행하고 provider call은 그 +사이에 transaction 밖에서 실행한다. application에는 `*Port`를 구현하는 policy class를 만들지 +않고, inbound use case/application policy/outbound port를 타입과 이름으로 구분한다. + +### 11.5 admission class + +application `NotificationKindPolicy`는 closed `NotificationAdmissionClass`도 선택한다. + +```text +SECURITY_CRITICAL +TRANSACTIONAL +BULK_LOW_VALUE +``` + +이 값은 intent에 저장되고 claim ordering/admission partition의 입력이 된다. config는 각 class의 +concurrency를 낮출 수만 있으며 kind의 class를 바꾸지 못한다. ordering은 +`admission class + bounded aging + next_action_at + notification_id`로 deterministic하게 +정의하고 낮은 class도 maximum starvation window 안에 기회를 얻어야 한다. + +## 12. durable state model + +### 12.1 state를 한 column에 합치지 않는다 + +최소 R2에서 intent 하나는 logical recipient 한 명을 갖고 여러 provider leg와 늦은 feedback을 +가질 수 있으므로 다음을 분리한다. + +```text +NotificationIntent + exactly 1 logical recipient + 1 -> N NotificationDeliveryLeg + 1 -> M immutable NotificationAttempt + 0 -> K immutable NotificationReceiptEvent +``` + +`NotificationDelivery`라는 기존 개념명은 provider leg를 뜻한다. 구현에서는 혼동을 피하기 위해 +`NotificationDeliveryLeg`를 우선 사용한다. intent summary는 leg state와 receipt fact에서 +파생하는 view/projection이다. provider별 부분 성공, fallback 대기 또는 bounce를 하나의 +`SENT` boolean으로 덮지 않는다. + +### 12.2 intent state + +권장 intent control state: + +```text +APPENDED +ACTIVE +COMPLETED +PARTIALLY_COMPLETED +TERMINAL_FAILED +CANCELLED +EXPIRED +``` + +`COMPLETED`는 이름만으로 최종 recipient 성공을 뜻하지 않으며, kind policy가 요구한 submission +objective를 만족했다는 뜻이다. +예를 들어 Slack은 `POSTED_TO_CONVERSATION`, transactional email은 +`PROVIDER_ACCEPTED` 또는 receipt policy에 따른 `DELIVERED_TO_RECIPIENT_MTA`가 될 수 있다. +서로 다른 기준을 같은 dashboard에서 비교할 때 capability card를 함께 표시한다. +후속 bounce/complaint는 immutable adverse fact/projection으로 함께 노출하며 accepted submission +fact를 지우지 않는다. + +### 12.3 delivery control state + +권장 provider leg state: + +```text +BLOCKED +QUEUED +CLAIMED +ATTEMPT_RESERVED +WIRE_AUTHORIZED +PROVIDER_ACCEPTED +RETRY_WAIT +PARKED_BINDING +RECONCILE_WAIT +RECONCILING +PERMANENTLY_REJECTED +SUPPRESSED +POLICY_REJECTED +TERMINAL_INDETERMINATE +CANCELLED +EXPIRED +``` + +`ORDERED_FALLBACK`은 첫 leg만 `QUEUED`, 나머지는 `BLOCKED`로 생성한다. `INDETERMINATE`라는 +모호한 transient/terminal 단일 state는 쓰지 않는다. reconcile 가능한 unknown은 +`RECONCILE_WAIT/RECONCILING`, horizon이 끝난 unknown은 `TERMINAL_INDETERMINATE`다. + +receipt는 단일 mutually-exclusive state가 아니라 immutable fact와 직교 projection으로 둔다. + +```text +SubmissionProjection = + UNKNOWN | ACCEPTED | DEFINITELY_REJECTED + +RecipientTransportProjection = + UNKNOWN | DELAYED | MTA_ACCEPTED | BOUNCED | FAILED_AFTER_ACCEPT + +AbuseProjection = + NONE | COMPLAINED + +ConversationPresenceProjection = + NOT_APPLICABLE | POSTED | UNKNOWN +``` + +provider/card에 없는 projection을 기대하지 않는다. reducer는 verified fact의 도착 순서와 +provider timestamp 순서가 달라도 같은 fact set이면 같은 projection을 만드는 order-independent +함수다. complaint는 MTA accepted/bounced와 공존할 수 있고 submission accepted fact를 지우지 +않는다. + +### 12.4 핵심 transition + +```text +BLOCKED + -> QUEUED [바로 앞 fallback leg가 definite-not-applied로 terminal 되는 같은 transaction] + +QUEUED / RETRY_WAIT + -> CLAIMED(ownerToken, leaseUntil) + -> ATTEMPT_RESERVED(attemptExecutionToken) + -> WIRE_AUTHORIZED + -> PROVIDER_ACCEPTED + | RETRY_WAIT + | PARKED_BINDING + | PERMANENTLY_REJECTED + | RECONCILE_WAIT + | TERMINAL_INDETERMINATE + +RECONCILE_WAIT + -> RECONCILING + -> PROVIDER_ACCEPTED + | DEFINITELY_NOT_APPLIED -> RETRY_WAIT | PERMANENTLY_REJECTED + | STILL_UNKNOWN -> RECONCILE_WAIT | TERMINAL_INDETERMINATE + +PROVIDER_ACCEPTED + + immutable receipt facts + -> orthogonal projections + +PARKED_BINDING + -> QUEUED | EXPIRED | POLICY_REJECTED [audited resume transaction] +``` + +`WIRE_AUTHORIZED` commit이 provider I/O의 local linearization point다. SDK/client 호출은 이 +commit의 성공을 확인한 뒤에만 시작한다. 이 transaction은 expiry, cancellation request, +application policy recheck 결과, technical suppression, 모든 route/provider/account admission +gate의 expected generation이 `ACTIVE`인지와 attempt budget을 다시 검사한다. cancellation, +gate park와 wire authorization 중 먼저 commit된 transition이 이긴다. + +외부 consent/preferences store와 이 DB를 원자화할 수 없으므로 recheck 직후 revoke와 실제 send +사이의 race는 제거할 수 없다. 이 한계를 receipt나 exactly-once 표현으로 감추지 않는다. + +### 12.5 crash classification + +| crash/실패 위치 | 복구 판정 | +| --- | --- | +| claim 뒤 attempt reserve 전 | wire authorization이 없으므로 lease 만료 후 안전하게 re-claim | +| `ATTEMPT_RESERVED` 뒤 | wire authorization이 없으므로 안전하게 requeue | +| `WIRE_AUTHORIZED` commit 뒤 provider call 전 process kill | maybe-send window로 취급 | +| `WIRE_AUTHORIZED` 뒤 response 전 | reconcile 가능하면 `RECONCILE_WAIT`, 아니면 terminal unknown | +| provider accepted response 뒤 local projection update 전 | immutable attempt result를 terminal-once 기록한 뒤 projection 재적용 | +| local accepted commit 뒤 | accepted 유지, duplicate callback idempotent 적용 | +| receipt 저장 뒤 delivery projection update 전 | 같은 transaction이면 함께 rollback; 아니면 inbox 재적용 | + +delivery claim owner token과 immutable attempt execution token을 분리한다. lease를 잃은 worker도 +정확히 받은 provider response를 `(delivery_id, attempt_ordinal)`의 open attempt에 terminal-once로 +append할 수 있다. delivery projection은 현재 owner/version CAS로 merge하고, 이미 더 강한 +accepted/receipt fact를 stale result로 낮추지 않는다. + +`WIRE_AUTHORIZED` attempt는 `attempt_deadline + transport drain/finalize grace`가 지나기 전에는 +reaper가 retry나 fallback을 활성화하지 않는다. 그 뒤에도 provider card가 definite-not-applied를 +증명하지 않으면 reconcile/terminal unknown으로만 이동한다. DB linearization point와 실제 socket +write를 원자화할 수 없으므로 exactly-once를 주장하지 않는다. + +`TERMINAL_INDETERMINATE`는 자동 send/reconcile budget의 종료이지 과거 fact를 지우는 봉인이 +아니다. 나중에 도착한 verified receipt 또는 정확한 late provider response는 accepted projection으로 +단조롭게 해소할 수 있지만, 이를 이유로 새 physical send를 자동 시작하지 않는다. + +### 12.6 fallback atomicity + +predecessor leg의 attempt 결과가 +`submissionCertainty=DEFINITELY_NOT_APPLIED`로 terminal 되는 transaction에서만 정확히 다음 +`BLOCKED` leg 하나를 `QUEUED`로 바꾼다. `(notification_id, strategy_group)`당 active fallback +leg 최대 1개를 partial unique constraint 또는 동등한 invariant로 강제한다. accepted, +bounce, complaint와 terminal indeterminate는 자동 fallback 사유가 아니다. cross-channel +escalation은 별도 application intent다. + +`PARK_BINDING`은 initial kernel에서 fallback을 활성화하지 않는다. parked primary는 active +fallback leg로 남아 secondary를 막는다. future exact fallback card가 park 시 chain advance를 +원하면 predecessor를 definite-not-applied terminal로 닫고 다음 leg 하나를 같은 transaction에서 +활성화하는 별도 reviewed policy/state를 추가해야 한다. + +## 13. planning, routing, fan-out과 fallback + +### 13.1 application intent와 adapter plan 분리 + +application은 logical `NotificationRouteId`를 선택한다. adapter의 immutable catalog/compiler가 +notification-local route/template/provider capability만 검증해 frozen plan을 만든다. + +```text +NotificationDeliveryPlan + routeId + revision + channel + strategy + templateRef/checksum + target descriptors + required capability + attempt/fallback/total-call limits + per-attempt deadline + retry horizon + receipt expectation +``` + +plan은 provider-neutral application value로 돌아오되 target은 bounded opaque reference다. +provider SDK request나 secret/endpoint를 plan에 넣지 않는다. + +cross-module readiness를 이 compiler 하나에 넣지 않는다. + +| 검증 소유자 | 제공하는 framework-free descriptor/책임 | +| --- | --- | +| `adapter-outbound-notification` | route/template/provider local compile 결과와 `NotificationProviderCapabilityDescriptor` | +| `adapter-outbound-persistence-jpa` | schema/store/key support, live/retained revision을 담은 `NotificationStoreCapabilityDescriptor` | +| `adapter-inbound-web` | callback transport/auth/topology의 `NotificationReceiptIngressDescriptor` | +| `application-core` | kind policy와 세 descriptor를 비교하는 pure `NotificationCapabilityCompatibilityValidator` | +| `app-bootstrap` | canonical settings를 provider-neutral send/receipt runtime profile로 분할하고 구현체를 조합 | + +send와 inbound receipt가 공유하는 account/region/configuration-set/topic identity는 bootstrap의 +canonical binding 한 곳에서 파생한다. outbound에는 `NotificationProviderRuntimeProfile`, +inbound에는 `NotificationReceiptIngressProfile`이라는 최소 slice만 전달하고 두 adapter는 서로 +의존하지 않는다. application validator는 secret/SDK/settings type이 없는 descriptor만 받는다. +`ApplicationContext` 탐색, bean-name reflection, sibling adapter 직접 호출이나 adapter에서 use case +orchestration을 하는 방식은 금지한다. + +### 13.2 route strategy + +```text +RouteStrategy = + SINGLE + FAN_OUT_ALL + ORDERED_FALLBACK +``` + +- `SINGLE`: exactly one target이 compile되어야 한다. +- `FAN_OUT_ALL`: target별 독립 provider leg row를 만들며 partial outcome을 보존한다. +- `ORDERED_FALLBACK`: 앞 target이 authoritative definite-not-accepted일 때만 다음 target을 + 활성화한다. + +provider list만 써놓고 strategy를 추론하지 않는다. 빈 list, duplicate target, channel mismatch, +capability mismatch, cycle 또는 상한 초과는 startup에서 실패한다. + +### 13.3 cross-channel fan-out + +email과 Slack을 모두 보내는 것은 대체로 business escalation/communication policy다. + +```text +feature application: + SECURITY_ALERT_EMAIL intent + SECURITY_ALERT_SLACK intent +``` + +하나의 outbound adapter route가 임의로 channel을 바꾸거나 email failure 뒤 Slack으로 넘어가지 +않는다. cross-channel fallback/escalation은 consent, urgency, duplicate tolerance가 다르므로 +application orchestration이 소유한다. + +### 13.4 fallback 안전 조건 + +다음 outcome만 기본 fallback activation을 허용한다. + +- local validation/rendering에서 provider call 전 definite failure; +- admission/quota 정책이 provider call 전 definite rejection을 증명; +- provider가 contract상 request를 수락하지 않았음을 명시; +- authoritative reconciliation이 not-applied를 반환. + +즉 공통 조건은 `submissionCertainty=DEFINITELY_NOT_APPLIED`다. retry 여부나 fault scope만 보고 +fallback하지 않는다. + +다음은 fallback을 기본 차단한다. + +- timeout; +- connection reset after possible write; +- malformed success response; +- provider accepted 뒤 local persistence 실패; +- provider/card에 reconciliation이 없는 unknown outcome. + +duplicate가 business적으로 허용되는 escalation은 `allowIndeterminateEscalation` 같은 global +boolean이 아니라 검토된 notification-kind policy와 별도 intent로 표현한다. + +### 13.5 amplification budget + +각 route는 다음 상한을 모두 고정한다. + +```text +recipientsPerIntent = exactly 1 +maxTargetsPerRecipient +maxPhysicalAttemptsPerDelivery +maxFallbackActivations +maxReconcileCalls +maxTotalProviderCallsPerIntent +maxElapsedRetryHorizon +``` + +config는 code maximum을 낮출 수만 있다. provider leg 수 × retry × fallback × reconcile의 +최악값이 `maxTotalProviderCallsPerIntent`를 넘으면 startup compiler가 거부한다. + +## 14. template, rendering과 localization + +### 14.1 checked-in immutable template가 baseline이다 + +R2 baseline은 versioned local template asset을 repository에 둔다. + +```text +templates/ + password-reset/ + v3/ + ko-KR/ + email-subject.txt + email-text.txt + email-html.html + en/ + ... + schema.json 또는 code descriptor +``` + +Slack은 JSON string template에 arbitrary substitution하는 방식보다 typed Block Kit model +builder를 사용한다. provider-stored SES template는 optional card이며 local rendering과 다른 +version/lifecycle 계약을 갖는다. + +### 14.2 immutable version + +- 같은 `(templateId, version, locale, asset)` content를 in-place 수정하지 않는다; +- build-time manifest에 SHA-256 checksum, schema version, supported locale과 byte limit을 + 기록한다; +- 변경은 새 version이다; +- active intent가 참조한 version은 retention/retry/receipt window 동안 제거하지 않는다; +- rollout에서 모든 live/retained intent가 참조하는 asset과 renderer/canonical + serialization/escaping revision의 load/checksum을 startup validation한다. + +### 14.3 typed parameter + +최선은 notification kind별 typed record/factory다. + +```java +record PasswordResetTemplateParameters( + DisplayName displayName, + ResetLinkReference resetLink, + ExpiryMinutes expiryMinutes) {} +``` + +공통 engine boundary가 필요하면 closed scalar/value set만 허용한다. + +```text +TemplateValue = + SafeText + TrustedAbsoluteLinkReference + LocalDateValue + LocalDateTimeValue + IntegerValue + MoneyValue +``` + +raw HTML, arbitrary JSON subtree, provider block object, unbounded collection은 기본 parameter가 +아니다. template schema는 unknown/missing parameter를 거부하고 unused parameter도 drift로 +검출한다. + +### 14.4 escaping과 injection + +- email HTML text와 attribute/URL context를 구분해 escape한다; +- email header subject/from/reply-to에는 CR/LF와 control character를 허용하지 않는다; +- Slack mrkdwn/plain_text context를 구분한다; +- raw ``, `<@user>`, link target 삽입은 별도 allowlisted value type만 허용한다; +- untrusted URL은 application이 검증한 opaque link reference에서 adapter가 resolve한다; +- template engine의 reflection, arbitrary method/property access, file/network include를 + 비활성화한다; +- output byte/block/element/depth 제한을 provider limit보다 보수적으로 둔다. + +### 14.5 locale + +locale fallback은 JVM default나 host locale을 사용하지 않는다. + +```text +requested exact locale + -> configured language fallback + -> notification-kind default locale + -> startup-validated default asset +``` + +선택된 locale/fallback result는 plan snapshot에 저장한다. timezone이 필요한 값은 business +policy가 명시한 zone을 사용하며 server default timezone을 사용하지 않는다. + +### 14.6 rendering 시점 + +durable baseline은 encrypted typed parameters와 frozen template reference를 저장하고 dispatch +직전에 render한다. + +장점: + +- rendered body의 장기 저장을 피한다; +- provider별 payload limit/format을 attempt 시점에 적용한다; +- key rotation과 redaction surface를 줄인다. + +단, asset revision은 frozen이어야 하며 render result digest를 attempt에 남겨 같은 plan의 drift를 +검출한다. legal/audit상 exact rendered content 보존이 필요한 kind는 별도 encrypted retention +class와 승인을 요구한다. + +### 14.7 attachment + +attachment와 대용량 inline image는 최소 R2 범위가 아니다. 도입 시 fileserver/object-storage +opaque reference, malware scan, size/content-type, recipient authorization, provider upload +lifecycle을 별도 설계한다. arbitrary byte array나 local path를 notification command에 넣지 +않는다. + +## 15. provider attempt contract + +### 15.1 internal provider SPI + +provider SPI는 adapter-internal type이며 개념적으로 다음 책임을 갖는다. + +```text +descriptor() +prepare(renderedMessage, target, attemptContext) +sendOneAuthorizedAttempt(preparedRequest, attemptExecutionToken, deadline) +reconcile(lookupReference, lookupMode, deadline) [optional] +``` + +`prepare`는 provider validation/size mapping을 수행하되 network side effect를 만들지 않는다. +`sendOneAuthorizedAttempt` 한 번은 coordinator 관점의 한 authorized attempt다. 이름이나 구현으로 +wire-level exactly-once를 암시하지 않는다. + +correlation identity를 생성 시점과 의미에 따라 분리한다. + +```text +AttemptCorrelationId // send 전 생성, opaque/non-PII +ProviderClientOperationKey // provider가 native key를 지원할 때만 +ProviderMessageReference // accepted response/event 뒤에만 획득 +ReconciliationLookupMode // PRE_SEND_CORRELATION | CLIENT_OPERATION_KEY + // | MESSAGE_REFERENCE | UNSUPPORTED +``` + +response-loss에서 아직 없는 `ProviderMessageReference`로 reconcile할 수 있다고 가정하지 않는다. + +### 15.2 provider descriptor + +```text +NotificationProviderDescriptor + providerId + channel + submissionSemantics + nativeIdempotencyCapability + reconciliationCapability + receiptCapability + destinationCapability + templateCapability + hiddenRetryMode + maxPayload/recipient constraints + supportedCredentialMode + preSendCorrelationCapability + reconciliationLookupMode +``` + +descriptor는 marketing label이 아니라 readiness/runtime compiler 입력이다. + +### 15.3 attempt outcome + +provider exception을 그대로 던지거나 모든 exception을 transient로 취급하지 않는다. transmission +certainty, retry/운영 조치와 fault scope를 직교 축으로 유지한다. + +```text +SubmissionCertainty = + DEFINITELY_NOT_APPLIED | PROVIDER_ACCEPTED | INDETERMINATE + +RetryDisposition = + RETRY_AT | PARK_BINDING | TERMINAL | NOT_APPLICABLE + +FaultScope = + DELIVERY | ROUTE_REVISION | PROVIDER_BINDING | ACCOUNT + +ProviderAttemptOutcome( + submissionCertainty, + retryDisposition, + faultScope, + stableReasonCode, + retryNotBefore?, + attemptCorrelationId, + providerMessageReference? +) +``` + +raw response body, raw address, token, SDK exception object는 application으로 나가지 않는다. +fallback은 submission certainty만, retry/parking은 retry disposition과 fault scope만 사용한다. +하나의 `permanent` 값으로 invalid recipient와 account credential failure를 합치지 않는다. + +### 15.4 error classification + +| failure | 기본 분류 | +| --- | --- | +| invalid recipient/content, wire call 전 | `DEFINITELY_NOT_APPLIED + TERMINAL + DELIVERY` | +| local template/renderer revision bug | `DEFINITELY_NOT_APPLIED + PARK_BINDING + ROUTE_REVISION` | +| local admission/rate-limit 거부, wire call 전 | `DEFINITELY_NOT_APPLIED + RETRY_AT + PROVIDER_BINDING` | +| provider explicit throttling이 non-acceptance를 보장 | `DEFINITELY_NOT_APPLIED + RETRY_AT + PROVIDER_BINDING` | +| provider auth/scope/config/account rejection이 non-acceptance를 보장 | `DEFINITELY_NOT_APPLIED + PARK_BINDING + PROVIDER_BINDING/ACCOUNT` | +| timeout/connection loss after possible write | `INDETERMINATE + NOT_APPLICABLE + DELIVERY` | +| success status but response decode/contract 실패 | `INDETERMINATE + NOT_APPLICABLE + DELIVERY` | +| provider accepted response | `PROVIDER_ACCEPTED + NOT_APPLICABLE + DELIVERY` | + +HTTP status 하나만으로 transmission certainty를 일반화하지 않는다. 각 provider card에 exact +response/error mapping table과 protocol test를 둔다. + +`PARK_BINDING`은 `FaultScope`에 대응하는 shared admission gate를 닫고 readiness를 내리며 +backlog를 terminal 유실시키지 않는다. + +```text +NotificationAdmissionGate = + (scopeType, scopeRevision) + + state = ACTIVE | PARKED + + generation + + boundedReasonCode/faultScope + + parkedAt/resumedAt +``` + +attempt finalize transaction은 exact outcome fact를 append하고, gate를 expected generation의 +`ACTIVE -> PARKED`로 CAS하며, 현재 leg를 `PARKED_BINDING`으로 바꾼다. concurrent park는 +idempotent하게 같은/higher generation을 관측한다. 다른 node의 eligible scan과 +`WIRE_AUTHORIZED` transaction은 route/provider/account gate가 모두 ACTIVE일 때만 진행하므로 +restart/multi-instance에서도 park가 유지되고 hot-loop하지 않는다. + +gate park보다 먼저 `WIRE_AUTHORIZED`를 commit한 attempt는 이미 권한을 얻었으므로 bounded +completion/indeterminate protocol을 따른다. park는 새 wire authorization을 막지만 이미 시작한 +provider side effect를 recall한다고 주장하지 않는다. + +route revision 수정 또는 credential/account 복구 후 audited resume use case만 readiness/config를 +재검증하고 generation을 증가시켜 ACTIVE로 바꾼다. 같은 transaction/bounded batch에서 parked +leg의 expiry, cancellation, policy/suppression과 attempt budget을 다시 판단해 `QUEUED`, +`EXPIRED` 또는 `POLICY_REJECTED`로 이동한다. initial R2는 park를 fallback activation으로 +해석하지 않는다. + +### 15.5 retry ownership + +coordinator가 다음을 소유한다. + +- attempt authorization; +- attempt ordinal과 total count; +- absolute attempt deadline; +- retry horizon/expiry; +- full-jitter backoff; +- bounded provider `Retry-After`; +- provider/card별 definite/indeterminate 분류; +- fallback activation; +- reconciliation budget. + +SDK default retry는 baseline에서 끈다. SDK를 끌 수 없으면 callback/interceptor로 모든 physical +wire attempt가 attempt journal과 total budget에 계수됨을 증명할 때만 provider card를 승인한다. + +`AttemptCorrelationId`와 지원되는 `ProviderClientOperationKey`는 같은 attempt 동안 안정적으로 +유지한다. `ProviderMessageReference`는 응답/event가 준 뒤에만 저장한다. provider의 documented +idempotency retention보다 local retry horizon이 길면 그 조합은 safe-retry capability가 아니다. + +### 15.6 cancellation + +deadline/cancellation은 local wait를 멈추는 신호이지 provider side effect rollback 증거가 아니다. +wire call 시작 뒤 cancellation되면 card가 definite-not-sent를 증명하지 않는 한 +`TERMINAL_INDETERMINATE` 또는 reconcile path다. thread interrupt만으로 “전송되지 않음”을 +주장하지 않는다. + +## 16. durable persistence와 worker protocol + +### 16.1 기준 topology + +최소 R2는 business source-of-truth와 notification journal이 같은 PostgreSQL transaction manager에 +참여할 수 있다는 가정에 기반한다. + +```text +business application transaction + -> business state write + -> NotificationIntentAppendPort + -> intent + frozen deliveries insert + -> commit + +dispatcher + -> claim in short DB transaction + -> commit claim + -> reserve attempt and commit WIRE_AUTHORIZED after final eligibility recheck + -> render/provider call outside DB transaction + -> append attempt result terminal-once + -> merge delivery projection in short token/version-guarded transaction +``` + +business DB와 journal DB가 다르면 이 원자성은 성립하지 않는다. 그 경우 generic outbox -> broker +-> inbound consumer/inbox topology를 별도 설계하고 현재 R2 baseline이라고 부르지 않는다. +`NotificationIntentAppendPort` 구현은 caller의 REQUIRED transaction에 참여하고 `REQUIRES_NEW`를 +사용하지 않는다. `NotificationDispatchUseCase`의 capability/transaction shape는 §11.4를 +정본으로 한다. + +### 16.2 `notification_intent` + +개념 column: + +```text +notification_id +tenant_scope_digest +notification_kind +channel +route_id +mode +admission_class +source_operation_digest +source_operation_hmac_key_version +idempotency_key_digest/key_version +intent_fingerprint +intent_fingerprint_key_version +policy_revision +route_plan_revision +template_id/version/checksum +renderer/serialization/escaping_revision +locale +recipient_ciphertext/nonce/algorithm/key_ref/key_version +parameter_ciphertext/nonce/algorithm/key_ref/key_version +not_before +expires_at +retention_class +created_at +summary_state/version +``` + +원칙: + +- immutable ciphertext와 crypto metadata를 우선한다; +- summary state는 delivery에서 검증 가능한 projection이다; +- same idempotency digest + same fingerprint는 기존 intent를 반환한다; +- same idempotency digest + different fingerprint는 permanent mismatch다; +- recipient/content plaintext index를 만들지 않는다. + +### 16.3 `notification_delivery_leg` + +```text +delivery_id +notification_id +target_ordinal +strategy_group/strategy_ordinal +opaque_target_ref +provider_binding_revision +state +submission_projection +recipient_transport_projection +abuse_projection +conversation_presence_projection +claim_owner_token +claim_lease_until +row_version +next_action_at +attempt_count +reconcile_count +attempt_correlation_digest/key_version +provider_client_operation_key_digest/key_version [optional] +provider_message_reference_ciphertext/nonce/key_ref/key_version [optional] +provider_message_reference_digest/hmac_key_version [optional] +last_reason_code +accepted_at +terminal_at +``` + +이 row는 recipient row가 아니라 provider leg다. fan-out target마다 별도 row를 만든다. +fallback target은 처음부터 frozen하되 첫 target만 `QUEUED`, 나머지는 `BLOCKED`로 둔다. +`PARKED_BINDING`은 `next_action_at=null`이며 gate가 audited resume되기 전 eligible scan에 +나타나지 않는다. + +### 16.4 `notification_attempt` + +append 중심의 physical evidence: + +```text +attempt_id +delivery_id +attempt_ordinal +attempt_execution_token +reserved_at +wire_authorized_at +attempt_deadline +transport_finalize_grace_until +completed_at +render_hmac/hmac_key_version +provider_binding_revision +credential_generation +authorized_admission_gate_generations +transmission_phase +submission_certainty +retry_disposition +fault_scope +stable_reason_code +attempt_correlation_id +provider_client_operation_key_digest/key_version [optional] +provider_message_reference_ciphertext/digest/key_versions [optional] +``` + +raw provider payload/error response는 저장하지 않는다. credential value가 아니라 bounded +generation/reference만 기록한다. execution token별 exact provider response/result fact는 최대 +하나만 기록한다. reaper의 deadline-expired/unknown observation은 별도 immutable fact이며 exact +response slot을 선점하지 않는다. + +### 16.5 `notification_receipt_event` + +```text +receipt_event_id +provider +provider_account_scope +outer_transport_message_id_digest/hmac_key_version +provider_event_id_digest/hmac_key_version +semantic_event_fingerprint/hmac_key_version +attempt_correlation_digest/hmac_key_version [optional] +provider_message_reference_digest/hmac_key_version [optional] +normalized_event_type +provider_occurred_at +server_received_at +verification_key_revision +state = ORPHAN | APPLIED | DUPLICATE | CONFLICT | QUARANTINED +encrypted_short_lived_evidence [optional] +retention_deadline +``` + +callback이 provider accepted DB update보다 먼저 도착할 수 있으므로 매칭되지 않은 verified +receipt를 버리지 않는다. `ORPHAN` inbox에 bounded하게 저장하고 later attach한다. + +### 16.6 technical suppression table + +email hard bounce/complaint 등 provider lifecycle로 생긴 suppression은 별도 table/port로 둔다. + +```text +channel +recipient_hmac/key_version +scope +reason +source_provider +effective_at +expires_at/null +evidence_ref +version +``` + +business unsubscribe/consent와 합치지 않는다. dispatch 전에 application business eligibility와 +technical suppression을 각각 평가한다. + +### 16.7 `notification_admission_gate` + +multi-instance park/resume의 정본은 process memory나 health cache가 아니라 같은 PostgreSQL의 +shared table이다. + +```text +scope_type = ROUTE_REVISION | PROVIDER_BINDING | ACCOUNT +scope_revision +state = ACTIVE | PARKED +generation +fault_scope +bounded_reason_code +parked_at +resumed_at +row_version +``` + +`(scope_type, scope_revision)`이 PK다. provider leg는 frozen route/provider/account scope를 통해 +필요한 gate를 결정한다. claim eligibility query는 모든 관련 gate가 ACTIVE인 row만 고르고, +`WIRE_AUTHORIZED` CAS는 읽은 gate generation이 그대로 ACTIVE인지 다시 검증한다. + +### 16.7.1 route writer fence와 legacy permit + +rolling cutover 중 legacy synchronous send와 canonical intent admission이 같은 route를 동시에 +받지 않도록, 같은 PostgreSQL에 route writer fence와 bounded legacy permit을 둔다. process-local +boolean이나 배포 순서만으로 single-writer를 주장하지 않는다. + +`notification_route_writer_fence`: + +```text +route_revision PK +owner = LEGACY | CANONICAL +state = ACTIVE | DRAINING +generation +row_version +last_operation_token [optional denormalized FK] +draining_started_at [optional] +switched_at [optional] +``` + +`notification_writer_operation`: + +```text +operation_token PK +operation_sequence UNIQUE [shared cutover sequence, DB-assigned after route fence/global lock] +action = INITIALIZE_LEGACY | INITIALIZE_CANONICAL_FRESH + | BEGIN_DRAIN | TERMINALIZE_EXPIRED_PERMITS | COMPLETE_SWITCH | ABORT_DRAIN +route_set_digest +request_input_digest [server-canonical, never caller-supplied] +fresh_installation_provenance_token [INITIALIZE_CANONICAL_FRESH only] +actor_digest +reason_code +recorded_at [post-lock clock_timestamp(); observation only, not physical commit time] +``` + +`notification_writer_operation_route`: + +```text +operation_token FK +route_revision +expected_owner [server-derived; optional only for the two INITIALIZE actions] +expected_generation [optional only for the two INITIALIZE actions] +result_owner +result_state +result_generation +drain_begin_operation_token [required for TERMINALIZE/COMPLETE/ABORT] +reviewed_old_node_count [BEGIN_DRAIN only] +reviewed_old_node_set_digest [BEGIN_DRAIN only] +reviewed_inventory_manifest_digest [BEGIN_DRAIN only] +transport_proof_requirement = HARD_BOUND_PROVEN | QUIESCENCE_REQUIRED +transport_proof_registry_digest +quiescence_attestation_token [optional except required COMPLETE for unproven transport] +blocking_permit_set_digest [paired with attestation] +affected_permit_count [TERMINALIZE_EXPIRED_PERMITS only] +affected_permit_set_digest [TERMINALIZE_EXPIRED_PERMITS only] +requested_batch_bound [TERMINALIZE_EXPIRED_PERMITS only] +PK (operation_token, route_revision) +``` + +`notification_writer_transport_proof_registry`: + +```text +route_revision +transport_profile_revision +admission_role = ACTIVE | RETIRING +transport_proof_class = HARD_BOUND_PROVEN | QUIESCENCE_REQUIRED +transport_proof_evidence_revision +route_registry_digest +initialization_operation_token +created_at +PK (route_revision, transport_profile_revision) +FK (initialization_operation_token, route_revision) + -> notification_writer_operation_route(operation_token, route_revision) +``` + +`notification_route_writer_permit`: + +```text +permit_token PK +route_revision FK +owner = LEGACY +fence_generation +transport_profile_revision +transport_proof_class = HARD_BOUND_PROVEN | QUIESCENCE_REQUIRED +transport_proof_evidence_revision +state = ACTIVE | RELEASED | EXPIRED_PROVEN | TIMED_OUT_UNPROVEN +holder_instance_digest +acquired_at +wire_deadline_at +expires_at +released_at [optional] +terminalized_at [optional] +terminalization_operation_token [required for EXPIRED_PROVEN | TIMED_OUT_UNPROVEN] +row_version +``` + +`notification_writer_quiescence_attestation`: + +```text +attestation_token PK +attestation_sequence UNIQUE [same cutover sequence, assigned after route fence lock] +route_revision +draining_fence_generation +drain_begin_operation_token +canonical_signed_payload +canonical_signed_payload_digest +signature_algorithm = ED25519 +detached_signature +issuer_identity_digest +issuer_key_revision +issuer_public_key_spki +issuer_public_key_digest +trust_snapshot_canonical_payload +trust_snapshot_digest +acceptance_window_profile_revision +allowed_clock_skew_ms +acceptance_margin_ms +issued_at +expires_at +server_verified = true +server_verified_at +server_verifier_revision +environment_identity_digest +database_system_identifier_digest +database_identity_digest +pre_artifact_digest +deployment_revision_digest +transport_profile_set_digest +transport_proof_registry_digest +blocking_permit_count +blocking_permit_set_digest +permit_holder_count +permit_holder_set_digest +consumer_inventory_identity_digest +consumer_inventory_snapshot_digest +consumer_count = 0 +old_node_count +old_node_set_digest [server-derived from the frozen BEGIN inventory] +old_nodes_quiesced_and_irreversibly_fenced = true +old_node_fence_evidence_set_digest +provider_call_ledger_identity_digest +provider_call_ledger_snapshot_digest +provider_call_ledger_open_count = 0 +quiescence_evidence_manifest_digest +evidence_digest +actor_digest +reason_code +observed_at +``` + +attestation은 일반 switch request body의 boolean이 아니다. authenticated +`POST /api/admin/notifications/routes/{routeId}/writer-quiescence-attestations`가 +`notification:cutover-attest` permission의 method-security-proxied application operation을 +호출한다. request는 reviewed drain generation, opaque attestation token과 독립 deployment +inventory issuer가 서명한 quiescence evidence manifest를 제공할 뿐 old-node/permit/zero-fact +digest를 권위 있게 주장하지 못한다. manifest는 exact environment/DB identity, route/drain +generation, PRE artifact/deployment revision, BEGIN에서 동결한 complete bridge-node inventory, +각 node의 retired/quiesced fact와 재시작을 막는 deployment-generation tombstone 및 legacy +credential/egress의 irreversible revocation, production consumer inventory +identity/snapshot/count 0, provider-call ledger identity/snapshot/open-count 0, 발급/만료 시각과 +issuer/trust snapshot, acceptance-window profile revision, bounded `allowed_clock_skew_ms`와 +minimum `acceptance_margin_ms`를 함께 서명한다. server는 reviewed trust catalog로 Ed25519를 +검증한 뒤 actor, post-lock DB `clock_timestamp()`인 `server_verified_at`이 +`issued_at - allowed_clock_skew <= server_verified_at +<= expires_at - acceptance_margin`인 bounded acceptance window, persisted +transport-proof registry, locked permit set/count/digest와 permit의 distinct +`holder_instance_digest` set을 derive한다. 검증한 canonical payload bytes, detached signature, +issuer identity/key, bounded canonical public-key SPKI bytes/digest, canonical trust-snapshot +payload/digest, issued/expiry/server-verified metadata를 summary와 같은 immutable root row에 +함께 보존한다. frozen BEGIN inventory와 manifest node set은 +exact equality여야 하고 permit holder set은 그 inventory의 subset이어야 한다. omitted permit +holder, extra/omitted inventory node, caller-only digest, unknown +issuer/key/environment/DB/artifact/consumer-inventory/provider-ledger identity 또는 snapshot은 +mutation 없이 거부한다. PRE composition/readiness는 registry와 compiled cutover catalog의 exact +equality를 먼저 검증한다. attestation 기록 시 ACTIVE permit가 남은 상태, nonzero/false fact, +acceptance window 밖의 authorization, token mismatch는 root transaction에서 mutation 없이 +거부하며 success response는 physical commit 뒤에만 쓴다. + +`notification_writer_drain_inventory_manifest`는 BEGIN에서 검증한 signed inventory manifest의 +retained header다. + +```text +drain_begin_operation_token +route_revision +expected_fence_generation +canonical_signed_payload +canonical_signed_payload_digest +signature_algorithm = ED25519 +detached_signature +issuer_identity_digest +issuer_key_revision +issuer_public_key_spki +issuer_public_key_digest +trust_snapshot_canonical_payload +trust_snapshot_digest +acceptance_window_profile_revision +allowed_clock_skew_ms +acceptance_margin_ms +issued_at +expires_at +server_verified = true +server_verified_at +server_verifier_revision +environment_identity_digest +database_system_identifier_digest +database_identity_digest +pre_artifact_digest +deployment_revision_digest +consumer_inventory_identity_digest +consumer_inventory_snapshot_digest +provider_call_ledger_identity_digest +provider_call_ledger_snapshot_digest +old_node_count +old_node_set_digest +inventory_manifest_digest +PK (drain_begin_operation_token, route_revision) +FK (drain_begin_operation_token, route_revision) + -> notification_writer_operation_route(operation_token, route_revision) +``` + +`notification_writer_drain_node_inventory`는 BEGIN에서 server가 검증한 complete old-writer +inventory를 digest만이 아니라 row set으로 동결한다. + +```text +drain_begin_operation_token +route_revision +node_instance_digest +transport_profile_revision +deployment_revision_digest +credential_generation_digest +inventory_manifest_digest +inventory_issuer_key_revision +PK (drain_begin_operation_token, route_revision, node_instance_digest) +FK (drain_begin_operation_token, route_revision) + -> notification_writer_drain_inventory_manifest(drain_begin_operation_token, route_revision) +``` + +BEGIN request는 caller-written node digest 대신 short-lived signed inventory manifest와 opaque +token을 전달한다. 별도 trusted deployment inventory issuer가 exact environment/DB/route, +PRE artifact/deployment revision, complete bridge-node set, consumer inventory +identity/snapshot과 provider-call ledger identity/snapshot을 서명한다. application verifier가 +Ed25519 signature/key revision/trust snapshot/acceptance window와 compiled PRE deployment +identity를 검증하고 canonical row set/count/digest를 만든다. BEGIN child, exact 한 retained +manifest header와 모든 inventory row는 fence CAS와 같은 root transaction에서 insert되며 +UPDATE/DELETE가 금지된다. old-node set이 0개여도 count 0과 canonical empty-set digest를 가진 +signed header 한 건은 반드시 보존한다. 따라서 node row 0개는 허용하지만 BEGIN header 0개는 +허용하지 않는다. manifest에 없지만 permit history에 나타나는 holder, manifest의 +duplicate/unknown node/profile, 서명·환경·DB·artifact/consumer-inventory/provider-ledger +identity 또는 snapshot mismatch는 BEGIN 또는 attestation을 fail closed한다. +inventory manifest도 signed acceptance-window profile/skew/margin을 같은 방식으로 검증하며, +negative/out-of-policy bound, unknown profile 또는 minimum remaining validity 미달은 mutation +없이 거부한다. +“quiesced”는 순간적인 process count 0이 아니다. quiescence manifest의 각 node는 deployment +control-plane이 그 exact instance/deployment generation의 재시작을 금지한 tombstone과 old +transport credential generation 또는 egress identity의 irreversible revocation을 함께 가져야 +한다. canonical credential을 공유해 독립적으로 폐기할 수 없거나 revocation을 되돌릴 수 있거나 +paused process가 기존 credential/connection으로 다시 provider I/O를 시작할 수 있으면 issuer는 +서명할 수 없고 route는 +`QUIESCENCE_REQUIRED/NOT_QUALIFIED`로 DRAINING에 남는다. + +`notification_writer_quiescence_node_evidence`는 verified manifest에서 parse한 per-node +irreversible fence를 보존한다. + +```text +attestation_token +route_revision +drain_begin_operation_token +node_instance_digest +deployment_generation_tombstone_digest +legacy_credential_or_egress_revocation_digest +node_evidence_digest +PK (attestation_token, route_revision, node_instance_digest) +FK (attestation_token, route_revision) -> attestation +FK (drain_begin_operation_token, route_revision, node_instance_digest) + -> notification_writer_drain_node_inventory +``` + +이 row set의 node key는 BEGIN inventory와 exact equality이고 canonical sorted digest는 +attestation의 `old_node_fence_evidence_set_digest`와 같아야 한다. attestation root transaction만 +insert하며 UPDATE/DELETE를 금지한다. overall manifest digest만 저장하고 per-node revocation +coverage를 버리지 않는다. +두 manifest는 raw JSON serialization을 서명하지 않는다. domain-separated +`writer-inventory-manifest-v1` / `writer-quiescence-manifest-v1` length-prefixed canonical field +encoding과 sorted bounded node/fact row set을 Ed25519로 서명하며, verifier는 unknown/duplicate +field, non-canonical order/encoding, oversized set과 algorithm/key downgrade를 거부한다. node, +environment와 ledger identity는 opaque digest이고 PII/credential을 포함하지 않는다. +issuer key ID나 request가 동봉한 임의 public key는 trust anchor가 아니다. reviewed artifact의 +closed trust catalog가 허용 key ID, bounded canonical Ed25519 SPKI bytes/digest, +trust-snapshot digest, current/retiring issuance window와 historical-verification +`ALLOW|REVOKED` 판정을 고정한다. write verifier는 retained SPKI bytes의 digest와 catalog +material을 대조한 뒤 그 key로 signature를 검증한다. startup/COMPLETE verifier도 retained SPKI +bytes로 signature를 다시 검증하고 현재 closed catalog가 exact issuer/key/trust-snapshot digest를 +historical `ALLOW`로 승인하는지 별도로 확인한다. 둘 중 하나라도 실패하거나 catalog가 +`REVOKED`면 fail closed한다. +inventory/quiescence issuer는 application 운영 주체와 분리된 external infrastructure +authority다. production artifact, container, database와 environment에는 issuer private key를 +두지 않는다. deterministic local issuer는 test fixture와 `LOCAL_TEST` evidence grade에서만 +허용하고 production cutover/readiness는 거부한다. + +inventory/attestation의 `issued_at..expires_at`은 evidence를 처음 수락할 수 있는 창이지, +이미 수락한 irreversible fact의 임대 시간이 아니다. Java write verifier가 그 창 안에서 서명과 +trust snapshot을 검증하고 immutable header/row set을 root-commit한 뒤에는 +inventory snapshot과, attestation의 deployment-generation tombstone, legacy credential/egress +irreversible revocation, consumer inventory 0과 provider-call ledger 0 snapshot은 시간이 지나도 +당시의 불변 사실로 남는다. +`COMPLETE_SWITCH`는 stored canonical payload/signature를 Java에서 다시 Ed25519 검증하고 BEGIN +inventory와 per-node irreversible fence exact equality, DRAINING이라 새 permit을 만들 수 없는 +상태와 ACTIVE permit 0, selected registry/ledger identity 및 snapshot equality를 같은 root +transaction에서 lock/recompute한다. attestation의 현재 만료 여부를 다시 묻지 않으며, +constraint timing을 바꾸는 `SET CONSTRAINTS`로 우회할 correctness dependency도 존재하지 않는다. +CAS, operation append와 결과는 한 physical commit으로 원자화하고 acknowledgement 뒤에만 +success를 반환한다. + +`notification_fresh_installation_provenance`는 empty database가 “legacy가 존재한 적 없는 +fresh provisioning”임을 입증하는 별도 immutable authority다. 순간적인 zero snapshot만으로는 +이 authority를 만들 수 없다. external infrastructure issuer는 먼저 exact database resource와 +birth certificate를 대상으로 영구적이고 비가역적인 control-plane +`no-legacy-authority fence`를 다음 순서로 완성해야 한다. + +1. 모든 reviewed legacy deployment generation deny/tombstone, 해당 DB와 provider credential의 + legacy-scoped 신규 발급 disable과 기존 legacy credential revoke, legacy DB ingress와 + provider egress의 + established-flow 차단을 먼저 irreversible enforcement revision으로 commit하고 + read-after-write한다. +2. 그 enforcement가 활성화된 뒤 legacy identity의 existing DB session과 provider + connection/flow를 강제 종료한다. application workload/business consumer/legacy node + inventory, DB open session, provider open flow와 provider-call ledger entry/open count가 모두 + 0인 post-enforcement manifest를 관측한다. 각 source evidence는 fence token과 enforcement + revision을 참조하고 그 read-back보다 같거나 뒤인 causal revision/time을 가져야 한다. + provider ledger snapshot cut은 모든 flow termination acknowledgement보다 뒤여야 하고 + accepted/pending/indeterminate count가 모두 0이어야 한다. provider가 그 authoritative settled + cut을 증명하지 못하면 fresh authorization을 발급하지 않는다. +3. issuer control-plane ledger가 위 enforcement와 post-enforcement zero/termination manifest를 + 하나의 permanent fence token/revision/digest로 seal-commit하고 + `committed_at`/`irreversible=true`를 read-after-write한다. 그 뒤에만 DB-birth authorization을 + 서명한다. + +zero 관측 뒤 deny를 활성화하거나, enforcement와 zero manifest 사이의 causal binding이 없는 +snapshot을 사후 조합하는 것은 금지한다. credential revoke만으로 cached authority를 회수했다고 +추론하지 않으며 fence를 해제하거나 같은 resource에 legacy authority를 다시 발급하는 operation은 +존재하지 않는다. 재시도가 필요하면 새 database resource와 새 birth certificate를 사용한다. +따라서 authorization 발급과 DB provisioning commit 사이에 old application workload나 legacy +node가 시작·재개해 DB/provider authority를 다시 얻거나 cached session/connection을 재사용할 수 +없고, paused old client가 resume해도 provider I/O는 0이다. + +```text +provenance_token PK +fresh_initialization_operation_token UNIQUE +canonical_signed_payload +canonical_signed_payload_digest +signature_algorithm = ED25519 +detached_signature +issuer_identity_digest +issuer_key_revision +issuer_public_key_spki +issuer_public_key_digest +trust_snapshot_canonical_payload +trust_snapshot_digest +acceptance_window_profile_revision +allowed_clock_skew_ms +acceptance_margin_ms +issued_at +expires_at +server_verified = true +server_verified_at +server_verifier_revision +database_resource_canonical_payload +database_resource_identity_digest +database_birth_certificate_canonical_payload +database_birth_certificate_digest +database_system_identifier_digest +database_identity_digest +schema_identity_digest +environment_identity_digest +final_artifact_digest +canonical_route_set_digest +application_workload_inventory_count = 0 +application_workload_inventory_digest +business_consumer_inventory_count = 0 +business_consumer_inventory_digest +legacy_node_inventory_count = 0 +legacy_node_inventory_digest +provider_call_ledger_identity_digest +provider_call_ledger_snapshot_digest +provider_call_ledger_snapshot_cut_revision +provider_call_ledger_snapshot_cut_at +provider_call_ledger_entry_count = 0 +provider_call_ledger_open_count = 0 +provider_call_ledger_indeterminate_count = 0 +no_legacy_authority_fence_token +no_legacy_authority_fence_revision +no_legacy_authority_fence_canonical_payload +no_legacy_authority_fence_digest +no_legacy_authority_fence_committed_at +no_legacy_authority_fence_read_back_at +no_legacy_authority_fence_irreversible = true +no_legacy_authority_enforcement_revision +no_legacy_authority_enforcement_digest +no_legacy_authority_enforcement_activated_at +no_legacy_authority_enforcement_read_back_at +post_enforcement_zero_manifest_canonical_payload +post_enforcement_zero_manifest_digest +post_enforcement_zero_observation_revision +post_enforcement_zero_observed_at +legacy_deployment_generation_deny_set_digest +legacy_deployment_generation_tombstone_set_digest +legacy_database_credential_issuance_disabled = true +legacy_database_credential_revocation_set_digest +legacy_database_credential_revocation_complete = true +legacy_database_session_inventory_digest +legacy_database_session_open_count = 0 +legacy_database_session_termination_evidence_digest +legacy_database_ingress_denied = true +legacy_database_ingress_denial_policy_digest +legacy_database_ingress_blocks_established_flows = true +legacy_provider_credential_issuance_disabled = true +legacy_provider_credential_revocation_set_digest +legacy_provider_credential_revocation_complete = true +legacy_provider_connection_flow_inventory_digest +legacy_provider_connection_flow_open_count = 0 +legacy_provider_connection_flow_termination_evidence_digest +provider_egress_denied = true +provider_egress_denial_policy_digest +provider_egress_blocks_established_flows = true +authorization_digest +``` + +`notification_writer_finalization_discriminator`는 FINAL database의 closed state를 보존한다. + +```text +singleton_key = NOTIFICATION_FINALIZATION +state = AWAITING_SIGNED_FRESH_PROVISIONING + | FRESH_PROVISIONED + | UPGRADE_VALIDATED +fresh_provenance_token [FRESH_PROVISIONED only] +validated_upgrade_history_digest [UPGRADE_VALIDATED only] +state_operation_token +row_version +``` + +V8은 schema/history 생성 전의 provenance를 요구하거나 생성하지 않는다. migration은 먼저 +schema shape를 additive하게 만든 뒤 다음 두 입력만 분류한다. + +- complete upgrade history와 exact canonical fence set이 있으면 전체 retained history를 + structural validation하고 그대로 보존한 뒤 discriminator를 `UPGRADE_VALIDATED`와 validated + history digest로 원자 기록한다; +- notification control/data-plane table과 fence/journal/provenance가 완전히 비었으면 canonical + fence나 initialization history를 seed하지 않고 discriminator만 + `AWAITING_SIGNED_FRESH_PROVISIONING`으로 둔다. V8 재실행은 이 singleton과 나머지 empty state만 + idempotent하게 허용한다. + +fence가 없는데 다른 notification row가 하나라도 있거나 partial/extra fence, incomplete operation +history, provenance 선행 삽입 또는 discriminator와 store shape mismatch가 있으면 migration은 +fail closed한다. V8 SQL은 payload/signature의 non-null·bounded length, digest/count/FK, +nonnegative reviewed skew/margin과 +`no_legacy_authority_enforcement_activated_at +<= no_legacy_authority_enforcement_read_back_at +<= provider_call_ledger_snapshot_cut_at +<= post_enforcement_zero_observed_at +<= no_legacy_authority_fence_committed_at +<= no_legacy_authority_fence_read_back_at +<= issued_at`, +`issued_at - allowed_clock_skew <= server_verified_at +<= expires_at - acceptance_margin` 같은 structural integrity만 검증한다. SQL이 +Ed25519 또는 trust validity를 검증했다고 주장하지 않는다. + +별도 post-migration deployment operation `notificationFreshProvisioning`만 +`AWAITING_SIGNED_FRESH_PROVISIONING`을 끝낼 수 있다. normal runtime startup이 아니라 격리된 +provisioning job이 위 fence를 먼저 commit한 independent infrastructure issuer의 short-lived +signed DB-birth authorization을 받는다. domain-separated +`notification-fresh-provisioning-v1` canonical payload는 exact database resource와 birth +certificate, environment/DB-system/database/schema identity, FINAL artifact digest, canonical +route set, application workload/business consumer/legacy node inventory 각각의 count 0과 canonical +empty-set digest, provider-call ledger identity/snapshot/cut revision/time과 +entry/open/indeterminate count 0, nonce, +deterministic operation token, acceptance-window profile revision, bounded allowed clock skew와 +minimum remaining validity margin을 묶는다. 또한 no-legacy-authority fence의 +token/revision/canonical payload/digest/committed-at/read-back-at/`irreversible=true`, legacy +deployment-generation deny/tombstone set, DB/provider credential issuance-disable와 revocation +set, legacy DB-session termination/open-count 0과 established-flow-blocking ingress policy, +legacy provider connection/flow termination/open-count 0과 established-flow-blocking +provider-egress deny policy를 모두 묶는다. irreversible enforcement +revision/digest/activated-at/read-back-at과, 그 revision 뒤의 zero/termination manifest canonical +payload/digest/observation revision/observed-at도 묶는다. 위 필드는 signed payload와 retained +provenance 양쪽에 exact value로 보존하며 digest만 남기고 원본 authority를 버리지 않는다. Java +verifier는 각 inventory/session/flow/ledger source evidence가 exact fence token과 enforcement +revision의 causal descendant인지, 위 timestamp 순서와 final fence revision/token read-back이 +일치하는지 검증해 pre-fence zero snapshot, sign-before-seal과 cross-revision 조합을 거부한다. +여기서 credential disable/revoke와 ingress/egress deny의 namespace는 signed legacy +deployment-generation set이다. reviewed provisioner와 이후 canonical runtime identity를 +legacy authority로 분류하거나 그 credential 발급을 암묵적으로 허용/차단하지 않는다. + +provisioning은 하나의 physical connection과 하나의 provisioner root transaction에서 반드시 +다음 순서로 실행한다. + +1. exact `SECURITY DEFINER` snapshot/read-lock function + `notification_fresh_provisioning_snapshot_and_lock`이 finalization discriminator, notification + control/data-plane emptiness, DB resource/system/database/schema identity와 existing same-token + result를 lock하고 bounded typed snapshot 및 DB-computed semantic digest를 반환한다. 이 + function은 mutation하지 않으며 lock은 physical commit/rollback까지 유지된다. +2. Java `NotificationFreshProvisioningAuthorizationVerifierPort` 구현이 같은 transaction을 + 열린 채 domain-separated canonical payload, Ed25519 signature, retained issuer SPKI + bytes/digest, artifact closed trust snapshot, issuer/key historical policy, birth certificate와 + committed irreversible fence evidence를 검증하고, 1단계 snapshot과 signed semantic value의 + exact equality를 확인한다. +3. exact `SECURITY DEFINER` apply function `notification_fresh_provisioning_apply`가 같은 + connection/transaction에서만 호출된다. apply는 1단계 lock ownership과 snapshot digest, + new-mutation branch의 AWAITING discriminator와 store emptiness, DB identity, operation + token 및 canonical payload semantic digest를 DB-owned value로 다시 계산·비교한다. 이어 새 + `clock_timestamp()` 값을 한 번 읽어 + `issued_at - allowed_clock_skew <= apply_now <= expires_at - acceptance_margin`을 다시 + 검증하고, 그 exact `apply_now`를 provenance의 `server_verified_at`과 + `INITIALIZE_CANONICAL_FRESH` operation header의 `recorded_at`에 함께 저장한다. 그 뒤에만 + retained provenance, 모든 route child, reviewed initial `ACTIVE/CANONICAL` fence를 insert하고 + discriminator를 `FRESH_PROVISIONED`로 전이한다. + +Java 검증 뒤 process가 pause되어 acceptance window를 벗어나면 3단계 fresh DB-time 검사가 +DML 전에 실패하고 root transaction 전체가 rollback되어 mutation은 0이다. apply statement가 +성공한 시점이 DB-birth authorization 수락의 linearization point이고 row visibility/durability는 +physical commit에서 생긴다. apply 뒤 commit이 지연되어 window가 지나더라도 issuer가 서명 전에 +commit한 birth/fence fact가 영구·비가역이고 그 사이 legacy authority가 부활할 수 없으므로 +safety는 유지된다. commit failure는 mutation과 success response가 0이며 success는 physical +commit acknowledgement 뒤에만 반환한다. rollback 뒤 retry는 두 함수와 Java 검증을 처음부터 +다시 거친다. 같은 token/input replay는 stored result를 반환하고 +token/input/identity/payload-digest mismatch는 mutation 없이 실패한다. + +same-token committed replay는 새 authorization acceptance가 아닌 read-only result recovery +branch다. snapshot function이 exact `FRESH_PROVISIONED` discriminator/provenance/init/fence +equality를 lock/recompute하고 Java가 stored signature/trust/semantic fact와 original +`server_verified_at` acceptance를 다시 검증한 경우에만 apply가 mutation 없이 stored result를 +반환한다. 이 branch는 current wall-clock expiry를 다시 적용하지 않는다. 기존 result가 없는 +첫 apply, partial/mixed result 또는 다른 input/token은 반드시 new-mutation branch를 타거나 +실패하므로 Java 검증 뒤 expiry pause를 우회하지 못한다. + +SQL은 lock, identity, state, exact-set/digest, time-window와 atomic write의 structural +authority일 뿐 Ed25519/trust authority가 아니다. cryptographic authority는 Java verifier +port에 있다. provisioner principal은 위 두 함수의 `EXECUTE`만 가지며 notification table의 +generic `SELECT|INSERT|UPDATE|DELETE`, sequence `USAGE`, DDL과 다른 function `EXECUTE`는 모두 +0이다. provisioner credential을 탈취한 주체가 Java 검증을 건너뛰고 structurally well-formed지만 +forged/invalid payload로 apply를 직접 호출해 row를 commit하더라도 그 row 자체는 readiness +authority가 아니다. 아래 mandatory FINAL read use case의 Java Ed25519/trust/semantic 재검증이 +성공하기 전에는 readiness, canonical admission, worker와 provider I/O가 모두 0이고 mismatch는 +dark/`NOT_QUALIFIED`다. 반대로 valid signed authorization을 직접 apply하더라도 서명 전에 +commit된 irreversible fence fact와 payload가 이미 결합되어 있으며 apply의 lock/state/DB +identity/payload digest/fresh DB-time 검사를 우회할 수 없다. out-of-band DB tampering이나 +constraint bypass까지 관측되면 복구 가능한 authorization으로 추론하지 않고 fail-closed +availability/integrity incident로 격리한다. + +AWAITING 동안 normal startup/readiness는 dark이며 canonical admission, claim, worker와 provider +I/O가 모두 0이다. provisioning commit 뒤를 포함한 모든 FINAL startup/readiness read path는 +`NotificationFinalizationEvidenceReadUseCase`(application read use case) +`-> NotificationFinalizationRetainedEvidenceQueryPort` +`-> PostgreSQL read-only persistence adapter` +`-> NotificationFinalizationEvidenceVerifierPort`의 seam만 사용한다. query adapter는 한 +read-only consistent transaction의 bounded snapshot을 application-owned immutable projection으로 +반환하며 persistence entity나 Spring Data type을 application으로 유출하지 않는다. row/set이 +reviewed bound를 넘으면 truncate하지 않고 fail closed한다. 마지막 단계는 application use case가 +반환된 projection을 verifier port로 넘기는 orchestration이며 persistence adapter가 verifier +implementation에 의존하거나 직접 호출한다는 뜻이 아니다. + +FRESH projection은 discriminator, full provenance, exact +`INITIALIZE_CANONICAL_FRESH` header/route child와 canonical fence set을 읽는다. UPGRADE +projection은 discriminator와 full operation header/route child, transport-proof registry, +permit, fence, signed BEGIN inventory header/node child, selected·superseded·unselected를 포함한 +모든 retained attestation header와 그 permit/holder/node/revocation/consumer/provider-ledger +child row를 읽는다. Java verifier port는 +branch별 canonical payload/signature, bounded issuer SPKI bytes/digest, retained trust-snapshot +payload/digest와 current closed catalog의 historical `ALLOW|REVOKED` 판정을 다시 검증하고, +DB identity, birth/fence facts, discriminator, operation/registry/permit/inventory/attestation 및 +route/fence semantic exact equality를 재계산한다. BEGIN-less/orphan attestation, closing +operation 뒤의 attestation, child set 누락·초과, unselected/superseded row의 signature/trust/ +semantic mismatch도 fail closed한다. 이 전체 검증이 성공한 경우에만 readiness를 연다. +query/read 또는 Java 재검증 오류, forged direct-apply row와 retained-row corruption은 +모두 dark/`NOT_QUALIFIED`, provider I/O 0인 availability/integrity incident이며 SQL structural +success나 provisioning job success를 readiness로 승격하지 않는다. + +app-bootstrap은 composition과 use case 호출만 담당하고 repository, persistence entity, JDBC, +query adapter 또는 verifier 구현을 직접 사용하지 않는다. FINAL cleanup은 transitional write +controller/command/function/grant만 삭제한다. retained provenance/operation/registry/permit/ +inventory/attestation/fence row, 위 read use case/query port/read-only adapter/verifier port와 +구현은 startup/readiness evidence를 위해 계속 보존한다. issuer는 application 운영 주체와 +분리된 external infrastructure authority이며 production artifact, container, database와 +environment에는 private key를 두지 않는다. deterministic local issuer는 test fixture에서만 +허용하고 evidence grade를 `LOCAL_TEST`로 낮추며 production provisioning/readiness는 이를 +거부한다. authorization이 동봉한 임의 key나 unreviewed environment key는 trust anchor가 +아니다. + +PRE bridge 배포에서 legacy fence를 자동 seed하지 않는다. bridge admission을 열기 전에 인증된 +human operator가 batch `INITIALIZE_LEGACY`를 호출한다. canonical binding과 post-migration fresh +provisioning의 route key +SSOT인 canonical route catalog에 PRE-only legacy alias/transport proof를 더한 compiled cutover +route catalog와 별도 reviewed runtime target-generation config를 결합한 +bounded ordered `(route revision, reviewed predecessor generation)` set과 그 set의 digest, +opaque operation token, actor digest와 bounded reason code를 받는다. fence/operation/permit을 +포함한 control table과 intent/delivery/attempt/receipt/suppression/gate/alias를 포함한 data-plane +journal이 모두 빈 경우에만 모든 route의 `ACTIVE/LEGACY@initial` fence, operation header와 route +result rows, route별 current+retiring transport-proof registry snapshot을 같은 root transaction에서 +insert한다. persisted registry의 각 route는 active admission profile이 정확히 하나이고 모든 +row는 같은 route registry digest와 initialization child FK를 가진다. 이 table은 UPDATE/DELETE가 +금지된 retained audit history이며 permit acquire는 proof class/evidence revision을 여기서 row에 +동결한다. direct SQL, application startup hook, +V7 schema migration은 이 초기화를 수행하지 않는다. 일부 route만 초기화하는 sequential +operation도 금지한다. 서로 다른 token의 동시 batch 초기화는 정확히 한 건만 이기며, 같은 token과 +같은 route set/input의 replay는 저장된 전체 결과를 반환하고 token 재사용이나 route-set mismatch는 +fail closed한다. commit failure는 mutation/성공 응답 0이고, commit-success/result-loss retry는 +operation journal의 저장 결과로 복구한다. init 전 absent/partial/extra fence key set에서는 모든 +provider I/O가 0이다. batch init 뒤 route-by-route rollout에서는 exact catalog key set 안에서 +`ACTIVE/LEGACY@predecessor`, `DRAINING/LEGACY@predecessor`, +`ACTIVE/CANONICAL@target`의 closed state만 혼재할 수 있다. legacy node는 첫 상태 route만, +canonical PRE node는 마지막 상태 route만 열고 DRAINING/owner mismatch route는 모두 닫는다. +unrelated owner/state/generation이나 catalog key mismatch는 전체 composition/readiness를 fail +closed한다. + +route revision set과 그 digest는 request authority가 아니다. proxied initializer가 retained +canonical catalog와 PRE-only cutover decorator, reviewed target config에서 server-side로 +derive하고, request는 그 exact key set에 대한 reviewed predecessor +generation map, reason과 opaque token만 제공한다. missing/extra route key 또는 caller가 주장한 +별도 digest는 거부한다. initialization 뒤 PRE artifact는 compiled decorator와 persisted +registry의 route/profile/admission-role/proof-class/evidence-revision/digest exact equality를 +startup과 readiness에서 계속 검증한다. cutover 중 registry 변경은 in-place update로 허용하지 +않는다. 새 profile/evidence revision이 필요하면 기존 sandbox/rollout을 폐기하고 별도 설계된 +registry-version 절차 없이는 진행하지 않는다. + +canonical admission은 business write + intent append와 같은 caller transaction에서 fence row를 +`SELECT ... FOR SHARE` 또는 BEGIN_DRAIN의 update lock과 충돌하는 동등한 tested primitive로 +잠그고, `ACTIVE + CANONICAL + expected generation`을 확인한다. lock은 caller physical commit/ +rollback까지 유지한다. mismatch면 business state와 intent append를 함께 rollback한다. 따라서 +이미 guard를 통과한 canonical transaction이 commit되기 전에 `BEGIN_DRAIN`이 완료되어 반대 +owner를 열 수 없다. + +legacy bridge는 provider I/O 전에 ambient transaction을 거부하는 root transaction으로 fence를 +lock하고 `ACTIVE + LEGACY + expected generation`을 확인한 뒤 opaque permit을 insert한다. physical +commit 전에는 provider를 호출하지 않는다. acquire result는 DB-time `acquired_at`, +`wire_deadline_at`, `expires_at`을 반환한다. wrapper/client는 commit acknowledgement 전부터 잰 +monotonic elapsed budget과 이 DB interval의 보수적인 minimum을 사용하고, +`wire_deadline_at` 이후에는 network I/O를 시작할 수 없다. provider call 뒤 release도 +token/version predicate를 +사용하는 별도 root transaction이다. release 실패나 process crash는 ACTIVE permit을 남기며, +switch가 이를 definite-drained로 오판하지 않는다. + +ownership switch는 sleep이나 provider I/O를 한 transaction/use case 안에 넣지 않고 다음 +audited operation으로 나눈다. + +closed transition matrix는 다음뿐이다. request는 target owner를 받지 않고 action이 server-side +result를 결정한다. + +```text +BEGIN_DRAIN: + ACTIVE/LEGACY@g -> DRAINING/LEGACY@g +TERMINALIZE_EXPIRED_PERMITS: + DRAINING/LEGACY@g -> DRAINING/LEGACY@g (fence unchanged) +COMPLETE_SWITCH: + DRAINING/LEGACY@g -> ACTIVE/CANONICAL@g+1 +ABORT_DRAIN: + DRAINING/LEGACY@g -> ACTIVE/LEGACY@g+1 +``` + +`ACTIVE/CANONICAL`에서 모든 switch/terminalizer action은 mutation 0으로 실패한다. reverse +owner transition, `DRAINING/CANONICAL`과 caller-selected target owner는 존재하지 않는다. + +1. `BEGIN_DRAIN`: exact `ACTIVE/LEGACY@g`에서 fence를 `DRAINING/LEGACY@g`으로 root-commit한다. 같은 row + update lock은 in-flight canonical guard의 share lock과 legacy acquire lock 모두와 충돌한다. + 이 transaction은 trusted deployment inventory issuer가 서명한 exact environment/DB/route/ + PRE-artifact complete old-writer manifest를 server-side 검증하고, canonical node row set과 + count/set/manifest digest를 BEGIN child와 + `notification_writer_drain_node_inventory`에 동결한다. caller가 old-node digest만 보내거나, + persisted permit holder가 manifest에서 누락되거나, manifest에 unknown/duplicate + node/profile이 있으면 mutation 0이다. commit 뒤에는 새 canonical append와 legacy permit + acquire가 원자적으로 거부된다; +2. read-only application operations query로 DB time 기준 ACTIVE permit 수/최장 expiry를 bounded + poll한다. query/COMPLETE는 permit state를 변경하지 않는다. 만료 ACTIVE가 있으면 PRE 전용 + authenticated + `POST /api/admin/notifications/routes/{routeId}/writer-permits/terminalize-expired`가 + `notification:cutover-terminalize` permission의 method-security-proxied application operation을 + 호출한다. exact `DRAINING/LEGACY@drain-generation` fence와 immutable persisted registry를 + lock하고 모든 historical fence generation에서 DB-time상 만료된 ACTIVE를 bounded batch로 + 고른 뒤 exact token/row-version CAS한다. hard-bound evidence가 동결된 profile만 + `EXPIRED_PROVEN`, 현재 R0처럼 hard bound가 없는 profile은 `TIMED_OUT_UNPROVEN`으로 옮긴다. + globally unique operation token, actor/reason, affected tuple set/count/digest와 route result를 + 같은 root transaction의 operation journal에 기록하고 physical commit 뒤에만 성공 응답을 + 쓴다. idempotency lookup은 expired-set selection보다 먼저 수행한다. same token과 같은 caller + input `(route, drain generation, batch bound, actor, reason)`의 replay는 현재 ACTIVE set이 + 달라졌거나 비었어도 저장된 affected result를 반환한다. affected set은 caller input이 아니라 + derived result이고, server-canonical `request_input_digest`와 stored + `requested_batch_bound`로 token의 caller input mismatch만 실패한다. digest는 request에서 + 받지 않고 action/header/route child의 persisted canonical input fields로 재계산한다. 다른 + token은 남은 bounded + batch를 처리할 수 있다. affected digest는 terminalization token을 참조하는 immutable + post-CAS tuple + `(permit token, fence generation, profile, proof class, evidence revision, result state, + terminalized_at, row version)`의 sorted set으로 정의한다. + 이 operation은 provider I/O를 하지 않으며 시간 경과를 drained evidence로 만들지 않는다; +3. `COMPLETE_SWITCH`: exact `DRAINING/LEGACY@g`, expected generation과 route의 모든 fence + generation에 걸친 ACTIVE permit 0을 같은 root transaction에서 검증한다. 그 뒤 PRE evidence는 + 다음 discriminated union 중 정확히 하나여야 한다. + + - `PRE_QUIESCENCE_EVIDENCE`: persisted current+retiring registry row가 하나라도 + `QUIESCENCE_REQUIRED`다. exact BEGIN signed inventory header/row set과 exact 한 selected + quiescence attestation header/per-node evidence가 필수다. COMPLETE는 stored inventory와 + attestation canonical payload/signature/trust snapshot을 Java에서 다시 Ed25519 검증하고, + 모든 `TIMED_OUT_UNPROVEN` tuple, distinct permit holder, inventory node, per-node + deployment-generation tombstone와 legacy credential/egress irreversible revocation, + consumer-inventory identity/snapshot/count 0, provider-call-ledger + identity/snapshot/open-count 0의 exact equality를 lock/recompute한다. attestation이 수락되어 + immutable root transaction으로 기록된 뒤 expiry가 지나도 이 branch의 irreversible fact는 + 무효가 되지 않는다. + - `PRE_HARD_BOUND_EVIDENCE`: persisted current+retiring registry row가 모두 + `HARD_BOUND_PROVEN`이다. exact BEGIN signed inventory header/row set과 registry/evidence + revision을 Java에서 재검증하고, 모든 permit이 `RELEASED|EXPIRED_PROVEN`이며 reviewed + wire-deadline/cancellation contract를 충족해야 한다. selected attestation token과 current + drain에 귀속된 attestation row는 없어야 한다. + + 두 branch가 모두 맞거나 둘 다 아니거나, BEGIN inventory header가 없거나, QUIESCENCE branch의 + attestation이 없거나 HARD_BOUND branch에 attestation이 있으면 mutation 없이 실패한다. old-node + set 0도 signed BEGIN header 한 건이 필수다. attestation은 ACTIVE를 override하지 않으며 + `TIMED_OUT_UNPROVEN`이 0건이어도 QUIESCENCE branch에는 필요하다. authoritative registry는 + initialization과 같은 root transaction에서 동결한 + `notification_writer_transport_proof_registry`이고 PRE runtime은 compiled catalog와 exact + equality를 별도로 강제한다. token, blocking-set digest와 drain BEGIN token을 COMPLETE operation + route row에 기록한 뒤에만 owner를 `CANONICAL`, generation을 `g+1`, state를 `ACTIVE`로 CAS한다. + fence CAS와 operation append는 같은 physical commit이고 acknowledgement 뒤에만 success를 + 반환한다. `expires_at`이 지났다는 이유로 ACTIVE/TIMED_OUT_UNPROVEN row나 human gate를 + query에서 암묵적으로 제외하지 않는다; +4. 취소가 필요하면 exact `DRAINING/LEGACY@g`에서 `ABORT_DRAIN`을 audited root transaction으로 + 수행해 `ACTIVE/LEGACY@g+1`의 새 generation을 + 발급한다. 기존 generation permit을 재활성화하거나 provider call을 replay하지 않는다. + +모든 operation은 fence CAS와 같은 root transaction에서 append-only operation header/route +journal에 exact route set/action, route별 expected owner/generation과 결과 +owner/state/generation, globally unique opaque operation token, authenticated actor digest와 +bounded reason code를 기록한다. 모든 operation의 `recorded_at`, permit `terminalized_at`과 +attestation `observed_at`은 관련 global/route fence lock을 얻은 뒤 PostgreSQL +`clock_timestamp()`으로 채운다. transaction 시작 시각인 `CURRENT_TIMESTAMP`/ +`transaction_timestamp()`는 금지한다. DB-assigned `operation_sequence`와 attestation의 +`attestation_sequence`는 같은 sequence에서 batch initialization 또는 해당 route fence lock을 +획득한 뒤 발급하므로 같은 route의 committed cutover event total order다. +rollback gap과 다른 route 사이 gap은 허용하지만 duplicate/order reversal은 허용하지 않는다. +initialization은 여러 route child를, 이후 switch는 exact 한 +route child를 갖는다. fence의 optional +`last_operation_token`은 각 mutation과 같은 transaction에서 해당 header로 갱신하는 조회/검증 +포인터일 뿐 audit/idempotency SSOT가 아니다. 같은 token과 +동일 route set/input의 replay는 오래된 operation이어도 journal의 committed result를 반환하며, +token을 다른 route set/action/input에 재사용하면 fail closed한다. commit failure/commit-result loss는 추측으로 +성공 보고하지 않으며 direct SQL cutover는 이 계약을 우회하므로 금지한다. +header `route_set_digest`는 sorted exact child route set에서, `request_input_digest`는 +action-specific persisted header/child input에서 server-side로 계산한다. orphan header/child, +empty child set, header/child action 불일치와 두 digest mismatch는 runtime과 V8 모두 거부한다. +두 digest는 domain-separated, versioned `writer-operation-route-set-v1` / +`writer-operation-input-v1` length-prefixed SHA-256 canonicalization을 사용하고 raw PII/secret을 +입력에 넣지 않는다. +`TERMINALIZE_EXPIRED_PERMITS`는 fence를 mutate하지 않으므로 `last_operation_token`을 갱신하지 +않고, affected permit의 `terminalization_operation_token`만 journal header를 참조한다. +terminal permit은 composite +`(terminalization_operation_token, route_revision)` FK로 exact operation child를 참조한다. +그 child는 action/route/drain generation과 unchanged `DRAINING/LEGACY` result를 기록하고, +affected count/digest는 그 token을 참조하는 post-CAS permit tuple set과 exact equality다. + +permit expiry는 live provider call이 끝났다는 증거가 아니다. legacy transport가 permit +root-commit에서 DB-time으로 동결한 absolute `wire_deadline_at`, +`wire_deadline_at + finalize margin <= expires_at`, deadline 뒤 network-start 거부, +connection close/cancellation이 wire deadline까지 확정되는 client contract와 process +pause/resume을 integration test로 증명한 경우에만 `EXPIRED_PROVEN`을 drained 판단에 포함한다. +특히 acquire commit 직후 process가 permit expiry 이후까지 pause되었다가 resume하면 provider +call은 0이어야 하고, deadline 직전 resume한 call도 그 absolute deadline까지 종료되어야 한다. +이 증거가 하나라도 없으면 profile은 `QUIESCENCE_REQUIRED`다. 현재 legacy +seam처럼 그 hard bound가 없으면 authenticated operator가 production consumer 0, old-node 완전 +quiesce와 provider-call ledger 0의 signed durable evidence를 append-only attestation으로 +root-commit해야 한다. `COMPLETE_SWITCH` command가 exact attestation token을 제공하지 않거나, +route/generation/profile-set/blocking-permit-set/signed fact가 맞지 않으면 DB CAS 자체가 +실패한다. permit release/timeout terminal transition으로 snapshot이 달라지면 새 attestation이 +필요하다. evidence acceptance TTL만 지나거나 +runbook checkbox만 확인해 호출이 끝났다고 추정하지 않는다. +permit state와 동결 proof class는 교차 불변식이다. +`EXPIRED_PROVEN`은 `HARD_BOUND_PROVEN`에만, +`TIMED_OUT_UNPROVEN`은 `QUIESCENCE_REQUIRED`에만 허용한다. `ACTIVE|RELEASED`는 양쪽 proof +class에 허용되지만 COMPLETE/V8은 persisted registry와 tuple equality를 다시 검증한다. + +legacy/operator code가 제거된 final artifact는 fresh database에서도 canonical fence를 얻어야 +하지만 “notification table이 비었다”는 조건만으로 fresh를 추론하지 않는다. cleanup 전용 +additive V8은 database를 canonical-ready로 직접 seed하지 않고 다음 structural classification만 +수행한다. + +- notification control/data-plane, fence, journal과 provenance가 완전히 비면 exact 한 + `AWAITING_SIGNED_FRESH_PROVISIONING` discriminator를 남긴다. 이 state에서는 fence와 + `INITIALIZE_CANONICAL_FRESH` history가 0이다; +- complete PRE upgrade history와 reviewed canonical route-revision key set의 + `ACTIVE/CANONICAL@g_final` fence가 있으면 history를 검증·보존하고 exact 한 + `UPGRADE_VALIDATED` discriminator와 `validated_upgrade_history_digest`를 남긴다. + +fence가 없는데 notification row가 하나라도 있거나 partial/extra fence/history, 선행 provenance, +두 classification의 혼합 또는 discriminator mismatch가 있으면 V8은 실패한다. empty store를 +upgrade나 fresh canonical state로 추론하지 않는다. V8 뒤 `notificationFreshProvisioning`이 +issuer가 먼저 commit한 irreversible no-legacy-authority fence를 포함한 signed DB-birth +authorization을 위 snapshot/read-lock -> Java verifier -> apply protocol로 검증하고 한 physical +transaction에서 provenance, `INITIALIZE_CANONICAL_FRESH` 전체 route history와 initial canonical +fence를 만든 경우에만 `FRESH_PROVISIONED`가 된다. + +FINAL startup/readiness evidence는 다음 closed union 중 정확히 하나다. + +- `FINAL_FRESH`: discriminator가 `FRESH_PROVISIONED`이고 exact 한 signed fresh provenance, + provenance token을 참조하는 exact 한 `INITIALIZE_CANONICAL_FRESH` header/전체 route child, + reviewed initial `ACTIVE/CANONICAL` fence set이 있다. provenance의 DB resource/birth + certificate, zero inventories/provider ledger와 committed irreversible + no-legacy-authority-fence field가 canonical signed payload와 exact equality여야 한다. + upgrade-history discriminator와 `INITIALIZE_LEGACY` history는 없어야 한다; +- `FINAL_UPGRADE`: discriminator가 `UPGRADE_VALIDATED`이고 V8이 동결한 exact + `validated_upgrade_history_digest`, audited `INITIALIZE_LEGACY`에서 route별 + `ACTIVE/CANONICAL@g_final`로 끝난 complete history와 fence set이 있다. fresh provenance와 + `INITIALIZE_CANONICAL_FRESH` history는 없어야 한다. + +둘 다 맞거나 둘 다 아니거나 반대 branch의 marker/history가 섞이면 startup/readiness는 dark다. +`AWAITING_SIGNED_FRESH_PROVISIONING`도 정상 migration completion state일 수 있지만 runtime +canonical admission/claim/provider I/O는 0이고 provisioning 전에는 ready가 아니다. 위 +application read use case -> retained-evidence query port -> persistence read-only adapter -> +verifier port seam이 한 bounded consistent snapshot에서 이 closed union을 판정한다. FRESH +branch는 discriminator/provenance/init child/fence를 모두 읽고 provisioning write와 이후 모든 +startup에서 retained provenance의 payload/signature/SPKI/trust snapshot, DB birth/fence fact와 +semantic exact equality를 Java로 재검증한다. UPGRADE branch는 discriminator와 full +operation/registry/permit/inventory row, 모든 selected·superseded·unselected attestation +header/child 및 fence를 읽고 retained BEGIN inventory와 모든 quiescence attestation의 +payload/signature/SPKI/trust snapshot과 semantic exact equality를 Java로 재검증한다. V8과 SQL +constraint는 canonical +bytes/digest/count/FK/time shape만 검증하며 cryptographic validity의 authority가 아니다. + +upgrade database에서는 reviewed route-revision key set exact equality를 요구하되 generation은 +route별 audited `g_final`일 수 있다. 모든 fence의 `ACTIVE/CANONICAL`, 각 fence와 최신 +`last_operation_token -> COMPLETE_SWITCH` route result의 owner/state/generation 일치를 +검증한다. upgrade의 transport-proof authority는 삭제될 PRE catalog나 attestation +self-assertion이 아니라 retained immutable +`notification_writer_transport_proof_registry`다. registry route key set은 fence/canonical key +set과 exact equality이고 route마다 ACTIVE profile이 정확히 하나여야 한다. 모든 registry row는 +같은 route digest와 initialization-operation child FK를 가져야 하며, 모든 permit의 frozen +profile/proof-class/evidence-revision, attestation registry digest와 COMPLETE child의 +proof-requirement/registry digest가 이 snapshot과 exact equality여야 한다. permit이 0인 +`QUIESCENCE_REQUIRED` route도 이 retained row 때문에 누락되지 않는다. ACTIVE permit은 항상 +실패하고 proof-class/state 교차 불변식 위반도 실패한다. + +각 route의 마지막 COMPLETE는 PRE evidence closed union을 replay한다. + +- `PRE_QUIESCENCE_EVIDENCE`는 signed BEGIN inventory header와 exact inventory row set, selected + attestation의 exact BEGIN FK/profile registry/blocking permit set/distinct holder set/old-node + set, per-node deployment-generation tombstone와 legacy credential/egress irreversible + revocation, consumer-inventory identity/snapshot/count 0, provider-ledger + identity/snapshot/open-count 0, COMPLETE child의 selected token/blocking-set digest를 요구한다. + `BEGIN.operation_sequence < attestation.attestation_sequence < + COMPLETE.operation_sequence`여야 한다. + `issued_at - allowed_clock_skew <= server_verified_at + <= expires_at - acceptance_margin`은 attestation을 처음 기록한 acceptance가 유효했음을 + 보존한다. cleanup 현재 시각이 + `expires_at` 뒤여도 immutable tombstone/revocation과 zero-ledger snapshot은 유효하다. +- `PRE_HARD_BOUND_EVIDENCE`는 signed BEGIN inventory header와 exact inventory row set, 모든 + current+retiring registry row의 `HARD_BOUND_PROVEN`, 모든 permit의 + `RELEASED|EXPIRED_PROVEN`과 reviewed deadline/cancellation evidence revision을 요구한다. + 마지막 drain-BEGIN에 selected attestation이나 attestation row가 있으면 실패한다. + +두 PRE branch가 모두 맞거나 둘 다 아니면 실패하고 old-node row가 0개인 BEGIN도 signed header +한 건을 요구한다. Java startup verifier는 stored canonical payload/signature/trust snapshot과 +semantic header/child row exact equality를 재검증한다. unknown issuer/key, trust snapshot +mismatch, payload/signature 불일치, reversible fence 또는 ledger identity/snapshot mismatch는 +upgrade를 중단한다. + +`EXPIRED_PROVEN|TIMED_OUT_UNPROVEN` permit은 exact route의 +`TERMINALIZE_EXPIRED_PERMITS` child를 composite FK로 참조해야 한다. V8은 action/route/drain +BEGIN FK/generation, unchanged DRAINING/LEGACY result, +`expires_at <= terminalized_at`, +`BEGIN.operation_sequence < terminalizer.operation_sequence < +first_closing_ABORT_or_COMPLETE.operation_sequence`와 child affected count/digest를 그 token을 +참조하는 immutable post-CAS permit tuple set에서 재계산한다. `recorded_at`/`terminalized_at`은 +post-lock `clock_timestamp()` shape와 expiry sanity를 보조 검증할 뿐 causal SSOT가 아니다. +wrong action/route/set, orphan token, expiry 전 또는 drain close 뒤 terminalization은 실패한다. +fence, permit, signed inventory/attestation header와 child row, per-node quiescence fence evidence, +fresh provenance, finalization discriminator, proof registry와 operation history를 모두 +byte-for-byte 보존한다. + +upgrade에서는 V8이 operation과 attestation이 공유하는 DB sequence를 route별로 replay한다. 허용 +operation history는 +`INITIALIZE_LEGACY -> (BEGIN -> TERMINALIZE* -> ABORT)* -> BEGIN -> TERMINALIZE* -> COMPLETE`이고 +COMPLETE는 해당 route의 마지막 mutation이어야 한다. 각 transition의 expected/result +owner/state/generation과 drain-BEGIN FK가 closed matrix와 일치해야 하며 terminalizer는 +DRAINING self-transition일 뿐이다. CANONICAL 뒤 BEGIN/TERMINALIZE/ABORT/두 번째 COMPLETE, +sequence duplicate/collision/reversal, missing predecessor와 journal replay 결과/fence/ +latest-mutation-pointer 불일치는 모두 실패한다. replay 전에 모든 header를 child와 양방향 +대조한다. 두 INITIALIZE header는 reviewed canonical route set과 정확히 같은 child set을, 모든 +non-init header는 exact 한 route child를 가져야 한다. header `route_set_digest`는 sorted child +route set과, `request_input_digest`는 action-specific persisted input과 재계산 equality여야 한다. +orphan/extra/empty header 또는 child는 모두 실패한다. + +immutable attestation은 permit row-version 변화 뒤 재발급되거나 ABORT로 선택되지 않을 수 있다. +same drain-BEGIN FK를 가지며 +`BEGIN.operation_sequence < attestation.attestation_sequence < +first_closing_ABORT_or_COMPLETE.operation_sequence`이고 signed payload/header/child 구조가 유효한 +superseded/unselected row만 audit history로 보존·허용한다. latest COMPLETE의 QUIESCENCE branch가 +선택한 token만 exact set을 full 검증하고 HARD_BOUND branch의 latest drain에는 attestation을 +금지한다. BEGIN 없는 forged row, ABORT/COMPLETE 뒤의 sequence, missing selected row와 selected +token/digest mismatch는 실패한다. LEGACY/DRAINING, ACTIVE permit, missing/extra route, +latest-operation mismatch, missing/mismatched signed inventory/attestation, BEGIN-less forged +history, discriminator branch mismatch 또는 missing-fence-with-any-nonempty-journal은 fail +closed한다. app-bootstrap startup도 compiled canonical route set과 persisted fence set/expected +generation exact equality를 검증한다. + +### 16.8 database constraint와 index + +다음은 migration과 PostgreSQL integration test가 강제할 최소 invariant다. + +- 모든 table은 opaque PK를 갖고 child row는 parent에 FK를 둔다. intent hard delete는 live + delivery/attempt/receipt가 있으면 금지하고 retention worker가 명시된 purge order를 따른다; +- intent source dedupe alias는 + `(tenant_scope, purpose, hmac_key_version, digest)` unique이며 stable semantic owner 하나만 + 가리킨다; +- `notification_delivery_leg(notification_id, target_ordinal)` unique; +- `notification_attempt(delivery_id, attempt_ordinal)` unique; +- `notification_attempt(attempt_execution_token)` unique와 exact provider-result fact 최대 1개; +- delivery당 open attempt 최대 1개 partial unique; +- `(notification_id, strategy_group)`당 `QUEUED/CLAIMED/ATTEMPT_RESERVED/WIRE_AUTHORIZED/ + RETRY_WAIT/PARKED_BINDING/RECONCILE_WAIT/RECONCILING` fallback leg 최대 1개 partial unique; +- admission gate PK/CAS와 leg park transition은 같은 finalize transaction에서 갱신한다; +- route writer fence는 route당 한 row이고 `(route_revision, generation, owner, state, + row_version)` predicate로 CAS한다. `BEGIN_DRAIN` 뒤 새 legacy permit은 0이어야 한다; +- writer operation token은 header에서 globally unique고 route child PK는 + `(operation_token, route_revision)`이다. batch 초기화와 모든 single-route ownership CAS는 + append-only header/route rows를 같은 root transaction에 기록하고, same-token/same-input + replay는 저장된 전체 committed result를 반환하며 token/route-set/input mismatch는 mutation + 없이 거부한다. operation과 attestation은 같은 DB sequence를 관련 fence/global lock 뒤 + 발급받고 두 table 사이 collision까지 V8이 거부해 route별 committed causal total order를 + 제공한다. 모든 cutover timestamp는 lock 뒤 `clock_timestamp()`으로 기록하고 + `CURRENT_TIMESTAMP`/`transaction_timestamp()`를 causal authority로 사용하지 않는다; +- BEGIN child의 reviewed complete node count/set/manifest digest는 같은 transaction의 exact 한 + immutable signed inventory header와 drain-node inventory row set에 exact equality다. node 0도 + header 한 건과 canonical empty-set digest가 필수다. inventory와 attestation header는 bounded + canonical payload/signature/issuer identity/key/bounded canonical public-key SPKI bytes/digest/ + canonical trust-snapshot payload/digest, signed acceptance-window profile/skew/margin과 + issued/expiry/server-verified metadata, environment/DB/artifact, consumer-inventory identity/snapshot과 + provider-ledger identity/snapshot을 보존한다. SQL은 non-null/length/digest/count/FK와 + nonnegative reviewed skew/margin, + `issued_at - allowed_clock_skew <= server_verified_at + <= expires_at - acceptance_margin`을 강제하고 Java write/startup verifier가 + Ed25519와 trust catalog를 재검증한다. permit의 distinct holder set이 inventory의 subset이 + 아니거나 attestation manifest node set이 inventory와 exact equality가 아니면 실패한다; +- transport-proof registry는 batch initialization의 operation child와 같은 root transaction에서 + exact route/current+retiring profile set으로만 insert한다. route마다 ACTIVE admission profile은 + 정확히 하나이고 registry digest는 모든 route child row에서 일치해야 한다. UPDATE/DELETE는 DB + constraint/trigger와 adapter surface 모두에서 금지한다. permit의 frozen profile/proof + class/evidence revision, attestation와 COMPLETE child의 registry digest는 이 retained snapshot과 + exact equality여야 한다; +- writer permit token은 globally unique다. active permit lookup은 + `(route_revision, fence_generation, owner, state, expires_at)` index를 사용하고 release/expiry는 + exact token + row version으로 한 번만 전이한다. hard-bound가 없으면 timeout은 + `TIMED_OUT_UNPROVEN`이고 자동 drained terminal이 아니다. + `EXPIRED_PROVEN => HARD_BOUND_PROVEN`, + `TIMED_OUT_UNPROVEN => QUIESCENCE_REQUIRED`를 DB CHECK로 강제하며 반대 조합을 insert/update할 + 수 없다. timeout terminal state는 `(terminalization_operation_token, route_revision)` composite + FK, non-null terminalized_at과 `expires_at <= terminalized_at`을 요구하고 ACTIVE/RELEASED는 + terminalization fields를 금지한다. `COMPLETE_SWITCH`와 operations + snapshot은 같은 locked fence route의 LEGACY ACTIVE/TIMED_OUT_UNPROVEN permit을 모든 + generation에 걸쳐 본다. ACTIVE는 항상 0이어야 하고, attestation은 exact + TIMED_OUT_UNPROVEN set을 덮는다. unproven transport는 그 set이 0이어도 exact signed + quiescence attestation이 필수다; +- quiescence attestation token은 globally unique하고 immutable하다. exact route/drain + generation/BEGIN FK, bounded transport-profile set digest, blocking permit count/set digest, + distinct holder set, frozen old-node set, exact per-node tombstone/revocation row-set digest, + signed consumer/provider-ledger zero-fact evidence와 retained canonical signature header를 가지며 + `COMPLETE_SWITCH` operation child가 token과 same blocking-set digest를 FK/constraint로 + 참조한다. acceptance window 밖의 새 evidence, mismatched/reused evidence, caller-authored + inventory digest 또는 multi-profile/node set 일부만 덮는 evidence는 mutation 없이 거부한다. + root-committed attestation은 per-node irreversible tombstone/revocation과 provider-ledger zero + snapshot의 durable proof이므로 이후 wall clock expiry로 무효화하지 않는다; +- COMPLETE는 exact 한 PRE evidence branch만 허용한다. + `PRE_QUIESCENCE_EVIDENCE`는 BEGIN inventory+selected signed attestation+per-node irreversible + evidence+ACTIVE permit 0이고, `PRE_HARD_BOUND_EVIDENCE`는 BEGIN inventory+all-hard-bound + registry+safe terminal permit+ACTIVE permit 0이며 current drain attestation은 0이다. SQL은 + structural exact-set/FK/CHECK를 강제하고 Java transition verifier가 retained Ed25519 payload를 + 재검증한다. correctness는 constraint execution timing에 의존하지 않는다; +- V8은 complete upgrade를 `UPGRADE_VALIDATED`로 보존하거나 완전히 empty store를 + `AWAITING_SIGNED_FRESH_PROVISIONING`으로 남길 뿐 canonical fence를 seed하지 않는다. any + nonempty missing-fence/partial-history store는 실패한다. 별도 `notificationFreshProvisioning`만 + independent issuer가 먼저 commit한 irreversible no-legacy-authority fence를 포함한 signed + DB-birth authorization을 Java로 검증한다. 같은 provisioner transaction의 exact + snapshot/read-lock과 apply function 사이에서 검증하며 apply의 fresh DB time이 window 안일 + 때만 provenance, exact 한 `INITIALIZE_CANONICAL_FRESH` batch, 모든 reviewed canonical fence와 + `FRESH_PROVISIONED` discriminator를 만든다. FINAL은 `FINAL_FRESH(provenance)`와 + `FINAL_UPGRADE(validated history discriminator)`의 closed union이다; +- database role topology는 정확히 `notification_migrator`, `notification_runtime`, + `notification_provisioner` 세 개다. `notification_migrator`는 notification schema object와 + 모든 `SECURITY DEFINER` function을 소유하고 Flyway에서만 사용하는 dedicated LOGIN + migration-only principal이며 일반 application/provisioning datasource가 아니다. + `notification_runtime`은 non-owner다. PRE에서는 exact initializer/switch/permit/ + terminalizer/attestation function `EXECUTE`를 method-security로 보호된 application use case + path를 통해서만 사용하고, retained evidence bounded projection `SELECT`와 canonical fence + read/lock에 필요한 최소 권한만 가진다. FINAL migration은 runtime의 transitional function + `EXECUTE`, retained cutover audit/control `INSERT|UPDATE|DELETE`와 cutover sequence `USAGE`를 + 명시적으로 revoke한다. PRE와 FINAL 모두 normal runtime의 active-release + intent/delivery/attempt/receipt 등 operational journal에 필요한 exact DML/SELECT와 그 전용 + sequence 권한은 별도 least-privilege grant로 유지한다; +- `notification_provisioner`는 + `notification_fresh_provisioning_snapshot_and_lock`과 + `notification_fresh_provisioning_apply` 두 function의 `EXECUTE`만 가진다. retained + audit/control/operational journal generic `SELECT|INSERT|UPDATE|DELETE`, 모든 sequence + `USAGE`, DDL, transitional function과 그 밖의 function `EXECUTE`, role membership은 0이다. + normal runtime은 이 두 fresh function의 `EXECUTE`를 갖지 않는다; +- 모든 `SECURITY DEFINER` function은 migration-only `notification_migrator`가 소유하고 + `SET search_path = pg_catalog`, fully-qualified object name, bounded typed input/output, + dynamic SQL 0을 강제하며 `PUBLIC EXECUTE`를 revoke한다. 특히 fresh 두 function은 같은 + physical provisioner transaction/connection의 lock protocol을 강제하고 apply가 state, + identity, snapshot/payload semantic digest와 fresh `clock_timestamp()` acceptance window를 + 재검산한다. SQL은 cryptographic validity를 주장하지 않으며 Ed25519/trust 검증은 두 function + 사이의 Java verifier port가 담당한다; +- provider receipt는 + `(provider_binding_revision, event_digest_key_version, provider_event_id_digest)`와 + `(provider_binding_revision, semantic_digest_key_version, semantic_event_fingerprint)`로 + outer retry와 의미상 중복을 각각 차단한다; +- provider message reference/correlation lookup은 binding revision과 HMAC key version까지 scope에 + 포함하고 하나의 open delivery/attempt와만 매칭한다; +- eligible scan index는 최소 `(state, next_action_at, admission_class, notification_id)`, + stale lease scan은 `(state, claim_lease_until)`, orphan attach는 provider binding과 + correlation/message-reference digest를 선두로 둔다; +- constraint conflict를 catch-and-ignore로 처리하지 않고 typed idempotent/conflict 결과로 + mapping한다. + +### 16.9 claim protocol + +claim query는 eligible state, `next_action_at`, expiry, 모든 admission gate ACTIVE와 bounded +batch를 사용하고 PostgreSQL +`FOR UPDATE SKIP LOCKED` 또는 동등한 tested primitive를 사용할 수 있다. + +claim/finalize update의 필수 predicate: + +```text +WHERE delivery_id = ? + AND claim_owner_token = ? + AND state = expected_state + AND row_version = expected_version +``` + +영향 row가 정확히 1이 아니면 stale owner/conflict다. stale worker는 provider result를 새 owner의 +state 위에 덮지 못한다. 다만 정확한 provider response는 claim owner와 독립된 immutable +`attempt_execution_token`으로 해당 open attempt에 terminal-once append할 수 있고, projection +merge만 현재 owner/version CAS를 사용한다. + +claim transaction 안에서 provider call/render-heavy work를 하지 않는다. +`WIRE_AUTHORIZED` transaction은 gate를 `(scope_type, scope_revision)` canonical order로 +lock/read해 deadlock을 피하고, authorized generation set을 attempt fact에 남긴다. + +### 16.10 lease expiry와 reaper + +- `CLAIMED`와 `ATTEMPT_RESERVED`는 `WIRE_AUTHORIZED` evidence가 없으므로 lease 만료 뒤 안전하게 + requeue할 수 있다; +- `WIRE_AUTHORIZED` 이후 owner를 잃으면 attempt deadline과 transport/finalize grace가 끝나기 + 전에 retry/fallback을 활성화하지 않는다; +- grace 뒤 정확한 result fact가 없으면 retry queue가 아니라 provider card에 따라 + `RECONCILE_WAIT` 또는 `TERMINAL_INDETERMINATE`로 이동한다; +- clock skew와 DB clock/application clock ownership을 명시한다; +- reaper는 provider side effect가 없었다고 추론하지 않는다; +- expired intent도 이미 maybe-sent인 attempt를 not-sent/cancelled로 낮추지 않는다. + +### 16.11 transaction failure + +provider accepted 뒤 finalize transaction이 실패할 수 있다. 다음을 보장하지 못한다. + +```text +external provider side effect + +local notification_delivery_leg projection update +``` + +따라서 pre-send correlation/native operation key가 있으면 provider contract대로 사용하고, +response loss/finalize failure는 지원되는 lookup mode로 reconciliation한다. post-response +message reference가 없는데 있다고 가정하지 않는다. reconciliation이 없는 provider는 +`TERMINAL_INDETERMINATE`와 수동 runbook을 갖는다. + +### 16.12 broker wake-up + +향후 throughput/latency 때문에 broker를 쓰더라도 payload에는 encrypted notification body를 +복제하지 않고 opaque `NotificationIntentId` 또는 delivery wake-up key만 싣는다. + +broker message는 hint다. consumer는 DB state/claim을 다시 확인한다. duplicate/lost wake-up이 +정확성에 영향을 주지 않도록 periodic DB scan을 유지한다. + +## 17. provider capability matrix + +초기 provider 후보의 목표 위치는 다음과 같다. + +| Provider | Channel | 초기 위치 | native idempotency | response reference | recipient feedback | R2 판정 | +| --- | --- | --- | --- | --- | --- | --- | +| `slack-web-api` | Slack | reference | 의존할 문서 계약 없음 | `channel`, `ts` | 사용자 delivery/read 없음 | sandbox evidence 필요 | +| `slack-webhook` | Slack | legacy fixed target | 없음 | 없음 | 없음 | R0/R1 compatibility | +| `aws-ses-v2` | Email | reference | `SendEmail` client token 없음 | `MessageId` | delivery/delay/bounce/complaint 등 | sandbox/feedback evidence 필요 | +| `google-email` | Email | legacy ambiguous seam | 정의 안 됨 | 정의 안 됨 | 정의 안 됨 | R0 only | +| `gmail-api` | Email | optional future | send native idempotency 계약 없음 | Gmail message resource | generic recipient delivery feedback 아님 | 별도 card | +| `smtp` | Email | optional future transport | protocol 전체의 generic idempotency 없음 | server-dependent | DSN/feedback topology별 상이 | 별도 card | + +한 provider의 submission API와 callback/reconciliation capability를 별도 provider인 것처럼 +오해하지 않는다. capability card는 send path, feedback transport, account/region/workspace와 +credential mode를 함께 고정한다. + +### 17.1 최소 R2 candidate card의 exact set + +초기 implementation/qualification 범위는 다음 세 card뿐이다. 이 목록은 target이며 required +evidence가 쌓이기 전에는 R2라고 부르지 않는다. + +| Card ID | 정확한 보장 | +| --- | --- | +| `slack-web-api-inline-single-local-v1` | application-policy inline, SINGLE, local Block Kit/text renderer, `chat.postMessage`, `(channel,ts)` conversation post, response-loss terminal unknown, receipt/reconcile 없음 | +| `slack-web-api-durable-single-local-v1` | same-DB append, one provider leg, local renderer, `chat.postMessage`, response-loss terminal unknown, receipt/reconcile 없음 | +| `aws-ses-v2-durable-single-local-sns-v1` | same-DB append, one recipient/leg/call, local-rendered text/HTML, SES v2 `SendEmail`, pre-send EmailTag correlation, SNS HTTPS feedback와 직교 projection | + +각 deployed card instance는 §7.1의 모든 축, exact provider binding revision, Slack +workspace/channel class 또는 AWS account/region/configuration set/topic, resolved credential source, +evidence manifest digest와 maturity를 채운다. derived card는 축 하나라도 바뀌면 새 ID/revision과 +독립 evidence를 요구한다. + +`FAN_OUT_ALL`과 `ORDERED_FALLBACK` kernel은 R1 contract 대상으로 구현할 수 있으나, 정확한 +provider chain/card ID와 concurrency/fault evidence를 승인하기 전에는 초기 provider R2 set에 +포함하지 않는다. legacy `slack-webhook`, `google-email`, future Gmail/SMTP도 이 세 card의 +evidence를 상속하지 않는다. + +## 18. Slack reference provider + +### 18.1 선택 + +초기 R2 reference는 Slack Web API +[`chat.postMessage`](https://docs.slack.dev/reference/methods/chat.postMessage/)다. + +선택 이유: + +- route binding이 고정한 channel ID를 요청마다 정확히 선택할 수 있다; +- 성공 응답에 `channel`과 message `ts`가 있다; +- thread/update/delete와 future reconciliation에 사용할 provider reference를 얻는다; +- incoming webhook보다 destination/capability가 명시적이다. + +provider ID는 capability 차이를 드러내는 `slack-web-api`를 사용한다. 기존 +`slack-webhook`을 같은 ID 뒤의 credential mode로 숨기지 않는다. + +### 18.2 destination과 권한 + +application은 Slack channel ID를 전달하지 않는다. + +```text +NotificationRouteId + -> compiled target + -> workspaceBindingId + -> channelId secret/config reference + -> provider credential reference +``` + +bot token은 least-privilege `chat:write`를 기준으로 하고 public/private channel 접근과 membership을 +startup/readiness card에서 검증한다. 모든 public channel에 쓰는 추가 scope를 편의상 기본 +요구하지 않는다. + +credential은 workload secret provider reference로 주입하고 plain application YAML, test fixture, +log에 넣지 않는다. resolution ownership은 §24.2의 bootstrap bridge/adapter-owned material +factory를 따른다. token rotation을 사용하는 profile은 old/new token generation과 in-flight +attempt의 binding revision을 정의한다. + +### 18.3 payload + +- local typed renderer가 `text`와 bounded Block Kit payload를 만든다; +- accessibility fallback용 top-level `text` 정책을 template descriptor에 둔다; +- block/element/text/overall byte 상한은 Slack documented limit보다 보수적으로 설정한다; +- arbitrary channel/user mention과 external URL은 allowlisted value type만 허용한다; +- correlation metadata를 쓰더라도 secret/PII를 넣지 않는다; +- unfurl은 route policy에서 명시적으로 disable/allow한다; +- provider raw JSON을 application parameter로 받지 않는다. + +### 18.4 rate limit과 retry + +Slack은 message posting에 channel별 대략 초당 1건 기준과 HTTP `429`의 `Retry-After` 처리를 +문서화한다. 정확한 burst 크기를 capacity 상수로 사용하지 않는다. + +dispatcher는 `(workspaceBinding, channelId)`별 bounded rate bucket/admission을 둔다. + +- local admission wait도 intent deadline 안에 포함한다; +- `429`는 response가 해당 request를 수락하지 않았다는 exact card evidence가 있을 때 + retryable rejection으로 분류한다; +- `Retry-After`는 local maximum과 expiry로 cap한다; +- timeout/5xx/response parse failure는 provider가 side effect를 만들었을 수 있으므로 + 기본 `INDETERMINATE`다; +- retry worker/thread를 target마다 무한 생성하지 않는다. + +공식 기준: +[Slack Web API rate limits](https://docs.slack.dev/apis/web-api/rate-limits/). + +### 18.5 success와 receipt 의미 + +`chat.postMessage` 성공 응답의 `(workspace, channel, ts)`를 encrypted/opaque provider message +reference로 저장한다. + +```text +PROVIDER_ACCEPTED + + (workspace, channel, ts) + -> POSTED_TO_CONVERSATION +``` + +이는 Slack conversation에 message가 생성되었다는 의미다. 특정 사용자의 desktop/mobile push +도착 또는 읽음을 증명하지 않는다. + +`conversations.history` 또는 event를 이용한 확인은 conversation presence reconciliation일 뿐 +user delivery receipt가 아니다. response를 잃어 `ts`가 없는 unknown attempt에서 history +absence만으로 definite-not-posted를 증명하지 않는다. + +공식 기준: + +- [Web API response contract](https://docs.slack.dev/apis/web-api/) +- [conversations.history](https://docs.slack.dev/reference/methods/conversations.history/) +- [message event](https://docs.slack.dev/reference/events/message/) + +### 18.6 idempotency와 unknown outcome + +현재 `chat.postMessage`의 normative method contract에는 운영상 의존할 수 있는 request +idempotency key와 dedupe retention semantics가 없다. error reference의 특정 field 이름을 +idempotency guarantee로 승격하지 않는다. + +따라서 response-loss attempt는 blind retry하지 않는다. duplicate-tolerant route가 아닌 한 +terminal/manual reconciliation 또는 provider card가 검증한 별도 reconciliation로 이동한다. + +### 18.7 incoming webhook compatibility + +Incoming Webhook은 다음 exact capability로만 등록한다. + +```text +FIXED_DESTINATION +NO_PROVIDER_MESSAGE_REFERENCE +NO_DOCUMENTED_IDEMPOTENCY +NO_USER_RECEIPT +NO_UPDATE_DELETE_BY_WEBHOOK +``` + +webhook URL 자체가 secret이며 고정 destination에 결합된다. 성공은 일반적으로 HTTP 200과 +`ok` text지만 `ts`를 반환하지 않는다. dynamic channel, durable reconciliation 또는 +receipt-required route에 사용하지 않는다. + +공식 기준: +[Sending messages using incoming webhooks](https://docs.slack.dev/messaging/sending-messages-using-incoming-webhooks/). + +### 18.8 Slack qualification + +R2 evidence lane은 production token이 아니라 별도 +[Slack developer sandbox/test workspace](https://docs.slack.dev/tools/developer-sandboxes/)와 +격리 channel을 사용한다. + +필수 evidence: + +- valid post와 returned `(channel, ts)`; +- invalid auth/channel/scope classification; +- 429와 `Retry-After`; +- deadline/connection loss fault injection의 indeterminate 분류; +- message size/block/escaping contract; +- credential rotation; +- per-channel concurrency/admission; +- no PII/secret telemetry; +- optional history/event presence 확인의 정확한 한계. + +Slack은 provider-side dry-run/emulator를 baseline으로 제공한다고 가정하지 않는다. + +## 19. Email reference provider + +### 19.1 선택 + +초기 R2 reference는 Amazon SES v2 +[`SendEmail`](https://docs.aws.amazon.com/ses/latest/APIReference-V2/API_SendEmail.html/)이다. + +선택 이유: + +- transactional email submission API와 account sending 상태/quota가 명시되어 있다; +- message ID와 event destination을 통한 delivery/bounce/complaint lifecycle을 구성할 수 있다; +- AWS SDK v2, workload IAM과 region/account profile을 명확히 고정할 수 있다; +- Gmail mailbox-specific OAuth/quotas를 generic baseline에 결합하지 않는다. + +provider ID는 `aws-ses-v2`다. 기존 `google-email` provider를 내부에서 SES로 바꾸지 않는다. + +### 19.2 한 recipient 한 provider call + +최소 R2는 SES `SendEmail` 한 호출에 intent의 logical recipient 정확히 한 명만 보낸다. + +이유: + +- recipient별 outcome, bounce/suppression, attempt identity를 정확히 연결한다; +- multi-destination partial semantics를 피한다; +- provider message ID를 하나의 delivery와 매핑한다; +- fan-out budget과 privacy boundary가 명확해진다. + +대량 personalized/bulk API는 별도 capability card와 partial result/state model이 필요하다. + +### 19.3 identity, sender와 credential + +route binding은 다음을 고정한다. + +```text +awsAccountBinding +region +verifiedFromIdentity +configurationSet +replyTo policy +feedback event destination +credential mode +``` + +application은 from address, region, configuration set 또는 IAM credential을 선택하지 않는다. + +credential baseline은 AWS SDK v2 +[default credentials provider chain](https://docs.aws.amazon.com/sdk-for-java/latest/developer-guide/credentials-chain.html/) +을 무조건 허용하는 것이 아니라 deployed card가 +`WEB_IDENTITY`, `CONTAINER` 또는 `INSTANCE_PROFILE` 중 resolved source를 하나 고정하는 workload +role profile이다. default chain을 구현에 사용하더라도 readiness가 실제 선택된 source를 exact +card와 비교해야 한다. production에서 `SYSTEM_PROPERTY`/`ENVIRONMENT_STATIC` credential이 +선택되면 startup/readiness를 실패시킨다. static access key literal은 settings에 넣지 않는다. +IAM은 route가 필요한 verified identity/configuration set/send operation으로 최소화한다. +client construction/refresh는 §24.2의 bootstrap bridge와 adapter-owned credential factory를 +따르며 application/settings에 resolved credential 값을 전달하지 않는다. + +공식 기준: +[Controlling access to Amazon SES](https://docs.aws.amazon.com/ses/latest/dg/control-user-access.html). + +### 19.4 content + +- local renderer가 UTF-8 text와 HTML part를 만든다; +- subject/header control character를 거부한다; +- from/reply-to/return-path는 route policy가 고정한다; +- provider-stored SES template는 별도 `SES_STORED_TEMPLATE` card로만 지원한다; +- open/click tracking은 privacy/security/URL mutation을 검토한 route에서만 opt-in한다; +- attachment/raw MIME는 최소 R2에서 제외한다; +- provider hard limit보다 낮은 local encoded-byte limit을 둔다. + +### 19.5 send response의 의미 + +SES `SendEmail` 응답의 `MessageId`는 요청이 accepted되었다는 evidence다. AWS 문서도 accepted +message가 이후 실제로 전송되지 않을 수 있음을 명시한다. + +```text +SendEmail MessageId + -> PROVIDER_ACCEPTED + != DELIVERED_TO_RECIPIENT_MTA + != INBOX_DELIVERED + != READ +``` + +공식 기준: + +- [SES email sending process](https://docs.aws.amazon.com/ses/latest/dg/send-email-concepts-process.html) +- [SendEmail API](https://docs.aws.amazon.com/ses/latest/APIReference-V2/API_SendEmail.html) + +### 19.6 idempotency, SDK retry와 indeterminate + +SES v2 `SendEmail` request에는 notification coordinator가 의존할 native `ClientToken`이 없다. +timeout/connection loss 뒤 동일 message를 재요청하면 duplicate를 배제할 provider contract가 +없다. + +`aws-ses-v2-durable-single-local-sns-v1`은 send 전에 만든 opaque non-PII ASCII/Base32 +`AttemptCorrelationId`를 고정 tag name `ca_attempt_v1`의 SES `EmailTags`에 넣는다. tag에는 +tenant/user/recipient/intent 의미를 인코딩하지 않는다. verified SES event의 matching tag와 +`SEND` fact는 response-loss attempt가 provider에 accepted되었음을 사후 복원할 수 있다. 이는 +provider dedupe/idempotency key가 아니며 동일 send 재요청을 안전하게 만들지 않는다. + +AWS SDK v2의 standard retry는 기본적으로 여러 attempt를 수행할 수 있으므로 mutation send +baseline에서는 disable하고 coordinator의 physical attempt journal을 사용한다. future provider +card가 SDK retry를 허용하려면 모든 actual attempt, backoff와 transmission certainty가 local +budget/evidence에 포함됨을 증명해야 한다. + +공식 기준: +[AWS SDK for Java 2.x retry strategy](https://docs.aws.amazon.com/sdk-for-java/latest/developer-guide/retry-strategy.html). + +### 19.7 quota, sandbox와 admission + +SES account/region의 sandbox 상태와 sending capability는 startup/readiness에서 exact profile로 +확인한다. quota 수치는 계정/region/상태에 따라 달라질 수 있으므로 문서에 고정 숫자를 박지 않고 +runtime control plane/readiness evidence를 사용한다. + +- send rate/24-hour quota에 맞춘 bounded token bucket; +- local backlog/expiry와 provider quota intersection; +- provider throttling의 bounded backoff; +- sandbox에서는 verified recipient/mailbox simulator만 사용; +- production access가 없으면 selected production card R2를 주장하지 않는다. + +공식 기준: + +- [SES quotas](https://docs.aws.amazon.com/ses/latest/dg/quotas.html) +- [GetAccount API](https://docs.aws.amazon.com/ses/latest/APIReference-V2/API_GetAccount.html) +- [Managing sending quota errors](https://docs.aws.amazon.com/ses/latest/dg/manage-sending-quotas-errors.html) + +### 19.8 feedback event + +최소 R2 feedback topology를 다음 하나로 고정한다. + +```text +SES configuration set + -> one SNS standard topic + -> HTTPS endpoint owned by adapter-inbound-web + -> NormalizedNotificationReceiptUseCase + +SNS exhausted-delivery -> infrastructure-managed DLQ +``` + +card는 SES account/region/configuration set, SNS TopicArn/account/region, endpoint profile, +SignatureVersion 2, retry horizon/ACK contract와 DLQ identity를 고정한다. 같은 route에서 SES +identity notification과 configuration-set event publishing을 이중 활성화하지 않는다. + +event mapping: + +| SES event | immutable fact/projection | +| --- | --- | +| `SEND` | `SubmissionProjection=ACCEPTED`; EmailTag correlation으로 response-loss 복원 가능 | +| `REJECT` | accepted fact를 지우지 않고 `RecipientTransport=FAILED_AFTER_ACCEPT` | +| `BOUNCE` | `RecipientTransport=BOUNCED`; hard bounce만 suppression 후보 | +| `COMPLAINT` | `Abuse=COMPLAINED`, 다른 transport projection과 공존 | +| `DELIVERY` | `RecipientTransport=MTA_ACCEPTED` | +| `DELIVERY_DELAY` | normalized `DELAYED` fact/projection | +| `RENDERING_FAILURE` | provider-stored template card에서만 `FAILED_AFTER_ACCEPT`; local-rendered 초기 card는 기대 event set에 넣지 않음 | + +`DELIVERY`는 recipient mail server가 message를 accepted했다는 의미이며 inbox placement/read가 +아니다. exact expected event set은 deployed card에 고정하고 extra/unknown event는 quarantine한다. + +공식 기준: + +- [SES event destination](https://docs.aws.amazon.com/ses/latest/APIReference-V2/API_EventDestination.html) +- [Monitoring sending activity using notifications](https://docs.aws.amazon.com/ses/latest/dg/monitor-sending-activity-using-notifications.html) +- [SES event publishing and message tags](https://docs.aws.amazon.com/ses/latest/dg/monitor-using-event-publishing.html) +- [SES SNS event examples](https://docs.aws.amazon.com/ses/latest/dg/event-publishing-retrieving-sns-examples.html) +- [SES message insights](https://docs.aws.amazon.com/ses/latest/APIReference-V2/API_GetMessageInsights.html) + +### 19.9 bounce, complaint와 suppression + +- hard bounce와 complaint는 technical suppression 후보로 처리한다; +- transient/delayed event를 hard suppression으로 즉시 승격하지 않는다; +- provider/account/global suppression과 local technical suppression의 precedence를 명시한다; +- business unsubscribe/marketing consent와 별도 store/port를 유지한다; +- recipient HMAC scope와 encryption key lifecycle을 적용한다; +- suppression 충돌/해제는 audit 가능한 use case로만 수행한다. + +공식 기준: +[SES global suppression list](https://docs.aws.amazon.com/ses/latest/dg/sending-email-global-suppression-list.html). + +### 19.10 Gmail API와 SMTP의 위치 + +`gmail-api`: + +- Workspace/mailbox identity가 실제 요구일 때만 선택한다; +- OAuth user consent 또는 domain-wide delegation, per-user quota, message resource와 push + notification을 exact card에 포함한다; +- Gmail `users.messages.send` response를 recipient delivery receipt로 부르지 않는다; +- 기존 `google-email` 이름만으로 Gmail R2를 주장하지 않는다. + +공식 기준: + +- [Gmail users.messages.send](https://developers.google.com/workspace/gmail/api/reference/rest/v1/users.messages/send) +- [Gmail API usage limits](https://developers.google.com/workspace/gmail/api/reference/quota) +- [Gmail API sending](https://developers.google.com/workspace/gmail/api/guides/sending) + +`smtp`: + +- SMTP는 provider가 아니라 transport profile로 다룬다; +- STARTTLS/TLS, AUTH, DSN, connection reuse, server response, timeout, duplicate semantics를 exact + provider card가 정의해야 한다; +- generic SMTP success를 inbox delivery/read로 표현하지 않는다; +- port 25 fallback, opportunistic TLS 또는 plaintext credential을 허용하지 않는다. + +## 20. receipt, reconciliation과 suppression workflow + +### 20.1 submission과 recipient outcome 분리 + +submission control과 recipient lifecycle fact를 별도로 보존한다. + +```text +submission: + appended -> wire-authorized -> accepted | definitely-rejected | indeterminate + +receipt facts: + SEND | REJECT | BOUNCE | COMPLAINT | DELIVERY | DELIVERY_DELAY + +orthogonal projections: + submission + recipientTransport + abuse + conversationPresence +``` + +provider accepted 뒤에도 bounce/complaint가 올 수 있으므로 receipt가 submission fact를 +덮어쓰지 않는다. complaint도 transport projection과 공존한다. summary projection은 모든 축을 +함께 보여준다. + +### 20.2 callback verification + +raw callback inbound adapter는 provider transport별로 다음을 적용한다. + +- raw body size/content-type/method limit; +- provider-defined signed representation에 대한 signature/authenticity 검증. raw bytes가 + signature contract인 transport만 exact raw bytes를 사용; +- provider retry semantics에 맞는 replay/dedupe window; +- expected account/topic/configuration set/workspace allowlist; +- verification key/secret rotation; +- constant-time comparison where applicable; +- event ID digest uniqueness; +- batch event count/depth/string limit; +- unknown schema/version quarantine; +- raw header/body/DTO를 application으로 넘기지 않음. + +검증 성공 뒤에만 `NormalizedNotificationReceiptCommand`를 만든다. + +SES R2의 SNS HTTPS ingress는 generic webhook secret으로 검증하지 않는다. + +- bounded JSON parse 뒤 SNS가 정의한 field canonical string을 구성하고 + `SignatureVersion=2`를 검증한다; +- `SigningCertURL`은 HTTPS, allowlisted AWS SNS host/path, DNS/IP/redirect와 certificate chain을 + 검증해 SSRF/host confusion을 막고 bounded cache/deadline으로 가져온다; +- exact TopicArn의 account/region/name과 canonical binding을 비교한다; +- exact SES card의 + `max_callback_age = bounded SNS HTTP retry horizon + bounded DLQ retention/redrive horizon + + allowed clock skew`를 checked-in ingress profile로 고정하고 Task 19가 실제 topology와 + 대조한다. initial `ses-notification-v1`은 각각 `1h + 7d + 5m = 7d1h5m`이고 outer tombstone은 + ingestion safety margin `1h`를 더 길게 덮는 `8d`, inner semantic tombstone은 `30d`다. 이 + window 안의 정상 delayed retry는 + `Timestamp`만으로 거부하지 않는다. allowed skew보다 미래인 timestamp와 window를 + 초과한 envelope는 signature가 유효해도 receipt/quarantine DB mutation 없이 4xx로 거부하고 + bounded security metric만 남긴다; +- outer SNS `MessageId`와 inner SES semantic fingerprint를 HMAC dedupe한다. 두 tombstone은 + `max_callback_age + ingestion safety margin`보다 길고, inner semantic tombstone은 승인된 manual + redrive window 전체를 덮는다; +- `SubscriptionConfirmation`/`UnsubscribeConfirmation`의 arbitrary `SubscribeURL`을 runtime에서 + 자동 fetch하지 않는다. IaC 또는 별도 인증·승인된 운영 절차가 exact TopicArn을 확인해 + subscription을 확정한다; +- normalized receipt transaction이 commit된 뒤에만 success ACK를 반환한다. transient failure는 + SNS retry를 유도하고 exhausted delivery는 configured DLQ에서 replay한다. +- original outer envelope가 max age를 지난 수동 DLQ replay는 public endpoint에 그대로 + 재주입하지 않는다. 별도 인증·승인된 운영 절차가 exact TopicArn으로 inner SES event를 + republish해 새 SNS outer `MessageId/Timestamp/signature`를 만들고, inner semantic fingerprint는 + 그대로 유지한다. semantic tombstone retention을 지난 replay는 projection을 변경하지 않는 + forensic 절차를 새로 승인하지 않는 한 거부한다. + +공식 기준: +[SNS message signature verification](https://docs.aws.amazon.com/sns/latest/dg/sns-verify-signature-of-message.html), +[SNS HTTP delivery retry](https://docs.aws.amazon.com/sns/latest/dg/sns-message-delivery-retries.html). + +### 20.3 duplicate/out-of-order/orphan + +- 같은 provider event ID는 idempotent duplicate다; +- SNS outer retry는 `(TopicArn, MessageId)`로 dedupe하고 SES inner semantic fingerprint는 + versioned HMAC over `(provider binding revision, SES messageId, normalized event type, + one-recipient digest, documented provider occurrence discriminator)`로 계산한다; +- provider message reference와 event ID가 충돌하면 quarantine한다; +- accepted finalize보다 callback이 먼저 오면 `ORPHAN` inbox에 저장한다; +- later attach worker가 bounded window 동안 재매칭한다; +- reducer는 같은 verified fact set의 모든 arrival permutation에서 같은 projection을 만든다; +- semantic conflict만 provider semantics table에 따라 audit/quarantine하고 단순 out-of-order를 + 이전 state overwrite로 처리하지 않는다; +- callback retry 응답은 receipt transaction commit 여부와 일치시킨다. + +### 20.4 reconciliation + +reconciliation은 provider card가 명시적으로 지원하는 경우만 호출한다. + +```text +ReconcileOutcome = + CONFIRMED_ACCEPTED + CONFIRMED_NOT_APPLIED + STILL_IN_PROGRESS + STILL_INDETERMINATE + RECONCILIATION_UNSUPPORTED +``` + +absence가 not-applied를 증명하는지 provider별로 검증한다. list/history API에서 못 찾았다는 +사실만으로 definite failure를 만들지 않는다. + +reconcile call에도 별도 deadline, quota, max count와 total amplification budget을 적용한다. + +### 20.5 cancellation + +intent cancellation은 아직 시작하지 않은 delivery를 막는 best effort control이다. + +- `QUEUED/RETRY_WAIT`은 token/version guarded하게 cancel할 수 있다; +- `CLAIMED/ATTEMPT_RESERVED`도 `WIRE_AUTHORIZED` 전에 token/version guarded하게 cancel할 수 있다; +- `WIRE_AUTHORIZED/RECONCILE_WAIT/TERMINAL_INDETERMINATE/PROVIDER_ACCEPTED`를 not-sent로 + 바꾸지 않는다; +- cancel과 wire authorization 중 먼저 commit된 transition이 이긴다; +- Slack message delete나 email recall을 generic cancellation으로 약속하지 않는다; +- provider-specific delete/update는 별도 operation/capability card다. + +### 20.6 manual operation + +operator action은 다음 bounded use case만 허용한다. + +- inspect summary and safe reason codes; +- pause/resume route dispatcher; +- retry definite-not-sent terminal with new audited occurrence; +- request reconciliation; +- attach/quarantine orphan receipt; +- expire/redact payload; +- rotate/restore required template or encryption revision. + +raw DB state edit, arbitrary resend, suppression row 직접 삭제는 runbook 정식 동작이 아니다. + +## 21. canonical configuration + +### 21.1 source of truth + +현재 channel-wide selector와 legacy enabled boolean을 폐기하고 하나의 canonical graph로 +activation을 결정한다. + +개념 예: + +```yaml +app: + notification: + expected-state: disabled | configured + expected-binding-ids: [security-email, engineering-alerts] + expected-writer-generations: + security-email-v3: 12 + engineering-alerts-v2: 8 + bindings: + security-email: + notification-kind: security-alert-email + channel: email + expected-mode: durable-async + strategy: single + route-revision: security-email-v3 + template: security-alert-v4 + providers: [aws-ses-primary] + engineering-alerts: + notification-kind: engineering-alert-slack + channel: slack + expected-mode: best-effort-inline + strategy: single + route-revision: engineering-alerts-v2 + template: engineering-alert-v2 + providers: [slack-web-api-primary] + providers: + aws-ses-primary: + type: aws-ses-v2 + region: ap-northeast-2 + credential-ref: workload-role + expected-credential-source: web-identity + from-identity-ref: transactional-sender + configuration-set: notification-events-v1 + feedback: + type: sns-https + topic-arn: ${APP_NOTIFICATION_SES_TOPIC_ARN} + expected-signature-version: "2" + ingress-profile: ses-notification-v1 + dlq-ref: notification-events-dlq + slack-web-api-primary: + type: slack-web-api + workspace-ref: engineering-workspace + token-ref: slack-bot-primary + destination-ref: engineering-alert-channel +``` + +실제 properties type과 env mapping은 implementation plan에서 env-key registry와 validation +grammar를 함께 정의한다. 위 YAML은 의미 예시이며 현재 동작하는 설정이 아니다. + +checked-in `NotificationCanonicalRouteCatalog`가 writer fence route key의 retained SSOT다. 그 +key set은 canonical binding graph와 post-migration fresh provisioning의 reviewed initial key set에 exact +equality여야 한다. PRE-only `NotificationCutoverRouteCatalog`는 canonical key를 추가/삭제하지 +못하고 route별 legacy alias와 bounded current+retiring transport profile registry만 장식한다. +registry는 active admission profile 하나와 각 revision의 proof class +(`HARD_BOUND_PROVEN | QUIESCENCE_REQUIRED`)/evidence revision을 고정한다. runtime canonical +target generation map은 별도 reviewed config/evidence revision이고 catalog key를 추가/삭제할 +수 없다. legacy settings나 현재 consumer 수는 이 set을 축소하지 못한다. production +consumer 0 또는 live legacy mapping이 없는 route도 PRE에서는 closed LEGACY predecessor fence로 +batch 초기화한 뒤 동일한 audited drain/complete를 거치며, direct canonical seed는 FINAL의 +independent issuer가 irreversible no-legacy-authority fence를 먼저 commit한 뒤 서명한 +DB-birth authorization을 exact two-function protocol로 검증한 +`notificationFreshProvisioning`에만 허용한다. bootstrap은 canonical +catalog key와 exact runtime target map을 retained +application-owned canonical route set으로 변환하고, PRE에서만 cutover proof decorator를 +transitional writer route set에 더한다. legacy/canonical composition, initializer와 migration +cross-check는 같은 key-set digest를 사용한다. + +permit acquire는 active profile만 사용하고 timeout, attestation과 COMPLETE는 request나 현재 +permit rows만으로 proof class를 추론하지 않는다. batch initialization이 compiled PRE +current+retiring registry를 retained +`notification_writer_transport_proof_registry`에 같은 root transaction으로 동결하고, 이후 PRE +runtime은 양쪽 exact equality를 검증한다. unknown/omitted historical blocking profile은 fail +closed하고 permit/attestation/operation history가 참조하는 retiring profile은 제거하지 못한다. +permit 0인 route도 persisted active profile이 `QUIESCENCE_REQUIRED`이면 attestation이 필수다. +`HARD_BOUND_PROVEN`은 reviewed deadline/ +cancellation integration evidence revision이 catalog와 qualification manifest에 일치할 때만 +허용하며, current R0 profile은 `QUIESCENCE_REQUIRED`다. + +### 21.2 `expected-state` + +```text +disabled + legacy config absent + -> PURE_DISABLED + -> canonical binding 0 + -> canonical/legacy provider, client, dispatcher, operator and table scan 0 + +disabled + exact legacy-only config + PRE_CUTOVER_BRIDGE artifact + -> PRE_LEGACY_BRIDGE + -> canonical binding/provider/store/worker 0 + -> transitional initializer/fence/permit/operator와 selected legacy provider만 구성 + -> audited initialization 전 legacy admission/provider call 0 + +configured + -> CANONICAL_CONFIGURED + -> actual binding ID set == expected-binding-ids + -> compiled route-revision key set == expected-writer-generations key set + -> FINAL/REQUIRE_CANONICAL이면 persisted fence set == ACTIVE/CANONICAL exact target set + -> PRE이면 exact predecessor/target closed state set을 허용하고 route별 owner match만 admission + -> 모든 required provider/template/store/worker가 exact graph를 만족 + -> 누락/unknown/mismatch면 startup failure +``` + +blank selector를 암묵적으로 disabled로 해석하는 것과 운영자가 configured를 기대했는데 실제 +binding 0인 것을 구분한다. configured에서 missing/extra binding 하나라도 실패하며 “하나 이상” +검사로 필수 SES binding의 소실을 숨기지 않는다. release artifact에는 sorted exact graph의 +manifest digest와 evidence revision도 남긴다. canonical configured와 어떤 legacy key도 같은 +process에서 공존할 수 없다. PRE legacy bridge는 pure disabled의 예외가 아니라 별도 closed +composition state이고 FINAL artifact에서는 존재할 수 없다. + +writer generation map은 runtime cutover revision assertion이며 secret이 아니다. route key는 +retained canonical catalog에서만 오고 값은 canonical target generation이다. legacy predecessor는 +`target - 1`이며 missing/extra/unknown/overflow를 허용하지 않는다. abort로 generation이 바뀌면 +config/evidence revision도 바뀌며 canonical node는 새 exact set으로 재배포되기 전 fail +closed한다. dark legacy bridge는 같은 map의 predecessor set을 batch init에 사용하되 fence +absent/partial 상태에서는 startup endpoint만 열고 provider admission은 0이다. + +PRE artifact에서 canonical-only graph가 exact catalog key set과 persisted closed predecessor/ +target state를 만나면 bootstrap이 `CUTOVER_WAIT`를 내부적으로 derive한다. request, env 또는 +generic property로 이 mode를 선택할 수 없다. process liveness는 유지하지만 predecessor/ +DRAINING route의 admission, claim, provider call은 0이고 notification readiness는 +`CUTOVER_WAIT`다. route가 committed `ACTIVE/CANONICAL@target`이 된 것을 fresh DB read로 확인한 +뒤에만 그 route를 동적으로 연다. FINAL artifact는 CUTOVER_WAIT production branch를 제거하고 +모든 route가 exact canonical target이 아니면 startup을 실패시킨다. + +### 21.3 binding compile + +notification-local compiler 결과와 application compatibility validator가 합성한 최종 +composition contract는 다음 exact tuple이다. + +```text +effective binding = + notification kind + + application NotificationKindPolicy revision/mode/admission class + + config expected-mode assertion + + route revision + + channel + + strategy + + template/version/checksum/locale set + + ordered provider target revisions + + required provider capability + + attempt/reconcile/amplification limits + + receipt expectation + + provider-local runtime profile +``` + +notification-local compiler가 검증할 항목: + +- code catalog에 없는 route/template/provider type 거부; +- duplicate/empty/cyclic binding 거부; +- channel/provider mismatch 거부; +- durable route + legacy/fail-open provider 거부; +- receipt-required route + receipt-unsupported provider 거부; +- fallback + indeterminate-unsafe chain 거부; +- bound 초과의 target/retry/reconcile 거부; +- unknown properties fail closed. + +application pure compatibility validator와 bootstrap composition이 별도로 검증할 항목: + +- `NotificationKindPolicy.mode`와 config `expected-mode` 불일치; +- actual/expected binding ID exact set와 release manifest digest; +- persistence store/schema/crypto descriptor와 durable policy; +- receipt-required card와 inbound SNS ingress descriptor; +- send/receipt slice의 account/region/configuration set/topic identity 일치; +- live/retained intent가 참조하는 모든 revision의 가용성; +- required worker/readiness/evidence manifest의 exact composition. + +notification-local compiler가 persistence/inbound sibling을 탐색하지 않는다. bootstrap은 §13.1의 +provider-neutral descriptor를 application validator에 전달할 뿐 business/retry/fallback 정책을 +settings/configuration class에 구현하지 않는다. + +### 21.4 legacy migration + +legacy: + +```text +app.notification.slack.provider +app.notification.email.provider +app.notification.slack-webhook.enabled +app.notification.google-email.enabled +app.notification.routes.* +``` + +canonical graph와 legacy key가 동시에 나타나면 값이 같더라도 startup을 실패시킨다. temporary +migration translator를 두더라도 한 방향으로만 변환하고 deprecation telemetry와 removal +deadline을 둔다. + +## 22. activation과 zero-resource contract + +`PURE_DISABLED`, 즉 canonical `expected-state=disabled`이면서 legacy key도 없는 상태에서 다음이 +0이어야 한다. + +- Slack/AWS/Gmail/SMTP client; +- provider credential resolution; +- HTTP connection/pool; +- dispatcher scheduler/thread/executor; +- claim/reconcile/reaper scan; +- rate limiter bucket background task; +- provider startup network probe; +- provider health indicator; +- callback subscription expectation; +- application/runtime notification table DML과 scan. + +framework가 settings metadata 또는 harmless validator를 생성하는 것은 가능하지만 external +resource/secret/worker side effect는 없어야 한다. +expand-first V7 DDL과 V8의 structural classification은 feature flag로 gate하지 않는 migration +lifecycle이므로 이 runtime zero-resource 계수에서 제외한다. V8이 +`AWAITING_SIGNED_FRESH_PROVISIONING`을 남긴 경우 별도 provisioning job 전까지 normal +application startup/readiness, bean/resource/DML/scan/provider call은 0이어야 한다. +`notificationFreshProvisioning`은 issuer가 먼저 commit한 irreversible +no-legacy-authority fence가 포함된 signed DB-birth authorization을 받고, 같은 provisioner +transaction에서 snapshot/read-lock -> Java verifier -> apply 순서로 실행하는 exact one-shot +deployment operation이며 runtime zero-resource 경로에 포함하지 않는다. + +`PRE_LEGACY_BRIDGE`는 zero-resource 상태가 아니다. canonical provider/store/dispatcher/ +readiness resource는 0이지만 exact legacy provider와 transitional initializer/fence/permit/ +operator surface는 의도적으로 존재한다. batch initialization 전에는 그 surface도 provider call +0이며, 이후 legacy send는 committed permit을 반드시 거친다. composition/zero-resource/ +fenced-legacy tests는 PURE_DISABLED, PRE_LEGACY_BRIDGE와 CANONICAL_CONFIGURED를 별도 fixture로 +검증하고 same-process legacy+canonical overlap을 거부한다. + +한 channel만 binding되면 다른 channel provider/client는 생성하지 않는다. durable binding 없이 +best-effort만 있으면 persistence dispatcher를 만들지 않는다. feedback-required SES binding이 +없으면 receipt reconciliation worker를 만들지 않는다. + +optional consumer가 port를 호출했는데 해당 route binding이 없으면 +`NotificationCapabilityUnavailable` 같은 typed failure를 반환한다. silent no-op bean을 만들지 +않는다. + +## 23. deadline, resource와 capacity + +### 23.1 deadline을 분리한다 + +| deadline/window | 의미 | +| --- | --- | +| append deadline | business transaction 안 intent 저장 한도 | +| dispatch eligibility | `notBefore` | +| claim lease | worker ownership 한도 | +| per-attempt deadline | 한 authorized provider call의 monotonic budget | +| retry horizon | first eligibility부터 retry 가능한 총 기간 | +| intent expiry | 이후 새 send를 시작하지 않는 business limit | +| reconciliation horizon | unknown attempt를 확인할 최대 기간 | +| receipt window | delayed feedback를 attach할 기간 | +| payload retention | encrypted recipient/parameter 보존 기간 | +| dedupe tombstone | duplicate source/callback을 막을 보존 기간 | + +이 값을 하나의 `timeout`으로 합치지 않는다. caller deadline과 route maximum의 intersection을 +사용하고 wall-clock rollback이 elapsed attempt budget을 늘리지 않도록 monotonic time을 +사용한다. durable scheduling timestamp는 DB/UTC wall time을 사용하되 elapsed attempt deadline과 +구분한다. + +### 23.2 concurrency + +bounded control: + +```text +global dispatcher concurrency +per-provider concurrency +per-account/workspace concurrency +per-destination/channel rate bucket +claim batch size +max in-memory rendered bytes +max outstanding attempts +max receipt batch/events +``` + +virtual thread를 사용해도 admission 상한이 사라지지 않는다. provider client 내부 queue와 local +worker queue를 모두 유한하게 둔다. + +### 23.3 backpressure + +- DB backlog가 높으면 claim batch/concurrency를 bounded하게 조절한다; +- provider quota가 낮아도 hot-loop claim/release를 하지 않는다; +- retry storm에 full jitter를 사용한다; +- critical route와 bulk/low-value route의 admission partition을 분리할 수 있다; +- priority가 starvation을 만들지 않도록 aging/weight를 명시한다; +- expiry 가까운 intent를 무조건 먼저 보내 privacy/consent를 우회하지 않는다; +- overload 시 best-effort와 durable append 정책을 별도로 정의한다. + +### 23.4 capacity equation + +최소 capacity review는 다음을 계산한다. + +```text +incoming durable intents/sec +× 1 logical recipient per intent +× provider legs per recipient +× expected physical attempts ++ reconciliation calls ++ callback events +``` + +worst-case는 route별 amplification cap으로 계산한다. provider advertised throughput만 보지 않고 +DB claim/finalize TPS, encryption/render CPU, callback burst와 retention storage를 함께 측정한다. + +## 24. security, privacy와 retention + +### 24.1 data classification + +| 데이터 | 기본 분류 | 저장 | +| --- | --- | --- | +| recipient address/channel mapping | PII/secret 가능 | versioned direct-AEAD ciphertext + lookup HMAC | +| template parameter | PII/business secret 가능 | versioned direct-AEAD ciphertext | +| rendered body/subject | PII/business secret 가능 | 기본 미저장, 필요 시 짧은 encrypted retention | +| provider token/webhook URL/AWS credential | secret | secret manager reference only | +| provider message reference | high-cardinality, 간접 PII 가능 | encrypted/opaque + optional HMAC | +| intent/delivery/attempt opaque ID | internal identifier | log 허용, metric tag 제한 검토 | +| route/template/provider/reason code | bounded operational metadata | log/metric 허용 | + +### 24.2 versioned direct AEAD와 key ownership + +최소 R2는 “envelope encryption”을 주장하지 않고 versioned direct AEAD를 선택한다. + +- algorithm profile은 `DIRECT_AEAD_AES_256_GCM_V1`로 고정하고 field마다 CSPRNG 96-bit nonce와 + 128-bit authentication tag를 사용한다. 같은 key에서 nonce 재사용을 허용하지 않는다; +- canonical AAD는 versioned length-prefix encoding으로 + `schema/table + record ID + notification ID + optional delivery/attempt ID + field purpose + + provider binding revision + crypto profile version`을 묶어 row/field swapping을 막는다; +- DB에는 ciphertext/tag, nonce, algorithm/profile, non-secret key reference와 key version만 + 저장한다. raw key/credential은 settings record, application value, persistence entity, log, + metric, backup/export에 넣지 않는다; +- 새 encrypt는 current key, decrypt는 live/retained row가 참조하는 current/retiring exact + version을 사용한다. retiring row는 decrypt-reencrypt migration과 evidence 뒤 제거한다; +- 검색/dedupe는 별도 purpose-separated keyed HMAC과 §10.2의 alias/re-HMAC rotation protocol을 + 사용한다; +- old AEAD/HMAC key 폐기 전 active/backlog뿐 아니라 retained intent, suppression, callback + dedupe, orphan, message-reference lookup, tombstone과 backup retention을 scan한다. + +현재 `SecretSource`는 `app-bootstrap` 소유이며 `Optional`을 반환하므로 +notification/persistence leaf가 이를 import하지 않는다. 각 consuming adapter는 +`CredentialMaterialProvider` 또는 `PayloadKeyMaterialProvider` 같은 최소 framework-free +factory/handle contract를 자기 leaf에 두고, bootstrap이 canonical secret reference를 현재 +`SecretSource`로 resolve해 bridge를 구현한다. 반환 material은 version이 붙은 +`AutoCloseable` char/byte handle로 adapter 내부에서만 짧게 사용하고 close 시 wipe한다. 장기 +provider client가 credential refresh를 요구하면 reference 기반 provider가 매번 새 handle을 +받고 generation을 검증한다. + +settings에는 secret reference만 남긴다. bootstrap은 adapter factory를 조합할 수 있지만 adapter는 +bootstrap type에 의존하지 않는다. current string-based `SecretSource`에서 생기는 immutable +String copy 최소화/wiping 한계와 binary secret 지원은 구현 계획의 bootstrap 변경·테스트 +항목으로 명시한다. key rotation은 current/retiring handle factory를 원자 교체하고 기존 +in-flight attempt가 frozen credential/key generation을 잃지 않는 protocol로 검증한다. + +### 24.3 safe value type + +recipient/parameter/provider response value의 `toString()`은 redacted 형태여야 한다. Java record의 +자동 `toString()`에 raw email/body가 노출되는 현재 `Notification`을 durable path에서 재사용하지 +않는다. + +exception message, assertion failure, structured log argument, span event에도 raw value를 넣지 +않는다. debug profile도 이 원칙을 완화하지 않는다. + +### 24.4 retention + +retention class는 notification kind가 고정한다. + +- terminal 뒤 provider retry/reconciliation에 필요 없는 ciphertext를 먼저 redact/delete한다; +- dedupe digest/tombstone은 source retry와 provider/callback replay window보다 길게 유지한다. + SES/SNS outer/semantic tombstone은 exact + `max callback age + ingestion safety margin`보다 길고 semantic tombstone은 승인된 manual + redrive horizon도 덮어야 한다; +- recipient/message-reference/correlation digest는 SNS retry/DLQ와 orphan attach window가 끝날 + 때까지 유지한다. indefinite suppression은 ciphertext 삭제 전에 current-key alias/re-HMAC을 + 완료한다; +- dead/indeterminate row가 PII 무기한 보관 수단이 되지 않게 maximum retention을 둔다; +- orphan receipt evidence는 bounded attach window 뒤 quarantine summary만 남긴다; +- audit상 content 보존이 필요하면 목적/기간/access/key deletion을 별도 승인한다; +- delete는 delivery fact/aggregate metric과 content ciphertext를 분리한다. + +### 24.5 email security + +- verified sender identity를 route에 고정한다; +- SPF, DKIM, DMARC alignment와 bounce/complaint monitoring을 production readiness에 포함한다; +- marketing/상업성 email에 필요한 unsubscribe header/one-click semantics는 legal/product policy와 + 함께 별도 notification kind에서 강제한다; +- header injection, display-name spoofing, external link policy를 test한다; +- SES account/region/configuration set drift를 readiness에서 검출한다. + +### 24.6 Slack security + +- bot token과 webhook URL을 secret으로 취급한다; +- route가 고정한 workspace/channel 외 전송을 막는다; +- public-wide posting scope와 user token/impersonation을 default로 사용하지 않는다; +- Block Kit link/mention/metadata에 secret/PII를 넣지 않는다; +- token rotation/revocation 뒤 old generation의 in-flight outcome을 indeterminate로 잘못 + downgrade하지 않는다. + +### 24.7 callback security + +provider event가 직접 suppression 또는 delivery를 바꾸므로 callback은 일반 telemetry webhook이 +아니다. signature 검증 실패, unexpected topic/account/workspace, transport contract에 어긋난 +timestamp/replay, oversize, schema drift는 성공으로 흘려보내지 않고 bounded quarantine/metric을 +남긴다. SNS는 exact max-callback-age 안의 늦은 정상 retry를 timestamp만으로 거부하지 않고 +message/semantic dedupe를 사용한다. 그 age를 넘긴 signed outer envelope는 mutation 없이 +거부하고, manual redrive는 새 outer envelope와 보존된 inner semantic fingerprint를 요구한다. + +## 25. observability + +### 25.1 metrics + +허용할 bounded tag 예: + +```text +channel +provider_type/provider_binding +route_id +template_id/version +mode +strategy +outcome/reason_code +attempt_bucket +readiness_card_revision +``` + +금지 tag: + +```text +recipient/address +body/subject/parameter +provider message ID +Slack channel ID/workspace ID raw value +tenant/user/source operation raw ID +intent/idempotency/correlation ID +token/webhook/endpoint +exception message +``` + +핵심 metric: + +```text +notification_intent_append_total +notification_delivery_backlog +notification_oldest_eligible_age +notification_attempt_total +notification_attempt_duration +notification_indeterminate_total +notification_retry_scheduled_total +notification_reconcile_total +notification_receipt_total +notification_orphan_receipt_total +notification_suppression_total +notification_payload_redaction_lag +notification_claim_conflict_total +notification_expired_total +notification_admission_gate_park_total +notification_parked_delivery_count +``` + +summary `success rate`는 submission/recipient outcome을 섞지 않고 별도 metric으로 표시한다. + +### 25.2 traces + +권장 span: + +```text +notification.request +notification.intent.append +notification.dispatch.claim +notification.render +notification.provider.attempt +notification.reconcile +notification.receipt.verify +notification.receipt.apply +notification.retention.redact +``` + +durable worker는 persisted trace link/correlation을 사용하며 원래 request span을 며칠간 parent로 +열어두지 않는다. baggage를 provider request에 자동 전파하지 않는다. + +### 25.3 logs + +structured log에는 opaque internal ID와 bounded codes만 쓴다. + +```text +intent_id +delivery_id +attempt_id +channel +route_id +provider_binding +state_from/state_to +reason_code +claim_owner_hash [필요 시] +``` + +provider raw response/error payload, recipient/content, credential, message reference는 기본 log +금지다. 필요 evidence는 allowlisted parsed code와 short digest로 남긴다. + +### 25.4 audit + +다음 action은 audit 대상이다. + +- route/template/provider revision activation; +- route writer `INITIALIZE_LEGACY`, `INITIALIZE_CANONICAL_FRESH`, `BEGIN_DRAIN`, + `TERMINALIZE_EXPIRED_PERMITS`, `COMPLETE_SWITCH`, `ABORT_DRAIN`; +- retained signed writer inventory/quiescence evidence, fresh-install provenance와 finalization + discriminator/provisioning; +- critical route pause/resume; +- manual retry/reconcile/cancel; +- suppression add/remove; +- orphan receipt attach/quarantine; +- encryption/template old revision retirement; +- operator payload access/redaction override. + +audit에는 actor/authorization/reason/revision과 opaque target만 기록하고 raw notification content를 +복제하지 않는다. + +## 26. startup, health와 readiness + +### 26.1 liveness + +application liveness는 Slack/SES/network/DB backlog와 독립이다. provider outage나 quota exhaustion +때문에 process liveness를 실패시켜 restart loop를 만들지 않는다. + +### 26.2 startup validation + +startup에서 network send 없이 다음을 검증한다. + +- `expected-state`, `expected-binding-ids`와 canonical graph manifest; +- notification-local code catalog와 config route/provider/template revision; +- template asset checksum/schema/locale/output static bounds; +- provider credential/identity reference의 존재와 형식; +- application validator가 받은 persistence/crypto/worker와 inbound receipt descriptor; +- route mode/strategy와 provider capability compatibility; +- retry/fallback/amplification bound; +- callback-required route의 send/receipt profile identity 일치; +- live/retained intent가 참조하는 모든 revision 가용성; +- legacy/canonical key conflict; +- compiled cutover route key set, canonical target generations와 persisted predecessor/target + fence state machine, retained signed inventory/attestation/provenance/finalization integrity와 + Java Ed25519 재검증; +- disabled/zero binding resource 0. + +notification-local compiler는 sibling bean/store/controller를 직접 탐색하지 않는다. +`app-bootstrap`이 각 adapter descriptor를 application의 pure compatibility validator에 전달해 +최종 composition을 판정한다. + +실제 provider credential validity/account state를 확인하는 network probe는 startup bean +construction과 분리한다. provider outage가 process boot를 무한 지연시키지 않도록 finite deadline, +cache와 readiness semantics를 둔다. + +### 26.3 readiness + +readiness는 active required binding만 평가한다. + +```text +required binding ready = + compiled graph valid + AND actual binding IDs exactly match expected set + AND required template revisions loaded + AND durable store reachable/schema compatible [durable only] + AND encryption/key refs usable + AND provider account/profile check acceptable + AND required route/provider/account admission gates ACTIVE + AND dispatcher admission running [durable only] + AND callback topology expected state met [receipt-required only] +``` + +PRE canonical-only node가 exact predecessor/DRAINING state를 관측하면 application liveness는 +healthy지만 notification readiness는 `CUTOVER_WAIT`이고 해당 route admission/worker/provider +call은 0이다. exact canonical target으로 committed 전이한 route만 fresh read 뒤 활성화한다. +partial/extra/unrelated generation, owner drift 또는 rollback generation 변화는 즉시 route를 +닫고 readiness를 내린다. FINAL은 wait state가 없으며 exact all-canonical set이 아니면 startup +failure다. + +best-effort optional binding outage가 전체 service readiness를 실패시킬지는 bootstrap의 reviewed +required/optional policy가 정한다. global “all notification provider healthy” boolean로 +합치지 않는다. + +### 26.4 provider health probe + +- 실제 user/channel/email에 synthetic message를 보내지 않는다; +- Slack `auth.test`는 token/team/bot identity 확인에만 사용하고 health를 위해 read scope를 + 추가하지 않는다. channel write access는 sandbox qualification 또는 실제 bounded send + evidence로 증명하며, route 기능에 필요하지 않은 `conversations.info/history` scope를 health + 전용으로 요구하지 않는다; +- SES는 account sending status/quota/identity/configuration set을 safe control-plane call로 + 확인하고 exact resolved credential source/account/region을 card와 비교한다; +- network probe는 bounded cache/jitter를 사용한다; +- probe failure를 send outcome으로 사용하지 않는다; +- disabled provider는 probe하지 않는다; +- readiness component 이름/tag에 secret/destination raw ID를 넣지 않는다. + +### 26.5 backlog health + +provider reachable 여부와 별도로 다음을 본다. + +- oldest eligible delivery age; +- retry/reconcile lag; +- expired-before-attempt rate; +- indeterminate accumulation; +- orphan receipt accumulation; +- payload redaction/key retirement lag; +- claim conflict/stale lease rate; +- provider quota headroom. +- parked admission gate/leg count와 oldest parked age. + +health threshold는 alert/runbook 신호이며 liveness restart trigger로 자동 재사용하지 않는다. + +## 27. lifecycle와 deployment safety + +### 27.1 startup order + +```text +settings bind/validate + -> catalog/template manifest load + -> binding compile + -> store schema/key/provider dependency validate + -> provider clients construct + -> readiness components register + -> dispatcher admission open +``` + +compile 실패 뒤 일부 provider client/worker를 남기지 않는다. + +### 27.2 graceful shutdown + +1. 신규 claim/admission을 닫는다; +2. 이미 claim했지만 send 전인 row를 safe release 또는 lease expiry 대상으로 표시한다; +3. in-flight attempt를 bounded grace 동안 기다린다; +4. wire call을 취소했더라도 possible-send는 reconcile path 또는 + `TERMINAL_INDETERMINATE`로 finalize하려 시도한다; +5. finalize 실패 시 lease/reaper가 reconcile path로 보내도록 durable evidence를 남긴다; +6. callback intake는 load balancer drain과 transaction completion 순서를 맞춘다; +7. provider client/executor를 닫는다. + +shutdown timeout 뒤 interrupt를 `DEFINITELY_NOT_APPLIED`로 해석하지 않는다. + +### 27.3 rolling deployment + +- expand schema가 구/신 version 모두와 호환된 뒤 code를 배포한다; +- 모든 live/retained intent가 참조하는 route/template/provider/renderer revision을 backlog와 + retention horizon 동안 유지한다; +- old worker와 new worker가 같은 row를 처리해도 owner token/version이 stale finalize를 막는다; +- state enum 추가는 unknown value로 old node가 row를 손상하지 않게 rollout한다; +- credential/key/template revision retirement는 active/backlog/retention scan 뒤 진행한다; +- rollback 가능한 기간 동안 new-only state와 ciphertext를 old code가 읽지 못하는 문제를 + 검증한다. + +### 27.4 clock + +- persisted schedule/expiry/provider occurred time은 UTC instant로 저장한다; +- elapsed provider deadline은 monotonic source를 쓴다; +- DB claim eligibility가 DB clock인지 application clock인지 하나로 고정한다; +- provider callback timestamp는 trusted ordering evidence로 바로 사용하지 않고 verification + window와 server received time을 함께 기록한다; +- NTP drift alert를 운영 prerequisite에 둔다. + +## 28. error taxonomy와 application mapping + +### 28.1 stable internal reason + +reason code family: + +```text +CONFIGURATION_* +CAPABILITY_UNAVAILABLE +INTENT_DUPLICATE +INTENT_FINGERPRINT_MISMATCH +BUSINESS_POLICY_REJECTED +TEMPLATE_* +RECIPIENT_* +ADMISSION_* +PROVIDER_THROTTLED +PROVIDER_AUTHORIZATION_REJECTED +PROVIDER_REQUEST_REJECTED +PROVIDER_ACCEPTED +PROVIDER_RESPONSE_INDETERMINATE +BINDING_PARKED +BINDING_RESUMED +RECONCILIATION_* +RECEIPT_* +SUPPRESSED_* +CLAIM_* +ENCRYPTION_* +EXPIRED +``` + +provider raw error code는 allowlisted mapping table을 통과해 stable reason code가 된다. unknown +provider error text를 exception/log/metric에 복제하지 않는다. + +### 28.2 application failure + +application-facing error는 대략 다음으로 제한한다. + +```text +NotificationCapabilityUnavailable +NotificationRequestRejected +NotificationIntentConflict +NotificationIntentPersistenceFailure +NotificationDispatchConflict +NotificationOutcomeIndeterminate +``` + +feature use case는 자신의 failure policy에 따라 이를 business error 또는 asynchronous operational +state로 mapping한다. controller가 provider status/SDK exception을 직접 mapping하지 않는다. + +### 28.3 inbound error + +callback inbound adapter는 signature/auth/size/schema failure를 transport status로 정확히 반환하되 +raw reason을 외부에 과다 노출하지 않는다. verified duplicate는 idempotent acknowledgement, +transient store failure는 provider retry를 유도하는 response, permanent invalid event는 provider +contract에 맞는 bounded response로 mapping한다. + +## 29. test, CI와 evidence strategy + +### 29.1 application-core test + +- feature-specific port가 reviewed kind/route/mode만 선택; +- consent/preference/quiet-hours/not-before/expiry; +- source operation idempotency와 fingerprint mismatch; +- typed parameter/recipient value validation과 redacted `toString()`; +- best-effort와 durable result 의미; +- dispatch state transition table; +- definite/retryable/permanent/indeterminate decision; +- `PARK_BINDING`과 initial fallback hold policy; +- fallback activation과 block; +- total amplification budget; +- cancellation/expiry와 maybe-sent 보존; +- callback duplicate/orphan/out-of-order command semantics. + +Spring, provider SDK, persistence entity 없이 fake port/clock을 사용한다. + +### 29.2 notification adapter test + +- code catalog와 canonical binding compiler; +- duplicate/unknown/mismatch/legacy conflict; +- SINGLE/FAN_OUT_ALL/ORDERED_FALLBACK; +- frozen plan/revision compatibility; +- template manifest/checksum/schema/locale fallback; +- text/HTML/Slack escaping과 injection property test; +- size/count/depth/control-character limit; +- provider error/outcome exact mapping; +- SDK hidden retry 0 또는 physical attempt count evidence; +- deadline/cancellation/response-loss indeterminate; +- provider descriptor/card compatibility; +- disabled/partial binding zero client/thread/probe; +- no PII/secret log, metric tag, exception. + +### 29.3 persistence-jpa integration test + +real PostgreSQL에서 다음을 검증한다. + +- business write + intent append same transaction commit/rollback; +- `TransactionPort.inRootWrite` physical commit-before-return과 ambient transaction fail-fast; +- outer REQUIRED transaction 안 best-effort 호출 rejection/rollback 시 provider call 0; +- same idempotency/same fingerprint와 mismatch; +- encrypted payload와 plaintext absence; +- concurrent `SKIP LOCKED` claim; +- owner token + expected state/version finalize; +- stale worker conflict; +- claim crash before/after `WIRE_AUTHORIZED`; +- lease 만료 뒤 늦은 exact provider result의 terminal-once append/projection merge; +- provider accepted 뒤 finalize failure; +- retry/backoff/expiry query; +- fan-out partial state; +- fallback activation atomicity; +- multi-node gate park CAS, restart persistence와 audited resume; +- park/resume/fallback/expiry 경쟁; +- cancellation/expiry/suppression과 wire authorization 경쟁; +- duplicate/out-of-order/orphan receipt; +- callback receipt apply와 delivery projection transaction; +- retention/redaction, AEAD/HMAC rotation 전후 dedupe/suppression matching; +- indexes/query plan/backlog capacity. + +H2-only test로 PostgreSQL lock/concurrency evidence를 대체하지 않는다. + +### 29.4 inbound callback test + +- provider-defined signed representation authenticity; +- SNS SignatureVersion 2 canonical string, cert URL/chain/SSRF와 delayed retry; +- current/previous verification key rotation; +- unexpected account/topic/workspace; +- oversize/content-type/schema/depth; +- batch partial invalid event policy; +- duplicate acknowledgement; +- transient store failure response; +- no raw DTO/SDK type escape; +- no sensitive body logging. + +### 29.5 app-bootstrap composition test + +- expected-state와 expected-binding-id exact set; +- canonical graph only; +- binding별 bean/client/worker/readiness exact count; +- durable binding에 store/worker/key 누락 시 startup failure; +- synchronous best-effort use case의 root transaction port wiring; +- receipt-required binding에 callback topology 누락 시 failure; +- legacy/canonical conflict; +- shutdown order와 in-flight classification; +- selected provider dependency/classpath absence failure; +- environment key registry와 sample/default YAML alignment. +- FINAL startup/readiness가 application retained-evidence read use case만 호출하고 + app-bootstrap repository/entity/JDBC 직접 접근이 0임; +- cleanup 뒤 FRESH/UPGRADE full bounded evidence read와 Java verifier wiring이 유지되고 + forged/mismatched retained row에서는 readiness/provider I/O가 0임. + +### 29.6 local protocol/fault test + +real provider 호출 없는 deterministic server/fake에서: + +- exact request auth/header/body mapping; +- response status/body/error mapping; +- 429/retry-after; +- timeout before connect/during possible write/after response; +- truncated/malformed success response; +- connection reset; +- provider SDK actual invocation count; +- cancellation and client resource close; +- request body/response log redaction. + +mock이 provider semantics를 창작하지 않도록 fixture는 공식 contract의 allowlisted case만 구현한다. + +### 29.7 Slack real-provider lane + +real network test는 `:adapter:outbound:notification:test`에 넣지 않는다. 그 focused test는 항상 +offline deterministic test이며 credential/network 유무에 따른 skip/pass가 없어야 한다. +`app-bootstrap` 소유의 명시적 opt-in `notificationReadiness` source set/harness가 별도 developer +sandbox/workspace/channel에서 다음 safe smoke를 실행한다. + +- real `chat.postMessage`; +- returned channel/ts와 optional conversation presence; +- message rendering/escaping; +- no production workspace/token; +- cleanup/update/delete가 필요한 test message lifecycle. + +invalid scope/channel/auth, 429/`Retry-After`, timeout/response loss는 local official-contract +protocol/fault suite에서 deterministic하게 매번 검증한다. 실제 channel throttling, credential +rotation/revocation은 승인된 scheduled/manual destructive drill로 분리한다. + +이 ownership을 구현할 때 notification leaf `CLAUDE.md`의 “no real network calls”는 focused +module test에 계속 적용되며, app-bootstrap opt-in readiness harness의 소유권과 금지 범위를 +함께 문서화하는 변경을 implementation deliverable로 포함한다. + +### 29.8 SES real-provider lane + +같은 app-bootstrap opt-in harness가 격리 AWS account/region, SES sandbox와 mailbox +simulator/verified recipient에서 다음 safe smoke를 실행한다. + +- account/sandbox/sending state; +- real `SendEmail`와 MessageId; +- EmailTag correlation과 SNS `SEND/DELIVERY/BOUNCE/COMPLAINT` 중 card의 safe deterministic + simulator case; +- IAM least privilege; +- exact credential source/account/region/configuration set/topic; +- no production recipient; +- feedback configuration set/account drift. + +invalid identity/auth/recipient, throttling/quota와 duplicate/out-of-order callback은 local +protocol/inbound fault suite에서 검증한다. 실제 quota pressure, credential/key rotation, +`DELIVERY_DELAY`, DLQ replay와 provider outage는 scheduled/manual drill로 분리한다. +local-rendered card에는 invalid provider template/`RENDERING_FAILURE` drill을 요구하지 않는다. + +실제 inbox placement/read를 acceptance로 사용하지 않는다. + +### 29.9 qualification evidence policy + +provider qualification은 세 lane으로 분리한다. + +1. safe real-provider smoke: exact sandbox profile에서 release/candidate마다 실행; +2. deterministic protocol/fault: offline focused/integration test에서 모든 build에 실행; +3. destructive/rotation/quota/delay drill: schedule과 승인된 manual run으로 실행. + +required lane은 secret/profile 부재를 “통과” skip으로 바꾸지 않는다. +`notificationProductionReadiness`는 exact card ID, binding/account/region/workspace, source commit, +test artifact, 실행 시각, lane type과 expiration을 가진 evidence manifest를 검증한다. freshness +window가 지났거나 required manifest가 없으면 `NOT_QUALIFIED` 또는 aggregate failure다. 한 lane의 +evidence를 다른 card/profile로 재사용하지 않는다. + +모든 manifest에는 build artifact에서만 파생한 immutable +`release_stage = PRE_CUTOVER_BRIDGE | FINAL_CLEANUP` 축을 포함한다. Gradle build가 compiled +production class/resource inventory, production dependency lock/source digest와 artifact digest로 +구조 manifest를 만들고 detector가 이를 판정한다. caller나 environment가 stage를 override할 수 +없다. 두 stage 모두 additive V7 +transport-proof-registry/permit/attestation/operation journal/history resource를 보존하며 +table/column/migration 문자열 자체는 executable legacy marker가 아니다. legacy path와 fenced +bridge, PRE cutover catalog/route set, initializer/switch/permit/terminalizer/attestation +class/bean/controller와 세 operator permission surface가 모두 존재하고 final cleanup migration이 없을 때만 +`PRE_CUTOVER_BRIDGE`, 그 executable surface와 CUTOVER_WAIT production branch/role mapping이 모두 +없고 retained canonical catalog/route set/graph, retained V7과 reviewed cleanup migration이 있을 +때만 `FINAL_CLEANUP`이다. +일부만 남은 +mixed/unknown artifact는 manifest를 발급하지 않는다. final aggregator는 +`PRE_CUTOVER_BRIDGE` evidence를 cleanup artifact에 재사용하지 않는다. + +detector가 비교할 legacy class/config marker 이름은 verification source와 reviewed detector +test allowlist에 명시적으로 남긴다. production consumer-zero 검사는 registered production +leaf의 `src/**/src/main` tree와 production +config만 대상으로 하고, 별도 allowlist test가 detector marker의 complete set과 allowlist 밖 +reference 0을 검증한다. 문자열 분할/난독화로 hygiene scan을 피하지 않는다. + +ownership setup도 release stage별로 닫힌 계약이다. PRE artifact의 provider/local lane은 +absent/LEGACY fence를 test fixture SQL로 우회하지 않는다. isolated sandbox에서 exact PRE +artifact를 legacy-only/dark로 배포하고 audited batch `INITIALIZE_LEGACY`를 root-commit한다. +그 뒤 canonical-only instances를 같은 PRE artifact의 `CUTOVER_WAIT`로 올리고 route별 +`BEGIN_DRAIN`에서 trusted external issuer의 complete old-node inventory signed header와 row set을 +동결한다. old-node 0도 header 한 건을 요구한다. expired ACTIVE permit은 별도 authenticated +bounded terminalizer를 root-commit한 뒤 read-only snapshot으로 다시 확인하며 query/COMPLETE가 +암묵적으로 state를 바꾸지 않는다. + +PRE qualification ownership evidence는 다음 closed union이다. + +- `PRE_QUIESCENCE_EVIDENCE`: exact signed BEGIN inventory, selected signed quiescence attestation, + exact registry/permit/holder/node set, per-node deployment-generation tombstone와 legacy + credential/egress irreversible revocation, consumer inventory 0, provider-call ledger identity/ + snapshot/open-count 0, ACTIVE permit 0을 요구한다; +- `PRE_HARD_BOUND_EVIDENCE`: exact signed BEGIN inventory, all-hard-bound registry/evidence revision, + `RELEASED|EXPIRED_PROVEN` permit과 ACTIVE permit 0을 요구하며 selected/current-drain + attestation은 금지한다. + +두 branch가 모두 있거나 둘 다 없으면 qualification을 발급하지 않는다. exact +`ACTIVE/CANONICAL@g_final`을 관측한 뒤에만 provider probe를 보낸다. signed evidence의 acceptance +window 뒤에도 QUIESCENCE branch의 root-committed irreversible facts는 유효하지만, qualification +runner는 retained canonical payload/signature/trust snapshot을 Java로 다시 Ed25519 검증한다. + +FINAL qualification ownership evidence도 closed union이다. + +- `FINAL_FRESH`는 V8의 AWAITING state 뒤 external infrastructure issuer authorization으로 실행한 + `notificationFreshProvisioning`, retained signed provenance, `FRESH_PROVISIONED` + discriminator와 exact `INITIALIZE_CANONICAL_FRESH`/canonical fence set을 요구한다. signed + provenance에는 exact DB resource/birth certificate, 세 zero inventory, provider-ledger zero와 + issuer가 서명 전에 commit한 irreversible no-legacy-authority fence의 전체 retained field가 + 있어야 한다; +- `FINAL_UPGRADE`는 V8의 `UPGRADE_VALIDATED` discriminator, validated history digest와 exact + canonical upgrade fence/history set을 요구한다. + +둘 다 있거나 둘 다 없거나 반대 branch provenance/history가 섞이면 fail closed한다. production +lane은 production private key를 artifact/environment/DB에 두지 않은 external issuer만 +수락한다. deterministic local issuer의 `LOCAL_TEST` evidence는 production qualification을 +충족하지 못한다. qualification runner도 production과 같은 application read use case -> +retained-evidence query port -> persistence read-only adapter -> verifier port를 사용한다. FRESH는 +discriminator/provenance/init/fence, UPGRADE는 discriminator와 full +operation/registry/permit/inventory row와 selected·superseded·unselected를 포함한 모든 +attestation header/child를 bounded snapshot으로 읽고 Java payload/SPKI/trust 및 semantic exact +equality를 재검증한다. cleanup artifact에서 이 read seam이나 retained row가 빠지면 +qualification을 발급하지 않는다. + +FINAL은 transitional endpoint/class/permission surface 0, 정확히 migrator/runtime/provisioner +세 database role, migration-only migrator ownership과 runtime non-ownership을 증명한다. +runtime의 retained cutover write·cutover sequence·transitional function `EXECUTE`는 0이되 +operational-journal exact least-privilege DML/SELECT는 유지되어야 한다. provisioner는 exact +snapshot/read-lock과 apply 두 function `EXECUTE`만 가지며 generic +SELECT/DML/sequence/DDL/role-membership과 다른 function `EXECUTE`는 0이어야 한다. test는 Java +검증 뒤 expiry까지 pause하면 apply mutation 0, apply 성공 뒤 commit 지연은 irreversible +birth/fence 아래 안전하고 commit acknowledgement 전 success 0, issuer fence commit 전 +authorization 발급 0도 검증한다. enforcement activation/read-back보다 앞선 zero snapshot, +post-enforcement source revision/time이 없는 manifest와 fence seal 전 signing도 거부한다. +credential revoke 전부터 열린 legacy DB session/provider connection을 가진 paused client를 +resume해도 session/flow termination과 established-flow deny 때문에 DB/provider I/O가 0임을 +integration evidence로 남긴다. + +manifest는 writer route-set digest와 exact canonical generation-set digest 외에 위 PRE/FINAL +discriminator와 branch별 signed payload/history digest를 가진다. mixed marker, caller stage +override 또는 branch mismatch는 fail closed한다. abort로 `g_final` 또는 runtime +expected-generation profile이 바뀌면 기존 PRE evidence는 stale이며 같은 production semantics로 +sandbox cutover/qualification을 다시 수행한다. + +### 29.10 privacy/cardinality test + +- representative PII/secret marker를 log/span/metric scrape/exception/DB plaintext scan에서 검색; +- metric unique time-series upper bound; +- queue/backlog dump와 actuator/health payload redaction; +- Java `toString()`/assertion snapshot redaction; +- backup/export fixture에서 ciphertext/key reference만 확인; +- terminal retention/redaction과 dedupe tombstone 분리. + +### 29.11 proposed task/lane + +다음 이름은 구현 계획에서 생성할 conceptual target이며 현재 존재한다고 주장하지 않는다. + +```text +:application-core:test +:adapter:outbound:notification:test +:adapter:outbound:persistence-jpa:test +:adapter:inbound:web:test +:app-bootstrap:test + +notificationContractTest +notificationPostgresIntegrationTest +notificationSlackProtocolTest +notificationSesProtocolTest +notificationPrivacyTest +:app-bootstrap:notificationSlackReadiness +:app-bootstrap:notificationSesReadiness +notificationProductionReadiness +``` + +`notificationProductionReadiness`는 §17.1에서 release가 선택한 exact card ID set의 required +real-provider lane, persistence +concurrency, callback, privacy와 config composition evidence를 aggregate한다. + +### 29.12 evidence claim matrix + +| 주장 | 최소 evidence | +| --- | --- | +| local route/render behavior | unit/property/contract | +| durable append | same-DB transaction integration | +| concurrent single-owner claim | real PostgreSQL concurrency/fault | +| no blind retry after maybe-send | crash/response-loss state test | +| Slack inline/durable exact card R2 | sandbox real API + mode별 transaction/fault/protocol/config | +| SES durable SNS exact card R2 | sandbox real API + EmailTag/SNS feedback + IAM/config | +| zero-resource disabled | bootstrap bean/thread/client/probe assertions | +| PII-safe | log/metric/span/DB/retention scan | +| rolling revision compatibility | all live/retained revision migration/rollback test | +| production topology R3 | production-like scale/failure/rotation exercise | + +한 row의 evidence를 다른 provider, mode, region, workspace 또는 topology로 일반화하지 않는다. + +## 30. performance와 chaos qualification + +### 30.1 load profile + +적어도 다음 workload를 분리한다. + +- steady transactional email; +- burst security Slack alert; +- provider throttling 중 backlog; +- callback burst; +- retry/reconcile storm; +- large-but-valid template rendering; +- mixed critical/best-effort route. + +측정: + +- append p50/p95/p99와 business transaction 영향; +- eligible-to-first-attempt lag; +- provider attempt latency; +- claim/finalize DB TPS와 lock wait; +- encryption/render CPU/heap; +- backlog recovery rate; +- duplicate provider call evidence; +- callback apply lag; +- payload redaction lag. + +### 30.2 failure injection + +- process kill after claim; +- process kill immediately before/after `WIRE_AUTHORIZED` commit와 provider call; +- lease 만료 중 blocked provider call의 늦은 accepted response; +- provider accepted response 뒤 DB unavailable; +- DB commit success response loss to caller; +- key manager/secret manager unavailable; +- old template/key revision removed; +- Slack/SES auth revoked; +- provider 429/throttle/outage; +- malformed provider response; +- callback before accepted finalize; +- duplicate/out-of-order/corrupt callback; +- concurrent fallback finalizer가 next leg 하나만 활성화하는 경쟁; +- cancellation/expiry/suppression과 wire authorization 경쟁; +- SES response-loss 뒤 EmailTag + verified `SEND`로 accepted 복원; +- `DELIVERY_DELAY/DELIVERY/COMPLAINT` fact 모든 순열의 동일 projection; +- HMAC rotation 전 event/suppression의 rotation 후 replay; +- SNS 늦은 정상 retry와 위조 `SigningCertURL`; +- payload ciphertext redaction 뒤 receipt/suppression matching; +- clock skew; +- disk/DB capacity pressure; +- rolling deploy with old/new worker. + +각 fault 뒤 state가 terminal인지 retry/reconcile/manual인지와 duplicate risk를 증거로 남긴다. + +### 30.3 no exactly-once claim + +테스트에서 duplicate 0건이 관찰되어도 외부 provider와 DB 사이 exactly-once를 증명한 것이 +아니다. readiness card는 다음처럼 표현한다. + +```text +at-least-one durable intent record ++ bounded single-owner local attempt ++ provider/card-specific retry/reconciliation ++ explicit indeterminate state +``` + +provider native idempotency/reconciliation이 없으면 unknown window의 duplicate 또는 terminal +manual resolution risk를 runbook에 남긴다. + +## 31. Gradle dependency와 architecture + +### 31.1 notification leaf + +notification leaf가 유지할 project edge: + +```text +domain-core +application-core +shared-contract +adapter-outbound-support +``` + +추가할 수 있는 external library 후보: + +- AWS SDK for Java 2.x SES v2 module; +- Slack Java SDK Web API client 또는 같은 leaf 안의 bounded provider-local HTTP engine; +- template/rendering library가 필요하면 sandboxable, bounded, reflection-off evidence가 있는 + 최소 모듈; +- provider response JSON/HTTP dependencies는 leaf 내부 implementation detail. + +초기 구현 계획에서 Slack은 공식 +[Java Slack SDK](https://docs.slack.dev/tools/java-slack-sdk/)의 Web API client를 우선 +평가한다. timeout, proxy, TLS, retry, connection lifecycle과 actual attempt count를 통제하지 +못하면 provider-local bounded client로 바꾸며, generic `adapter-outbound-httpclient`를 몰래 +의존하지 않는다. + +모든 external dependency는 lockfile, license, CVE, transitive HTTP/logging conflict와 Java +21/Spring Boot 4 호환을 검증한다. + +### 31.2 persistence-jpa + +notification table/migration와 store adapter는 persistence-jpa leaf에 추가한다. + +- persistence entity/repository가 application이나 notification leaf로 나가지 않는다; +- application port를 구현한다; +- application-owned `NotificationFinalizationRetainedEvidenceQueryPort`를 구현하는 read-only + adapter가 FRESH/UPGRADE branch의 full bounded child row를 한 consistent snapshot으로 읽고 + persistence entity가 아닌 immutable application projection을 반환한다; +- `TransactionPort.inRootWrite`는 ambient actual transaction을 거부하고 physical commit 뒤 + 반환한다. 기존 join-capable `inWrite`와 의미를 섞거나 `NEVER` propagation을 추가하지 않는다; +- PostgreSQL-specific claim SQL은 adapter 내부다; +- encryption abstraction의 key material은 persistence entity에 노출하지 않는다; +- schema migration/rollback/retention index를 같은 owner leaf가 검증한다. + +### 31.3 inbound web + +callback controller/verifier는 inbound web leaf에 두고 application receipt use case만 호출한다. +notification outbound adapter의 internal provider type에 의존하지 않는다. provider-specific +signature code가 application DTO로 유출되지 않게 inbound internal collaborator로 둔다. +SES R2 ingress는 SNS SignatureVersion 2 verifier와 provider-neutral +`NotificationReceiptIngressDescriptor`를 제공한다. send-side notification adapter를 직접 +호출하거나 그 settings class를 import하지 않는다. + +### 31.4 app-bootstrap + +bootstrap은 다음만 조합한다. + +- canonical settings에서 파생한 notification-local send profile, inbound receipt profile과 + persistence profile; +- adapter별 capability descriptor와 application pure compatibility validator; +- current `SecretSource`를 adapter-owned credential/key material factory에 연결하는 bridge; +- application dispatch/receipt use case; +- FINAL retained-evidence read use case와 query/verifier port implementation binding; +- notification provider port implementation; +- persistence store implementation; +- scheduler/executor/lifecycle/readiness. + +retry/fallback/consent/state policy 자체를 `@Configuration`이나 settings class에 구현하지 않는다. +bootstrap composition이 `ApplicationContext`/bean reflection으로 sibling capability를 추론하지 +않으며, secret/key material을 settings/application value에 보관하지 않는다. bootstrap +readiness는 위 application read use case만 호출하고 repository, persistence entity, JDBC, +query adapter나 verifier 구현을 직접 호출하지 않는다. + +### 31.5 registry 변경 조건 + +다음 요구가 생기면 `modules.json`, `settings.gradle`, Gradle dependency gate, architecture test와 +문서를 함께 변경하는 별도 architecture decision이 필요하다. + +- notification leaf가 generic HTTP client capability를 의존; +- broker consumer/inbox를 위한 inbound messaging leaf; +- provider callback 전용 inbound notification leaf; +- notification persistence를 독립 leaf로 분리; +- 별도 notification service/deployment. + +현재 19-leaf 경계를 우회해 app-bootstrap에 consumer/business handler를 넣지 않는다. + +## 32. 단계별 migration + +### Phase 0 — truth와 activation drift 정리 + +- 현재 implementation/evidence 표 확정; +- README/CLAUDE/YAML/env registry/conditional test의 selector drift inventory; +- canonical config와 migration alias 결정; +- 기존 `slack-webhook`, `google-email`, `NotificationPort`를 R0 legacy로 명시; +- production consumer 0과 fake-only evidence 명시; +- design 승인 전 behavior 변경 없음. + +Acceptance: + +- 한 문서에서 current truth를 재현할 수 있다; +- legacy/canonical key의 removal/cutover rule이 정해진다; +- R0를 R2로 오해하는 문구가 없다. + +### Phase 1 — application semantic foundation + +- bounded identity/value; +- feature-specific application request factory/policy와 outbound port pattern; +- typed parameter/recipient와 redacted value; +- intent/mode/policy/result; +- synchronous best-effort용 `TransactionPort.inRootWrite` boundary; +- provider-neutral plan/append/store/attempt/receipt ports; +- state/outcome/fingerprint contract; +- application contract/unit test. + +Acceptance: + +- core에 Spring/JPA/SDK/transport/raw DTO가 없다; +- critical vs best-effort가 type/catalog로 구분된다; +- best-effort는 physical commit 뒤에만 send하고 ambient transaction에서는 side effect 전에 + fail-fast한다; +- indeterminate/fallback/state transition test가 있다. + +### Phase 2 — catalog, template와 canonical activation + +- code catalog/route compiler; +- immutable template manifest/assets; +- typed renderer/locale/escaping/limits; +- canonical settings/expected-state/expected-binding-ids; +- adapter별 descriptor와 application compatibility validator; +- zero-resource binding; +- startup/readiness graph; +- legacy conflict fail-fast. + +Acceptance: + +- binding graph가 exact tuple로 compile된다; +- no binding resources 0; +- template drift/injection/locale test; +- existing legacy는 아직 별도 path로만 동작한다. + +### Phase 3 — Slack Web API best-effort reference + +- `slack-web-api` provider/client; +- one physical attempt/outcome mapping; +- per-channel rate/admission; +- `BEST_EFFORT_INLINE`; +- app-bootstrap sandbox readiness lane; +- incoming webhook legacy capability descriptor. + +Acceptance: + +- `chat.postMessage` response `(channel, ts)`와 exact state; +- timeout/response loss indeterminate; +- no documented idempotency를 readiness/runbook에 반영; +- no PII/secret telemetry. + +이 단계는 durable Notification R2 완료가 아니다. + +### Phase 4 — durable PostgreSQL workflow와 SES submission + +- intent/delivery/attempt/receipt schema; +- same-transaction append; +- claim token/attempt execution token/`WIRE_AUTHORIZED`/terminal-once result; +- shared admission gate park/resume generation; +- versioned direct AEAD/HMAC rotation/retention; +- dispatcher/retry/reconcile protocol; +- Slack Web API durable-single binding과 response-loss terminal unknown; +- SES v2 one-recipient send와 EmailTag attempt correlation; +- account/quota/IAM/readiness; +- concurrency/crash/finalize failure integration test. + +Acceptance: + +- same DB append atomicity; +- stale owner 차단; +- multi-instance park/restart/resume에서 binding fault backlog 보존; +- provider call transaction 밖; +- maybe-send crash -> indeterminate; +- Slack durable response-loss를 blind retry하지 않음; +- SES MessageId는 provider accepted로만 표시; +- selected submission cards의 real-provider evidence. + +### Phase 5 — feedback, suppression과 operational R2 + +- verified SES -> SNS HTTPS feedback callback와 DLQ; +- duplicate/orphan/out-of-order receipt; +- technical suppression; +- backlog/reconcile/redaction health; +- runbook/alerts/dashboards; +- rolling revision/key/credential test; +- `notificationProductionReadiness`. + +Acceptance: + +- selected Slack/SES cards의 required lanes no-skip; +- feedback authenticity/dedupe/race evidence; +- privacy/cardinality/retention evidence; +- rollback/rotation/failure drill; +- blocker/high architecture review 0. + +### Phase 6 — legacy removal과 optional provider + +- route별 retained signed old-node inventory, irreversible quiescence와 PRE closed-union cutover + evidence; +- V8 AWAITING/UPGRADE discriminator, post-migration irreversible no-legacy-authority fence가 + 결합된 signed DB-birth provenance, retained read seam과 FINAL closed-union evidence; +- legacy `NotificationPort`, global fail-open wrapper와 stale config 제거; +- `google-email` 제거 또는 exact Gmail card로 rename/rebuild; +- incoming webhook/Gmail/SMTP/SES stored template optional cards; +- 필요 시 inbound messaging/notification leaf architecture migration. + +Acceptance: + +- dual-running duplicate path 없음; +- env/sample/docs/test가 canonical graph 하나만 사용; +- optional provider가 baseline 보장을 자동 상속하지 않음. + +## 33. rollout와 cutover + +### 33.1 schema/code/config 순서 + +```text +expand schema + -> new code dark/disabled + -> contract and readiness evidence + -> route-specific canonical binding + -> old path disabled for that route + -> observation window + -> legacy config/code contract +``` + +old notifier와 new durable dispatcher가 같은 business event에 동시에 send하지 않게 route별 +single-writer cutover token/config revision을 둔다. 구현은 §16.7.1의 PostgreSQL fence/permit과 +`BEGIN_DRAIN -> bounded external poll -> COMPLETE_SWITCH`를 사용한다. 각 node config에는 legacy와 +canonical key를 동시에 넣지 않고, rolling node 간 config revision 차이는 shared owner/generation이 +fail-closed로 중재한다. legacy transport hard deadline이 증명되지 않으면 TTL expiry로 drain을 +추론하지 않고 BEGIN에서 동결한 complete old-node inventory, signed quiesce/consumer/ledger-0 +evidence, per-node deployment-generation tombstone와 legacy credential/egress irreversible +revocation, ACTIVE permit 0가 모두 준비될 때까지 switch를 중단한다. signed evidence는 수락 +시점의 bounded window 안에서 Java가 검증하고 root-commit하며, 이후 COMPLETE는 retained +signature와 immutable facts를 재검증한다. + +canonical-only PRE node는 persisted route set이 exact catalog이고 각 route가 configured target의 +LEGACY/DRAINING predecessor 또는 CANONICAL target인 경우에만 `CUTOVER_WAIT`로 liveness-healthy +기동할 수 있다. predecessor/DRAINING route의 admission/worker/provider call은 0이며, committed +CANONICAL target을 fresh read한 route만 열린다. route별 전환 중 mixed set은 이 closed state +범위에서 허용된다. FINAL artifact는 wait branch를 제거하고 all-canonical exact target만 +허용한다. + +첫 bridge release는 V7이나 startup에서 legacy ownership을 자동 생성하지 않는다. 인증된 +least-privilege operator가 canonical key catalog + PRE cutover decorator와 exact equality인 전체 route/generation +set/reason/token으로 §16.7.1의 audited batch `INITIALIZE_LEGACY`를 root-commit한 뒤에만 bridge +admission을 연다. 이 초기화 전에 fence가 없거나 set이 partial이면 legacy와 canonical admission은 +모두 fail closed한다. abort가 있으면 최종 canonical generation은 단순 `g+1`이 아니라 latest +committed `COMPLETE_SWITCH` operation result와 일치하는 route별 `g_final`이다. runtime expected +generation/config와 qualification manifest는 이 값을 exact axis로 가지며 변경 시 재검증한다. +final cleanup은 empty-store만으로 fresh를 추론하지 않는다. V8은 complete upgrade history를 +`UPGRADE_VALIDATED`로 보존하거나 notification state가 완전히 빈 database를 +`AWAITING_SIGNED_FRESH_PROVISIONING`으로 남긴다. any nonempty missing-fence/partial-history +database는 중단한다. fresh database는 V8 뒤 별도 `notificationFreshProvisioning` job이 +independent infrastructure issuer가 exact DB resource/birth certificate에 irreversible +no-legacy-authority fence를 먼저 commit한 뒤 서명한 environment/DB-system/database/schema/ +artifact/route-set/application-workload-0/business-consumer-0/legacy-node-0/provider-ledger-0 +authorization을 받는다. 같은 provisioner transaction에서 exact snapshot/read-lock function, +Java verifier, exact apply function 순서와 fresh apply-time window check를 통과한 후에만 retained +provenance, reviewed canonical route set 전체의 `INITIALIZE_CANONICAL_FRESH`와 +`FRESH_PROVISIONED` discriminator를 만든다. AWAITING 동안 normal runtime/readiness와 provider +I/O는 0이고 upgrade database의 complete operation history와 route별 `g_final` fence 및 FINAL +startup/readiness read seam은 cleanup 뒤에도 보존한다. + +### 33.2 backfill + +과거 generic outbox event나 log를 새 notification intent로 자동 backfill하지 않는다. 이미 +provider side effect가 있었는지 알 수 없기 때문이다. + +backfill이 필요하면: + +- 대상 event type/window를 명시; +- prior-send evidence와 duplicate tolerance를 검토; +- one-time migration occurrence ID; +- dry inventory와 승인; +- bounded batch; +- 별도 audit/rollback; +- active path와 dedupe collision test. + +### 33.3 rollback + +new route를 rollback한다고 legacy path로 자동 resend하지 않는다. 이미 provider accepted 또는 +indeterminate intent가 있을 수 있다. + +- `BEGIN_DRAIN` 뒤 `COMPLETE_SWITCH` 전에는 `ABORT_DRAIN`만 허용하며 새 LEGACY generation을 + 발급한다; +- `COMPLETE_SWITCH` 뒤에는 이 설계가 reverse handoff를 지원하지 않는다. legacy re-enable, + CANONICAL→LEGACY owner CAS와 old-code worker 재개를 금지한다; +- 이상 징후가 있으면 canonical 신규 append admission과 worker를 shared gate로 pause; +- in-flight/new states가 더 진행되지 않음을 확인; +- plan/template/key revision 유지; +- exact route별 pending/accepted/indeterminate inventory; +- 자동 resend 없이 manual reconciliation 또는 forward-fix 결정. + +COMPLETE 뒤 reverse가 필요하다면 canonical in-flight/intent backlog/provider-result를 drain하는 +별도 state machine, permit/evidence protocol, duplicate policy와 provider requalification을 먼저 +설계·승인해야 한다. duplicate risk 승인만으로 legacy를 다시 열 수 없다. + +## 34. completion criteria + +### 34.1 design 완료 + +- [x] current code/config/evidence diagnosis +- [x] alternatives와 selected architecture +- [x] module ownership/dependency direction +- [x] application contract와 business/technical policy boundary +- [x] mode/routing/template/provider outcome +- [x] durable state/store/claim/reconciliation +- [x] Slack/SES reference provider semantics +- [x] config/security/observability/health/lifecycle +- [x] test/evidence/migration/completion criteria +- [x] independent architecture/consistency/durability design review blocker/high 0 +- [x] 사용자 설계 승인 +- [x] 승인 후 implementation plan 작성 + +이 문서의 상태는 “상세 설계 승인, 구현 계획 작성, 구현 미착수”다. + +### 34.2 minimum implementation R2 + +- [ ] canonical binding/expected-state와 legacy conflict 제거 +- [ ] feature-specific application semantic port +- [ ] typed/frozen intent/template/route plan +- [ ] best-effort와 durable path 분리 +- [ ] same-DB intent/delivery/attempt/receipt journal +- [ ] owner token/version claim/finalize +- [ ] encrypted PII + keyed HMAC + retention +- [ ] indeterminate/reconcile/fallback safety +- [ ] Slack Web API exact provider card +- [ ] SES v2 submission/feedback exact provider card +- [ ] callback verification/dedupe/orphan race +- [ ] zero-resource disabled +- [ ] bounded deadlines/concurrency/amplification +- [ ] health/metrics/traces/runbook +- [ ] focused/architecture/real-provider/no-skip/privacy evidence +- [ ] independent review blocker/high 0 +- [ ] LLM Wiki capture + +### 34.3 금지할 완료 표현 + +- fake test만으로 “Slack/Email 연동 완료”; +- MessageId/ts만으로 “사용자에게 전달 완료”; +- global fail-open path를 “reliable notification”; +- local DB claim만으로 “exactly once delivery”; +- optional provider lane skip 상태로 “production ready”; +- 한 provider/account/region card로 notification module 전체 R2; +- config만 있고 실제 client/consumer가 없는데 “enabled”; +- branch-note/검증 없이 “설계/구현 완료”. + +## 35. 운영 runbook 최소 항목 + +### 35.1 backlog 증가 + +1. channel/provider/route/mode별 backlog와 oldest eligible age를 본다. +2. provider quota/auth/outage, DB claim/finalize, encryption/render failure를 분리한다. +3. concurrency를 무조건 높이기 전에 provider rate와 DB capacity를 확인한다. +4. expiry와 business urgency를 확인하되 consent/suppression을 우회하지 않는다. +5. pause/resume은 audited route use case로 수행한다. + +### 35.2 indeterminate 증가 + +1. attempt phase/provider/revision/deployment window를 분류한다. +2. blind retry/fallback을 켜지 않는다. +3. provider card의 reconciliation 가능 여부를 확인한다. +4. Slack은 `ts`가 없는 response-loss를 terminal unknown으로 두고, SES는 verified + EmailTag+`SEND` fact가 있을 때만 accepted로 복원한다. +5. duplicate risk와 business impact를 함께 보고 manual resolution한다. + +### 35.3 bounce/complaint 증가 + +1. verified feedback인지와 account/configuration set을 확인한다. +2. recipient/content를 log/export하지 않는다. +3. technical suppression 적용/충돌을 점검한다. +4. business consent/unsubscribe 시스템과 별도 incident로 연계한다. +5. sender identity/DKIM/DMARC/content/reputation과 provider account 상태를 조사한다. + +### 35.4 credential/key/template rotation + +1. new revision을 추가하고 startup/readiness를 통과한다. +2. new append/attempt가 새 revision을 쓰는지 확인한다. +3. old active/backlog/receipt/retention row를 inventory한다. +4. 모든 live/retained row가 참조하는 revision compatibility와 rollback을 검증한다. +5. old secret/key/template를 제거한 뒤 canary/failure alert를 확인한다. + +### 35.5 provider outage + +- liveness restart loop를 만들지 않는다; +- durable route는 bounded retry/backlog, best-effort route는 explicit outcome; +- auth/account/config fault는 shared admission gate를 park하고, readiness/config 재검증 뒤 audited + generation-bumping resume만 수행한다; +- unknown outcome과 definite rejection을 분리; +- cross-provider fallback은 authoritative failure일 때만; +- expiry/retention/capacity 임계치와 stakeholder communication을 실행한다. + +## 36. 승인 gate와 남은 설계 가정 + +다음 다섯 결정을 이 설계의 승인 gate로 둔다. + +1. 최소 R2 durability는 business state와 notification journal이 같은 PostgreSQL transaction에 + 참여할 수 있다는 가정을 채택한다. +2. 최소 R2는 intent 하나에 logical recipient 정확히 한 명이며, delivery row는 provider leg다. +3. Slack reference는 `chat.postMessage`, email reference는 Amazon SES v2로 채택하고 §17.1의 + exact 세 card만 초기 qualification 대상으로 둔다. +4. SES feedback은 `configuration set -> SNS HTTPS adapter-inbound-web + DLQ` topology로 고정한다. +5. 기존 `slack-webhook`, `google-email`, raw `NotificationPort`는 R0 legacy best-effort로 + 분류하고 route별 migration 뒤 제거한다. + +이 가정 중 1번이 실제 제품 topology와 다르면 구현 계획을 쓰기 전에 broker handoff + +inbound messaging/inbox architecture로 설계를 수정해야 한다. + +사용자는 2026-07-28에 위 다섯 결정을 승인했다. 구현은 +[Notification Production Capability Implementation Plan](../plans/2026-07-28-notification-production-capability.md)의 +작은 TDD task, owner leaf, registry-derived Gradle path, focused test, architecture gate, +real-provider evidence와 rollback point를 따른다. + +## 37. primary references + +### 37.1 repository + +- [Production Capability Platform Design](2026-07-26-production-capability-platform-design.md) +- [Redis Production Capability Deep Design](2026-07-26-redis-production-capability-design.md) +- [Fileserver Production Capability Deep Design](2026-07-26-fileserver-production-capability-design.md) +- [HTTP Client Production Capability Deep Design](2026-07-27-httpclient-production-capability-design.md) +- `AGENTS.md` +- `src/config/architecture/modules.json` +- `src/adapter/outbound/notification/CLAUDE.md` +- `src/adapter/outbound/notification/README.md` + +### 37.2 Slack + +- [chat.postMessage](https://docs.slack.dev/reference/methods/chat.postMessage/) +- [Incoming Webhooks](https://docs.slack.dev/messaging/sending-messages-using-incoming-webhooks/) +- [Web API rate limits](https://docs.slack.dev/apis/web-api/rate-limits/) +- [Web API response contract](https://docs.slack.dev/apis/web-api/) +- [conversations.history](https://docs.slack.dev/reference/methods/conversations.history/) +- [message event](https://docs.slack.dev/reference/events/message/) +- [chat.update](https://docs.slack.dev/reference/methods/chat.update/) +- [chat.delete](https://docs.slack.dev/reference/methods/chat.delete/) +- [Slack OAuth installation](https://docs.slack.dev/authentication/installing-with-oauth/) +- [Slack token rotation](https://docs.slack.dev/authentication/using-token-rotation/) +- [Slack developer sandboxes](https://docs.slack.dev/tools/developer-sandboxes/) +- [Java Slack SDK](https://docs.slack.dev/tools/java-slack-sdk/) +- [Slack `auth.test`](https://docs.slack.dev/reference/methods/auth.test/) + +### 37.3 Amazon SES and AWS SDK + +- [SES v2 SendEmail](https://docs.aws.amazon.com/ses/latest/APIReference-V2/API_SendEmail.html) +- [SES email sending process](https://docs.aws.amazon.com/ses/latest/dg/send-email-concepts-process.html) +- [SES quotas](https://docs.aws.amazon.com/ses/latest/dg/quotas.html) +- [SES GetAccount](https://docs.aws.amazon.com/ses/latest/APIReference-V2/API_GetAccount.html) +- [Managing SES sending quota errors](https://docs.aws.amazon.com/ses/latest/dg/manage-sending-quotas-errors.html) +- [SES EventDestination](https://docs.aws.amazon.com/ses/latest/APIReference-V2/API_EventDestination.html) +- [SES message insights](https://docs.aws.amazon.com/ses/latest/APIReference-V2/API_GetMessageInsights.html) +- [Monitoring SES activity using notifications](https://docs.aws.amazon.com/ses/latest/dg/monitor-sending-activity-using-notifications.html) +- [SES event publishing and message tags](https://docs.aws.amazon.com/ses/latest/dg/monitor-using-event-publishing.html) +- [SES SNS event examples](https://docs.aws.amazon.com/ses/latest/dg/event-publishing-retrieving-sns-examples.html) +- [SES sending authorization](https://docs.aws.amazon.com/ses/latest/dg/control-user-access.html) +- [SES suppression list](https://docs.aws.amazon.com/ses/latest/dg/sending-email-global-suppression-list.html) +- [SNS signature verification](https://docs.aws.amazon.com/sns/latest/dg/sns-verify-signature-of-message.html) +- [SNS HTTP retry policy](https://docs.aws.amazon.com/sns/latest/dg/sns-message-delivery-retries.html) +- [SNS HTTP subscription confirmation](https://docs.aws.amazon.com/sns/latest/dg/http-subscription-confirmation-json.html) +- [SNS `ConfirmSubscription`](https://docs.aws.amazon.com/sns/latest/api/API_ConfirmSubscription.html) +- [AWS SDK v2 default credentials provider chain](https://docs.aws.amazon.com/sdk-for-java/latest/developer-guide/credentials-chain.html) +- [AWS SDK v2 retry strategy](https://docs.aws.amazon.com/sdk-for-java/latest/developer-guide/retry-strategy.html) + +### 37.4 Gmail and SMTP references for future cards + +- [Gmail users.messages.send](https://developers.google.com/workspace/gmail/api/reference/rest/v1/users.messages/send) +- [Gmail API quotas](https://developers.google.com/workspace/gmail/api/reference/quota) +- [Gmail API sending](https://developers.google.com/workspace/gmail/api/guides/sending) +- [OAuth service accounts/domain-wide delegation](https://developers.google.com/identity/protocols/oauth2/service-account) +- [Gmail push notifications](https://developers.google.com/workspace/gmail/api/guides/push) +- [RFC 5321 — SMTP](https://www.rfc-editor.org/rfc/rfc5321) +- [RFC 3461 — SMTP DSN](https://www.rfc-editor.org/rfc/rfc3461) +- [RFC 4954 — SMTP AUTH](https://www.rfc-editor.org/rfc/rfc4954) +- [RFC 8314 — TLS for email submission/access](https://www.rfc-editor.org/rfc/rfc8314) diff --git a/src/adapter/outbound/notification/CLAUDE.md b/src/adapter/outbound/notification/CLAUDE.md index a8d9f18a..660f77ed 100644 --- a/src/adapter/outbound/notification/CLAUDE.md +++ b/src/adapter/outbound/notification/CLAUDE.md @@ -12,10 +12,28 @@ Package root: `dev.caskeleton.adapter.outbound.notification`. ## Responsibility -- Implement notification provider routing and provider-specific Slack/email clients behind ports. -- Own provider settings, technical fallback, and provider adaptation. +- Preserve the current raw notification router/provider seams only as the `R0 legacy` compatibility + baseline until the reviewed canonical cutover removes them. +- Implement future provider protocols behind application-owned ports without leaking SDK, transport, + bootstrap, or persistence types. +- Own provider settings, technical fallback mechanics, and provider adaptation; application policy + owns mode, eligibility, retry/fallback decisions and business failure semantics. - Reuse `adapter:outbound:support` for shared outbound concerns. +## Current R0 freeze + +- `RoutingNotifier` performs route-list fan-out over `(Channel, providerId)`. +- `FailOpenNotificationProvider` applies one global fail-open rule. +- `google-email`/`GoogleEmailClient` and `slack-webhook`/`SlackClient` are fake-only extension seams, + not production integrations or qualified provider cards. +- Checked-in provider selector keys drift from the router's `routes` + provider `enabled` grammar. + Preserve and document that drift until the canonical graph replaces it; do not silently reinterpret + the old keys. +- There are no feature/application production consumers and no real-provider, durable, receipt, + security, load, or rotation evidence. +- The exact legacy deletion inventory lives in [README.md](README.md). Do not add behavior to those + classes while building their canonical replacements. + ## Boundaries - Allowed dependency edges come only from the module's diff --git a/src/adapter/outbound/notification/README.md b/src/adapter/outbound/notification/README.md index df412927..4fbe71c2 100644 --- a/src/adapter/outbound/notification/README.md +++ b/src/adapter/outbound/notification/README.md @@ -1,13 +1,34 @@ -# adapter:outbound:notification — 설계 결정 참조 +# adapter:outbound:notification — R0 legacy truth + +> 현재 구현 전체는 교체 전 호환성 기준선인 `R0 legacy`다. `GoogleEmailClient`와 +> `SlackClient`는 project-supplied seam일 뿐 실제 Google Mail 또는 Slack 연동이 아니며, +> provider/card qualification evidence도 없다. 알림(email/Slack 등) 아웃바운드 어댑터 모듈. 패키지 루트: `dev.caskeleton.adapter.outbound.notification`. `:adapter:outbound:support` 에 의존해 공유 correlation / fail-open 의존성 로깅을 재사용한다. -허용/금지 의존 정책은 `src/build.gradle` 의 -`allowedProjectDependencies['adapter:outbound:notification']` 항목이 SSOT 다(이 모듈은 아직 -별도 CLAUDE.md 를 두지 않았다). 이 문서는 코드 주석에서 덜어낸 **설계 결정의 근거**를 모아둔 -참조용 기록이다. +허용/금지 의존 정책은 `src/config/architecture/modules.json`의 +`adapter-outbound-notification` row가 SSOT다. 이 문서는 코드 주석에서 덜어낸 **설계 결정의 +근거**와 canonical 구현 전 삭제 대상을 모아둔 참조용 기록이다. + +## Task 1 R0 truth table + +| 축 | 현재 사실 | 증거와 한계 | +| --- | --- | --- | +| application contract | raw `NotificationPort.notify(Channel, route, Notification)` | mode, transaction, receipt, attempt certainty가 없는 R0 port | +| routing | `(channel, providerId)` registry + route별 provider ID list fan-out | `RoutingNotifierTest`; route list의 모든 provider를 순서대로 호출 | +| provider failure | 모든 provider를 중앙 `FailOpenNotificationProvider`로 감싸고 예외를 삼킴 | global fail-open이며 application kind별 정책이 아님 | +| unbound route | `AdapterDisabledException` fail-fast | `NotificationAdapterTest`; disabled sentinel은 없음 | +| email seam | `google-email` + `GoogleEmailClient` interface | module 안 production client/SDK/credential/protocol 구현 0 | +| Slack seam | `slack-webhook` + `SlackClient` interface | module 안 production client/SDK/credential/protocol 구현 0 | +| configuration | code는 `app.notification.routes.*`와 provider별 `*.enabled`를 읽음 | checked-in `application.yml`/env registry의 `app.notification.{slack,email}.provider` selector와 drift | +| production consumer | feature/application production consumer 0 | main source에는 application contract 선언, adapter 구현과 bootstrap composition만 존재 | +| evidence grade | local fake/contract baseline | real provider, durability, callback, security, load evidence 0; 모든 seam `R0 legacy` | + +selector drift는 이 기준선의 일부다. Task 1에서는 고치지 않는다. canonical graph가 준비되고 +cutover evidence가 생기기 전까지 기존 key를 새 의미로 재사용하거나 legacy class에 production +동작을 추가하지 않는다. ## 모듈 개요 @@ -32,3 +53,26 @@ client 는 포킹 프로젝트가 채우는 seam 이다. `channel()`+`providerId()` 로 키잉된 `NotificationProvider` 빈으로 기여한다(예: `GoogleEmailProvider`, `SlackWebhookProvider`). `GoogleEmailClient`/`SlackClient` 는 포크가 구현하는 seam 이며 실패는 데코레이터가 fail-open 처리한다. + +## Wave G deletion inventory + +다음 surface는 canonical-only cutover와 retained evidence 검증이 끝난 뒤 한 묶음으로 제거한다. +그 전에는 동작을 확장하지 않고 R0 회귀 기준선으로만 유지한다. + +- application R0 contract: + `Channel`, `Notification`, raw `NotificationPort` +- router/decorator SPI: + `NotificationConfig`, `NotificationRoutesSettings`, `RoutingNotifier`, + `NotificationProvider`, `FailOpenNotificationProvider` +- fake-only Google email seam: + `GoogleEmailClient`, `GoogleEmailProvider`, `GoogleEmailNotificationAdapterConfig` +- fake-only Slack webhook seam: + `SlackClient`, `SlackWebhookProvider`, `SlackNotificationAdapterConfig` +- legacy configuration/tests: + `app.notification.routes.*`, `app.notification.google-email.enabled`, + `app.notification.slack-webhook.enabled`, drifted + `APP_NOTIFICATION_EMAIL_PROVIDER`/`APP_NOTIFICATION_SLACK_PROVIDER`, + `NotificationAdapterTest`, `RoutingNotifierTest`와 bootstrap legacy gating cases + +accepted 또는 indeterminate work를 inventory하지 않은 상태에서 이 목록을 삭제하거나 canonical +provider로 자동 재전송하지 않는다. diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/catalog/CompiledNotificationBinding.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/catalog/CompiledNotificationBinding.java new file mode 100644 index 00000000..589a6a1e --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/catalog/CompiledNotificationBinding.java @@ -0,0 +1,151 @@ +package dev.caskeleton.adapter.outbound.notification.catalog; + +import java.util.Comparator; +import java.util.List; +import java.util.Objects; + +/** + * Fully resolved immutable route graph; only {@link NotificationBindingCompiler} can construct it. + */ +public final class CompiledNotificationBinding { + + private final NotificationRouteDescriptor route; + private final NotificationTemplateDescriptor template; + private final List targets; + private final String bindingDigest; + + CompiledNotificationBinding( + NotificationRouteDescriptor route, + NotificationTemplateDescriptor template, + List targets, + String bindingDigest) { + this.route = Objects.requireNonNull(route, "compiled notification route must be non-null"); + this.template = + Objects.requireNonNull(template, "compiled notification template must be non-null"); + Objects.requireNonNull(targets, "compiled notification targets must be non-null"); + this.targets = + targets.stream() + .map(target -> Objects.requireNonNull(target, "compiled target must be non-null")) + .sorted(Comparator.comparing(target -> target.target().targetId())) + .toList(); + if (this.targets.size() != route.targets().size()) { + throw new IllegalArgumentException( + "compiled notification target count must match route target count"); + } + this.bindingDigest = + NotificationCatalogException.requireDigest( + "compiled notification binding digest", bindingDigest); + } + + public NotificationRouteDescriptor route() { + return route; + } + + public NotificationTemplateDescriptor template() { + return template; + } + + public List targets() { + return targets; + } + + public String bindingDigest() { + return bindingDigest; + } + + @Override + public boolean equals(Object other) { + return this == other + || (other instanceof CompiledNotificationBinding that + && route.equals(that.route) + && template.equals(that.template) + && targets.equals(that.targets) + && bindingDigest.equals(that.bindingDigest)); + } + + @Override + public int hashCode() { + return Objects.hash(route, template, targets, bindingDigest); + } + + @Override + public String toString() { + return "CompiledNotificationBinding[route=" + + route.routeId().value() + + ", routeRevision=" + + route.routeRevision() + + ", template=" + + template.templateRef().templateId() + + "-v" + + template.templateRef().version() + + ", targets=, bindingDigest=" + + bindingDigest + + "]"; + } + + /** Non-forgeable resolved target exposed read-only to provider protocol implementations. */ + public static final class CompiledTarget { + + private final NotificationRouteDescriptor.Target target; + private final NotificationProviderRuntimeProfile runtimeProfile; + private final NotificationProviderDescriptor provider; + private final NotificationProviderCapabilityCard capabilityCard; + + CompiledTarget( + NotificationRouteDescriptor.Target target, + NotificationProviderRuntimeProfile runtimeProfile, + NotificationProviderDescriptor provider, + NotificationProviderCapabilityCard capabilityCard) { + this.target = Objects.requireNonNull(target, "compiled route target must be non-null"); + this.runtimeProfile = + Objects.requireNonNull(runtimeProfile, "compiled runtime profile must be non-null"); + this.provider = Objects.requireNonNull(provider, "compiled provider must be non-null"); + this.capabilityCard = + Objects.requireNonNull(capabilityCard, "compiled capability card must be non-null"); + } + + public NotificationRouteDescriptor.Target target() { + return target; + } + + public NotificationProviderRuntimeProfile runtimeProfile() { + return runtimeProfile; + } + + public NotificationProviderDescriptor provider() { + return provider; + } + + public NotificationProviderCapabilityCard capabilityCard() { + return capabilityCard; + } + + @Override + public boolean equals(Object other) { + return this == other + || (other instanceof CompiledTarget that + && target.equals(that.target) + && runtimeProfile.equals(that.runtimeProfile) + && provider.equals(that.provider) + && capabilityCard.equals(that.capabilityCard)); + } + + @Override + public int hashCode() { + return Objects.hash(target, runtimeProfile, provider, capabilityCard); + } + + @Override + public String toString() { + return "CompiledTarget[targetId=" + + target.targetId() + + ", runtimeProfileId=" + + runtimeProfile.profileId() + + ", providerId=" + + provider.providerId() + + ", capabilityCardId=" + + capabilityCard.cardId() + + ", sensitiveReferences=]"; + } + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/catalog/NotificationBindingCompiler.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/catalog/NotificationBindingCompiler.java new file mode 100644 index 00000000..6a3825e6 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/catalog/NotificationBindingCompiler.java @@ -0,0 +1,516 @@ +package dev.caskeleton.adapter.outbound.notification.catalog; + +import dev.caskeleton.application.notification.NotificationRouteId; +import dev.caskeleton.application.notification.NotificationTemplateRef; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.function.Function; + +/** + * Pure deterministic compiler over explicit provider, template, route and runtime-profile inputs. + */ +public final class NotificationBindingCompiler { + + private static final Set INITIAL_CARD_IDS = + Set.of( + "slack-web-api-inline-single-local-v1", + "slack-web-api-durable-single-local-v1", + "aws-ses-v2-durable-single-local-sns-v1"); + private static final Map APPROVED_CARD_PROVIDERS = + Map.of( + "slack-web-api-inline-single-local-v1", + "slack-web-api", + "slack-web-api-durable-single-local-v1", + "slack-web-api", + "aws-ses-v2-durable-single-local-sns-v1", + "aws-ses-v2"); + + private final List providers; + private final List templates; + private final List routes; + private final List runtimeProfiles; + private final Map activeRouteRevisions; + + public NotificationBindingCompiler( + List providers, + List templates, + List routes, + List runtimeProfiles) { + this(providers, templates, routes, runtimeProfiles, singletonActiveRevisions(routes)); + } + + public NotificationBindingCompiler( + List providers, + List templates, + List routes, + List runtimeProfiles, + Map activeRouteRevisions) { + this.providers = immutableCopy("providers", providers); + this.templates = immutableCopy("templates", templates); + this.routes = immutableCopy("routes", routes); + this.runtimeProfiles = immutableCopy("runtime profiles", runtimeProfiles); + this.activeRouteRevisions = + Map.copyOf( + Objects.requireNonNull( + activeRouteRevisions, "active notification route revisions must be non-null")); + } + + public CompiledGraph compile() { + Map providerIndex = + uniqueIndex(providers, NotificationProviderDescriptor::providerId, "provider"); + Map templateIndex = + uniqueIndex(templates, NotificationTemplateDescriptor::templateRef, "template"); + Map profileIndex = + uniqueIndex( + runtimeProfiles, NotificationProviderRuntimeProfile::profileId, "runtime profile"); + uniqueIndex( + routes, route -> route.routeId().value() + "#" + route.routeRevision(), "route revision"); + validateActiveRevisions(routes, activeRouteRevisions); + validateRegisteredCards(providerIndex.values()); + + List bindings = + routes.stream() + .sorted( + Comparator.comparing((NotificationRouteDescriptor route) -> route.routeId().value()) + .thenComparingInt(NotificationRouteDescriptor::routeRevision)) + .map(route -> compileRoute(route, providerIndex, templateIndex, profileIndex)) + .toList(); + if (bindings.isEmpty() || bindings.size() > 100) { + throw new NotificationCatalogException( + "notification binding graph must contain 1..100 routes"); + } + return new CompiledGraph( + bindings, activeRouteRevisions, manifestDigest(bindings, activeRouteRevisions)); + } + + private static CompiledNotificationBinding compileRoute( + NotificationRouteDescriptor route, + Map providers, + Map templates, + Map profiles) { + NotificationTemplateDescriptor template = templates.get(route.templateRef()); + if (template == null) { + throw new NotificationCatalogException( + "unknown template for route " + route.routeId().value()); + } + if (template.channel() != route.channel()) { + throw new NotificationCatalogException( + "template channel does not match route " + route.routeId().value()); + } + validateRouteShape(route); + + List targets = new ArrayList<>(); + for (NotificationRouteDescriptor.Target target : route.targets()) { + NotificationProviderRuntimeProfile profile = profiles.get(target.runtimeProfileId()); + if (profile == null) { + throw new NotificationCatalogException( + "unknown runtime profile for target " + target.targetId()); + } + NotificationProviderDescriptor provider = providers.get(profile.providerId()); + if (provider == null) { + throw new NotificationCatalogException( + "unknown provider for runtime profile " + profile.profileId()); + } + if (provider.legacyFailOpen()) { + throw new NotificationCatalogException( + "legacy fail-open provider cannot participate in a canonical binding"); + } + NotificationProviderCapabilityCard card = + provider.capabilityCards().stream() + .filter(candidate -> candidate.cardId().equals(profile.capabilityCardId())) + .findFirst() + .orElseThrow( + () -> + new NotificationCatalogException( + "unknown capability card for runtime profile " + profile.profileId())); + validateCompatibility(route, template, provider, card); + targets.add(new CompiledNotificationBinding.CompiledTarget(target, profile, provider, card)); + } + String bindingDigest = bindingDigest(route, template, targets); + return new CompiledNotificationBinding(route, template, targets, bindingDigest); + } + + private static void validateRouteShape(NotificationRouteDescriptor route) { + if (route.routeStrategy() + != dev.caskeleton.application.notification.NotificationRouteStrategy.SINGLE) { + throw new NotificationCatalogException( + "initial notification catalog supports SINGLE route strategy only"); + } + if (route.targets().size() != route.maximumTargets()) { + throw new NotificationCatalogException( + "route target count must equal its maximum target bound"); + } + long worstCaseCalls = + Math.addExact( + Math.multiplyExact( + (long) route.maximumTargets(), (long) route.maximumPhysicalAttempts()), + route.maximumReconcileCalls()); + if (worstCaseCalls > route.maximumTotalProviderCalls()) { + throw new NotificationCatalogException( + "route amplification exceeds maximum total provider calls"); + } + switch (route.routeStrategy()) { + case SINGLE -> { + if (route.maximumTargets() != 1 + || route.maximumFallbackActivations() != 0 + || route.targets().stream().anyMatch(target -> target.fallbackTargetId().isPresent())) { + throw new NotificationCatalogException( + "SINGLE route requires one target and no fallback"); + } + } + case FAN_OUT_ALL -> { + if (route.maximumFallbackActivations() != 0 + || route.targets().stream().anyMatch(target -> target.fallbackTargetId().isPresent())) { + throw new NotificationCatalogException("FAN_OUT_ALL route cannot define fallback"); + } + } + case ORDERED_FALLBACK -> { + if (route.maximumTargets() < 2 + || route.maximumFallbackActivations() < 1 + || route.maximumFallbackActivations() > route.maximumTargets() - 1) { + throw new NotificationCatalogException("ORDERED_FALLBACK route has invalid bounds"); + } + } + default -> + throw new NotificationCatalogException( + "unknown notification route strategy: " + route.routeStrategy()); + } + validateFallbackGraph(route); + } + + private static void validateFallbackGraph(NotificationRouteDescriptor route) { + Map edges = new HashMap<>(); + Set targets = + route.targets().stream() + .map(NotificationRouteDescriptor.Target::targetId) + .collect(java.util.stream.Collectors.toUnmodifiableSet()); + route + .targets() + .forEach( + target -> + target + .fallbackTargetId() + .ifPresent( + fallback -> { + if (!targets.contains(fallback)) { + throw new NotificationCatalogException( + "fallback references unknown target"); + } + edges.put(target.targetId(), fallback); + })); + for (String start : targets) { + Set visited = new HashSet<>(); + String current = start; + while (current != null) { + if (!visited.add(current)) { + throw new NotificationCatalogException("cyclic notification fallback graph"); + } + current = edges.get(current); + } + } + } + + private static void validateCompatibility( + NotificationRouteDescriptor route, + NotificationTemplateDescriptor template, + NotificationProviderDescriptor provider, + NotificationProviderCapabilityCard card) { + if (provider.channel() != route.channel() || card.channel() != route.channel()) { + throw new NotificationCatalogException( + "provider channel does not match route " + route.routeId().value()); + } + if (card.mode() != route.mode()) { + throw new NotificationCatalogException( + "provider mode does not match route " + route.routeId().value()); + } + if (card.routeStrategy() != route.routeStrategy()) { + throw new NotificationCatalogException( + "provider route strategy does not match route " + route.routeId().value()); + } + if (route.maximumTargets() > card.maximumTargets()) { + throw new NotificationCatalogException("route target bound exceeds provider capability"); + } + if (route.receiptRequired() && !card.receiptSupported()) { + throw new NotificationCatalogException( + "receipt-required route uses provider without receipt capability"); + } + if (route.maximumReconcileCalls() > card.maximumReconcileCalls() + || (route.maximumReconcileCalls() > 0 && !card.reconciliationSupported())) { + throw new NotificationCatalogException( + "route reconciliation bound exceeds provider capability"); + } + if (route.maximumPhysicalAttempts() > card.maximumPhysicalAttempts()) { + throw new NotificationCatalogException("route attempt bound exceeds provider capability"); + } + if (route.maximumTotalProviderCalls() > card.maximumTotalProviderCalls()) { + throw new NotificationCatalogException( + "route amplification bound exceeds provider capability"); + } + if (template.maximumRenderedBytes() > card.maximumPayloadBytes()) { + throw new NotificationCatalogException("template payload bound exceeds provider capability"); + } + if (route.fallbackAfterIndeterminate() && card.terminalIndeterminatePossible()) { + throw new NotificationCatalogException( + "fallback after an indeterminate submission is unsafe"); + } + } + + private static void validateRegisteredCards( + java.util.Collection providers) { + Set seen = new HashSet<>(); + providers.forEach( + provider -> + provider + .capabilityCards() + .forEach( + card -> { + if (!INITIAL_CARD_IDS.contains(card.cardId())) { + throw new NotificationCatalogException( + "unknown provider capability card: " + card.cardId()); + } + if (!provider + .providerId() + .equals(APPROVED_CARD_PROVIDERS.get(card.cardId()))) { + throw new NotificationCatalogException( + "provider capability card is bound to a different provider family"); + } + NotificationProviderCapabilityCard approved = + NotificationProviderCapabilityCard.initial(card.cardId()); + if (!approved.equals(card)) { + throw new NotificationCatalogException( + "provider capability card differs from approved definition: " + + card.cardId()); + } + if (!seen.add(card.cardId())) { + throw new NotificationCatalogException( + "duplicate provider capability card: " + card.cardId()); + } + })); + } + + private static String bindingDigest( + NotificationRouteDescriptor route, + NotificationTemplateDescriptor template, + List targets) { + MessageDigest digest = NotificationCatalogException.sha256(); + updateRoute(digest, route); + NotificationCatalogException.update(digest, template.templateRef().templateId()); + NotificationCatalogException.update(digest, template.templateRef().version()); + NotificationCatalogException.update(digest, template.rendererRevision()); + NotificationCatalogException.update(digest, template.checksum()); + template.supportedLocales().stream() + .map(java.util.Locale::toLanguageTag) + .sorted() + .forEach(locale -> NotificationCatalogException.update(digest, locale)); + NotificationCatalogException.update(digest, template.fallbackLocale().toLanguageTag()); + template.parameterNames().stream() + .sorted() + .forEach(parameter -> NotificationCatalogException.update(digest, parameter)); + NotificationCatalogException.update(digest, template.maximumRenderedBytes()); + targets.stream() + .sorted(Comparator.comparing(target -> target.target().targetId())) + .forEach( + target -> { + NotificationCatalogException.update(digest, target.target().targetId()); + NotificationCatalogException.update( + digest, target.target().fallbackTargetId().orElse("")); + NotificationCatalogException.update(digest, target.runtimeProfile().profileId()); + NotificationCatalogException.update( + digest, target.runtimeProfile().bindingRevision()); + NotificationCatalogException.update( + digest, target.runtimeProfile().credentialGeneration()); + NotificationCatalogException.update( + digest, target.runtimeProfile().credentialReference()); + NotificationCatalogException.update( + digest, target.runtimeProfile().destinationReference()); + NotificationCatalogException.update(digest, target.provider().providerId()); + NotificationCatalogException.update(digest, target.capabilityCard().cardId()); + }); + return NotificationCatalogException.finish(digest); + } + + private static String manifestDigest( + List bindings, + Map activeRouteRevisions) { + MessageDigest digest = NotificationCatalogException.sha256(); + bindings.forEach( + binding -> { + NotificationCatalogException.update(digest, binding.route().routeId().value()); + NotificationCatalogException.update(digest, binding.route().routeRevision()); + NotificationCatalogException.update(digest, binding.bindingDigest()); + }); + activeRouteRevisions.entrySet().stream() + .sorted(Map.Entry.comparingByKey(Comparator.comparing(NotificationRouteId::value))) + .forEach( + entry -> { + NotificationCatalogException.update(digest, "active"); + NotificationCatalogException.update(digest, entry.getKey().value()); + NotificationCatalogException.update(digest, entry.getValue()); + }); + return NotificationCatalogException.finish(digest); + } + + private static Map singletonActiveRevisions( + List routes) { + Objects.requireNonNull(routes, "notification routes must be non-null"); + Map active = new HashMap<>(); + routes.forEach( + route -> { + Objects.requireNonNull(route, "notification route entry is null"); + if (active.putIfAbsent(route.routeId(), route.routeRevision()) != null) { + throw new NotificationCatalogException( + "multiple retained route revisions require an explicit active revision map"); + } + }); + return Map.copyOf(active); + } + + private static void validateActiveRevisions( + List routes, + Map activeRouteRevisions) { + Set routeIds = + routes.stream() + .map(NotificationRouteDescriptor::routeId) + .collect(java.util.stream.Collectors.toUnmodifiableSet()); + if (!activeRouteRevisions.keySet().equals(routeIds)) { + throw new NotificationCatalogException( + "active route revision map must exactly match retained route IDs"); + } + activeRouteRevisions.forEach( + (routeId, revision) -> { + Objects.requireNonNull(routeId, "active route ID must be non-null"); + Objects.requireNonNull(revision, "active route revision must be non-null"); + if (routes.stream() + .noneMatch( + route -> route.routeId().equals(routeId) && route.routeRevision() == revision)) { + throw new NotificationCatalogException( + "active route revision does not exist in retained bindings"); + } + }); + } + + private static void updateRoute(MessageDigest digest, NotificationRouteDescriptor route) { + NotificationCatalogException.update(digest, route.routeId().value()); + NotificationCatalogException.update(digest, route.routeRevision()); + NotificationCatalogException.update(digest, route.channel().name()); + NotificationCatalogException.update(digest, route.mode().name()); + NotificationCatalogException.update(digest, route.admissionClass().name()); + NotificationCatalogException.update(digest, route.routeStrategy().name()); + NotificationCatalogException.update(digest, route.receiptRequired()); + NotificationCatalogException.update(digest, route.fallbackAfterIndeterminate()); + NotificationCatalogException.update(digest, route.maximumTargets()); + NotificationCatalogException.update(digest, route.maximumPhysicalAttempts()); + NotificationCatalogException.update(digest, route.maximumFallbackActivations()); + NotificationCatalogException.update(digest, route.maximumReconcileCalls()); + NotificationCatalogException.update(digest, route.maximumTotalProviderCalls()); + NotificationCatalogException.update(digest, route.perAttemptDeadline().toNanos()); + } + + private static List immutableCopy(String label, List source) { + Objects.requireNonNull(source, "notification " + label + " must be non-null"); + return source.stream() + .map(value -> Objects.requireNonNull(value, "notification " + label + " entry is null")) + .toList(); + } + + private static Map uniqueIndex(List values, Function key, String label) { + Map index = new HashMap<>(); + values.forEach( + value -> { + K itemKey = key.apply(value); + if (index.putIfAbsent(itemKey, value) != null) { + throw new NotificationCatalogException("duplicate " + label + ": " + itemKey); + } + }); + return Map.copyOf(index); + } + + /** Sorted immutable result and digest suitable for release evidence. */ + public static final class CompiledGraph { + + private final List bindings; + private final Map activeRouteRevisions; + private final String manifestDigest; + + private CompiledGraph( + List bindings, + Map activeRouteRevisions, + String manifestDigest) { + Objects.requireNonNull(bindings, "compiled notification bindings must be non-null"); + this.bindings = + bindings.stream() + .map(binding -> Objects.requireNonNull(binding, "compiled binding must be non-null")) + .sorted( + Comparator.comparing( + (CompiledNotificationBinding binding) -> + binding.route().routeId().value()) + .thenComparingInt(binding -> binding.route().routeRevision())) + .toList(); + this.activeRouteRevisions = + Map.copyOf( + Objects.requireNonNull( + activeRouteRevisions, "active route revisions must be non-null")); + this.manifestDigest = + NotificationCatalogException.requireDigest( + "notification graph manifest digest", manifestDigest); + } + + public List bindings() { + return bindings; + } + + public Map activeRouteRevisions() { + return activeRouteRevisions; + } + + public String manifestDigest() { + return manifestDigest; + } + + public CompiledNotificationBinding activeBinding(NotificationRouteId routeId) { + Objects.requireNonNull(routeId, "active notification route ID must be non-null"); + Integer revision = activeRouteRevisions.get(routeId); + if (revision == null) { + return null; + } + return bindings.stream() + .filter(binding -> binding.route().routeId().equals(routeId)) + .filter(binding -> binding.route().routeRevision() == revision) + .findFirst() + .orElseThrow( + () -> new IllegalStateException("active notification binding is not retained")); + } + + @Override + public boolean equals(Object other) { + return this == other + || (other instanceof CompiledGraph that + && bindings.equals(that.bindings) + && activeRouteRevisions.equals(that.activeRouteRevisions) + && manifestDigest.equals(that.manifestDigest)); + } + + @Override + public int hashCode() { + return Objects.hash(bindings, activeRouteRevisions, manifestDigest); + } + + @Override + public String toString() { + return "CompiledGraph[bindingCount=" + + bindings.size() + + ", activeRouteCount=" + + activeRouteRevisions.size() + + ", manifestDigest=" + + manifestDigest + + "]"; + } + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/catalog/NotificationCanonicalRouteCatalog.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/catalog/NotificationCanonicalRouteCatalog.java new file mode 100644 index 00000000..066b7a1d --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/catalog/NotificationCanonicalRouteCatalog.java @@ -0,0 +1,124 @@ +package dev.caskeleton.adapter.outbound.notification.catalog; + +import dev.caskeleton.application.notification.NotificationCanonicalWriterRouteSet; +import dev.caskeleton.application.notification.NotificationRouteId; +import java.security.MessageDigest; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** Retained key-only source of truth derived only from the compiled binding graph. */ +public final class NotificationCanonicalRouteCatalog { + + private final List routes; + + private NotificationCanonicalRouteCatalog(List routes) { + Objects.requireNonNull(routes, "canonical notification routes must be non-null"); + this.routes = + routes.stream() + .map(route -> Objects.requireNonNull(route, "canonical route key must be non-null")) + .sorted(Comparator.naturalOrder()) + .toList(); + if (this.routes.isEmpty() || this.routes.size() > 100) { + throw new IllegalArgumentException("canonical route catalog must contain 1..100 routes"); + } + if (new HashSet<>(this.routes).size() != this.routes.size()) { + throw new NotificationCatalogException("duplicate canonical route key"); + } + if (this.routes.stream().map(RouteKey::routeId).distinct().count() != this.routes.size()) { + throw new NotificationCatalogException("duplicate canonical route ID has multiple revisions"); + } + } + + public static NotificationCanonicalRouteCatalog fromCompiledGraph( + NotificationBindingCompiler.CompiledGraph graph) { + Objects.requireNonNull(graph, "compiled notification graph must be non-null"); + return new NotificationCanonicalRouteCatalog( + graph.activeRouteRevisions().entrySet().stream() + .map(entry -> new RouteKey(entry.getKey(), entry.getValue())) + .toList()); + } + + static NotificationCanonicalRouteCatalog fromRoutes(List routes) { + Objects.requireNonNull(routes, "notification route descriptors must be non-null"); + return new NotificationCanonicalRouteCatalog( + routes.stream() + .map( + route -> { + Objects.requireNonNull(route, "notification route descriptor must be non-null"); + return new RouteKey(route.routeId(), route.routeRevision()); + }) + .toList()); + } + + public List routes() { + return routes; + } + + public NotificationCanonicalWriterRouteSet toApplication( + Map predecessorGenerations) { + Objects.requireNonNull( + predecessorGenerations, "canonical predecessor generations must be non-null"); + if (!predecessorGenerations.keySet().equals(Set.copyOf(routes))) { + throw new NotificationCatalogException( + "canonical generation config must exactly match route catalog keys"); + } + return new NotificationCanonicalWriterRouteSet( + routes.stream() + .map( + route -> + new NotificationCanonicalWriterRouteSet.RouteRevision( + route.routeId(), + route.routeRevision(), + Objects.requireNonNull( + predecessorGenerations.get(route), + "predecessor generation must be non-null"))) + .toList()); + } + + public String digest() { + MessageDigest digest = NotificationCatalogException.sha256(); + routes.forEach( + route -> { + NotificationCatalogException.update(digest, route.routeId().value()); + NotificationCatalogException.update(digest, route.routeRevision()); + }); + return NotificationCatalogException.finish(digest); + } + + @Override + public boolean equals(Object other) { + return this == other + || (other instanceof NotificationCanonicalRouteCatalog that && routes.equals(that.routes)); + } + + @Override + public int hashCode() { + return routes.hashCode(); + } + + @Override + public String toString() { + return "NotificationCanonicalRouteCatalog[routes=" + routes + "]"; + } + + public record RouteKey(NotificationRouteId routeId, int routeRevision) + implements Comparable { + + public RouteKey { + Objects.requireNonNull(routeId, "canonical notification route ID must be non-null"); + if (routeRevision < 1 || routeRevision > 1_000_000) { + throw new IllegalArgumentException("canonical route revision must be in 1..1000000"); + } + } + + @Override + public int compareTo(RouteKey other) { + int routeOrder = routeId.value().compareTo(other.routeId.value()); + return routeOrder != 0 ? routeOrder : Integer.compare(routeRevision, other.routeRevision); + } + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/catalog/NotificationCatalogException.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/catalog/NotificationCatalogException.java new file mode 100644 index 00000000..f82c7ee2 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/catalog/NotificationCatalogException.java @@ -0,0 +1,68 @@ +package dev.caskeleton.adapter.outbound.notification.catalog; + +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; + +/** Fail-closed error for an invalid checked-in notification catalog. */ +public final class NotificationCatalogException extends RuntimeException { + + public NotificationCatalogException(String message) { + super(message); + } + + static String requireSlug(String field, String value) { + if (value == null || !value.matches("[a-z][a-z0-9.-]{0,62}")) { + throw new IllegalArgumentException(field + " must match [a-z][a-z0-9.-]{0,62}"); + } + return value; + } + + static String requireOpaque(String field, String value) { + if (value == null || !value.matches("[A-Za-z0-9][A-Za-z0-9._:-]{0,127}")) { + throw new IllegalArgumentException( + field + " must contain 1..128 opaque identifier characters"); + } + return value; + } + + static String requireDigest(String field, String value) { + if (value == null || !value.matches("[0-9a-f]{64}")) { + throw new IllegalArgumentException(field + " must be a lowercase SHA-256 digest"); + } + return value; + } + + static MessageDigest sha256() { + try { + return MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException unavailable) { + throw new IllegalStateException( + "SHA-256 must be available on every Java runtime", unavailable); + } + } + + static void update(MessageDigest digest, String value) { + byte[] encoded = value.getBytes(StandardCharsets.UTF_8); + digest.update(ByteBuffer.allocate(Integer.BYTES).putInt(encoded.length).array()); + digest.update(encoded); + } + + static void update(MessageDigest digest, int value) { + digest.update(ByteBuffer.allocate(Integer.BYTES).putInt(value).array()); + } + + static void update(MessageDigest digest, long value) { + digest.update(ByteBuffer.allocate(Long.BYTES).putLong(value).array()); + } + + static void update(MessageDigest digest, boolean value) { + digest.update((byte) (value ? 1 : 0)); + } + + static String finish(MessageDigest digest) { + return HexFormat.of().formatHex(digest.digest()); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/catalog/NotificationCutoverRouteCatalog.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/catalog/NotificationCutoverRouteCatalog.java new file mode 100644 index 00000000..2fd0408d --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/catalog/NotificationCutoverRouteCatalog.java @@ -0,0 +1,151 @@ +package dev.caskeleton.adapter.outbound.notification.catalog; + +import dev.caskeleton.application.notification.NotificationCanonicalWriterRouteSet; +import dev.caskeleton.application.notification.NotificationWriterRouteSet; +import java.security.MessageDigest; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +/** PRE-only legacy proof metadata decorating exactly the retained canonical route keys. */ +public record NotificationCutoverRouteCatalog( + NotificationCanonicalRouteCatalog canonical, List routes) { + + public NotificationCutoverRouteCatalog { + Objects.requireNonNull(canonical, "canonical route catalog must be non-null"); + Objects.requireNonNull(routes, "cutover route catalog must be non-null"); + routes = + routes.stream() + .map(route -> Objects.requireNonNull(route, "cutover route must be non-null")) + .sorted(Comparator.comparing(CutoverRoute::route)) + .toList(); + if (!routes.stream().map(CutoverRoute::route).toList().equals(canonical.routes())) { + throw new NotificationCatalogException( + "cutover route keys must exactly equal canonical route keys"); + } + List aliases = routes.stream().flatMap(route -> route.legacyAlias().stream()).toList(); + if (new HashSet<>(aliases).size() != aliases.size()) { + throw new NotificationCatalogException("cutover route contains duplicate legacy alias"); + } + } + + public NotificationWriterRouteSet toApplication( + Map predecessorGenerations) { + NotificationCanonicalWriterRouteSet applicationCanonical = + canonical.toApplication(predecessorGenerations); + Map< + NotificationCanonicalRouteCatalog.RouteKey, + NotificationCanonicalWriterRouteSet.RouteRevision> + routeIndex = + applicationCanonical.routes().stream() + .collect( + java.util.stream.Collectors.toUnmodifiableMap( + route -> + new NotificationCanonicalRouteCatalog.RouteKey( + route.routeId(), route.routeRevision()), + route -> route)); + return new NotificationWriterRouteSet( + applicationCanonical, + routes.stream() + .map( + route -> + new NotificationWriterRouteSet.RouteProfile( + routeIndex.get(route.route()), + route.legacyAlias(), + route.transportProfiles().stream() + .map(LegacyTransportProfile::toApplication) + .toList())) + .toList()); + } + + public String digest() { + MessageDigest digest = NotificationCatalogException.sha256(); + NotificationCatalogException.update(digest, canonical.digest()); + routes.forEach( + route -> { + NotificationCatalogException.update(digest, route.route().routeId().value()); + NotificationCatalogException.update(digest, route.route().routeRevision()); + NotificationCatalogException.update(digest, route.legacyAlias().orElse("")); + route + .transportProfiles() + .forEach( + profile -> { + NotificationCatalogException.update(digest, profile.profileId()); + NotificationCatalogException.update(digest, profile.proofClass().name()); + NotificationCatalogException.update(digest, profile.evidenceRevision()); + NotificationCatalogException.update(digest, profile.activeAdmissionProfile()); + NotificationCatalogException.update( + digest, profile.reviewedHardBoundEvidence()); + }); + }); + return NotificationCatalogException.finish(digest); + } + + public record CutoverRoute( + NotificationCanonicalRouteCatalog.RouteKey route, + Optional legacyAlias, + List transportProfiles) { + + public CutoverRoute { + Objects.requireNonNull(route, "cutover route key must be non-null"); + Objects.requireNonNull(legacyAlias, "legacy route alias container must be non-null"); + legacyAlias = + legacyAlias.map( + alias -> NotificationCatalogException.requireSlug("legacy route alias", alias)); + Objects.requireNonNull( + transportProfiles, "legacy transport profile registry must be non-null"); + transportProfiles = + transportProfiles.stream() + .map( + profile -> + Objects.requireNonNull(profile, "legacy transport profile must be non-null")) + .sorted(Comparator.comparing(LegacyTransportProfile::profileId)) + .toList(); + if (transportProfiles.isEmpty() || transportProfiles.size() > 8) { + throw new IllegalArgumentException( + "legacy transport profile registry must contain 1..8 entries"); + } + if (new HashSet<>(transportProfiles.stream().map(LegacyTransportProfile::profileId).toList()) + .size() + != transportProfiles.size()) { + throw new IllegalArgumentException( + "legacy transport profile registry contains duplicate profile"); + } + if (transportProfiles.stream().filter(LegacyTransportProfile::activeAdmissionProfile).count() + != 1) { + throw new IllegalArgumentException( + "legacy transport registry requires exactly one active admission profile"); + } + } + } + + public record LegacyTransportProfile( + String profileId, + NotificationWriterRouteSet.ProofClass proofClass, + String evidenceRevision, + boolean activeAdmissionProfile, + boolean reviewedHardBoundEvidence) { + + public LegacyTransportProfile { + profileId = + NotificationCatalogException.requireSlug("legacy transport profile ID", profileId); + Objects.requireNonNull(proofClass, "legacy transport proof class must be non-null"); + evidenceRevision = + NotificationCatalogException.requireSlug( + "legacy transport evidence revision", evidenceRevision); + if (proofClass == NotificationWriterRouteSet.ProofClass.HARD_BOUND_PROVEN + && !reviewedHardBoundEvidence) { + throw new IllegalArgumentException( + "HARD_BOUND_PROVEN requires reviewed integration evidence"); + } + } + + NotificationWriterRouteSet.TransportProfile toApplication() { + return new NotificationWriterRouteSet.TransportProfile( + profileId, proofClass, evidenceRevision, activeAdmissionProfile); + } + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/catalog/NotificationPlanAdapter.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/catalog/NotificationPlanAdapter.java new file mode 100644 index 00000000..ddcab405 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/catalog/NotificationPlanAdapter.java @@ -0,0 +1,86 @@ +package dev.caskeleton.adapter.outbound.notification.catalog; + +import dev.caskeleton.application.notification.NotificationFrozenPlan; +import dev.caskeleton.application.notification.NotificationIntentDraft; +import dev.caskeleton.application.notification.NotificationKindPolicy; +import dev.caskeleton.application.notification.NotificationPlanPort; +import dev.caskeleton.application.notification.NotificationPlanningResult; +import dev.caskeleton.application.notification.NotificationReasonCode; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.Objects; + +/** Converts a selected adapter-local binding to the application-owned immutable plan snapshot. */ +public final class NotificationPlanAdapter implements NotificationPlanPort { + + private static final NotificationReasonCode ROUTE_UNAVAILABLE = + new NotificationReasonCode("CATALOG_ROUTE_UNAVAILABLE"); + private static final NotificationReasonCode POLICY_MISMATCH = + new NotificationReasonCode("CATALOG_POLICY_MISMATCH"); + private static final NotificationReasonCode PARAMETERS_MISMATCH = + new NotificationReasonCode("CATALOG_PARAMETERS_MISMATCH"); + + private final NotificationBindingCompiler.CompiledGraph graph; + + public NotificationPlanAdapter(NotificationBindingCompiler.CompiledGraph graph) { + this.graph = Objects.requireNonNull(graph, "compiled notification graph must be non-null"); + } + + @Override + public NotificationPlanningResult plan(NotificationIntentDraft draft) { + Objects.requireNonNull(draft, "notification intent draft must be non-null"); + CompiledNotificationBinding binding = graph.activeBinding(draft.policy().routeId()); + if (binding == null) { + return new NotificationPlanningResult.CapabilityUnavailable(ROUTE_UNAVAILABLE); + } + if (!matchesPolicy(binding.route(), draft.policy())) { + return new NotificationPlanningResult.Rejected(POLICY_MISMATCH); + } + if (!binding.template().parameterNames().equals(draft.parameters().values().keySet())) { + return new NotificationPlanningResult.Rejected(PARAMETERS_MISMATCH); + } + + Locale locale = binding.template().selectLocale(draft.requestedLocale()); + List targets = + binding.targets().stream() + .sorted(Comparator.comparing(target -> target.target().targetId())) + .map( + target -> + new NotificationFrozenPlan.FrozenTarget( + binding.targets().stream() + .sorted(Comparator.comparing(item -> item.target().targetId())) + .toList() + .indexOf(target), + target.target().targetId(), + target.capabilityCard().cardId(), + target.runtimeProfile().bindingRevision(), + target.runtimeProfile().credentialGeneration())) + .toList(); + NotificationFrozenPlan.BindingSnapshot snapshot = + new NotificationFrozenPlan.BindingSnapshot( + binding.route().routeRevision(), + binding.bindingDigest(), + binding.template().checksum(), + binding.template().rendererRevision(), + targets, + binding.route().receiptRequired(), + binding.route().perAttemptDeadline()); + return new NotificationPlanningResult.Planned( + NotificationFrozenPlan.from(draft, locale, snapshot)); + } + + private static boolean matchesPolicy( + NotificationRouteDescriptor route, NotificationKindPolicy policy) { + return route.channel() == policy.channel() + && route.mode() == policy.mode() + && route.admissionClass() == policy.admissionClass() + && route.routeStrategy() == policy.routeStrategy() + && route.templateRef().equals(policy.templateRef()) + && route.maximumTargets() == policy.maxTargetsPerRecipient() + && route.maximumPhysicalAttempts() == policy.maxPhysicalAttemptsPerDelivery() + && route.maximumFallbackActivations() == policy.maxFallbackActivations() + && route.maximumReconcileCalls() == policy.maxReconcileCalls() + && route.maximumTotalProviderCalls() == policy.maxTotalProviderCallsPerIntent(); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/catalog/NotificationProviderCapabilityCard.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/catalog/NotificationProviderCapabilityCard.java new file mode 100644 index 00000000..4eefb824 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/catalog/NotificationProviderCapabilityCard.java @@ -0,0 +1,99 @@ +package dev.caskeleton.adapter.outbound.notification.catalog; + +import dev.caskeleton.application.notification.NotificationChannel; +import dev.caskeleton.application.notification.NotificationMode; +import dev.caskeleton.application.notification.NotificationRouteStrategy; +import java.util.Objects; + +/** Closed, code-owned capability statement for one qualified provider protocol shape. */ +public record NotificationProviderCapabilityCard( + String cardId, + NotificationChannel channel, + NotificationMode mode, + NotificationRouteStrategy routeStrategy, + boolean receiptSupported, + boolean reconciliationSupported, + boolean hiddenRetriesControlled, + boolean terminalIndeterminatePossible, + int maximumTargets, + int maximumPhysicalAttempts, + int maximumReconcileCalls, + int maximumTotalProviderCalls, + int maximumPayloadBytes) { + + public NotificationProviderCapabilityCard { + cardId = NotificationCatalogException.requireSlug("provider capability card ID", cardId); + Objects.requireNonNull(channel, "provider capability channel must be non-null"); + Objects.requireNonNull(mode, "provider capability mode must be non-null"); + Objects.requireNonNull(routeStrategy, "provider capability route strategy must be non-null"); + if (maximumTargets < 1 + || maximumTargets > 16 + || maximumPhysicalAttempts < 1 + || maximumPhysicalAttempts > 10 + || maximumReconcileCalls < 0 + || maximumReconcileCalls > 10 + || maximumTotalProviderCalls < 1 + || maximumTotalProviderCalls > 64 + || maximumPayloadBytes < 1 + || maximumPayloadBytes > 10_000_000) { + throw new IllegalArgumentException("provider capability bounds are outside supported limits"); + } + if (!hiddenRetriesControlled) { + throw new IllegalArgumentException("provider capability must control hidden retries"); + } + } + + /** Returns one of the only three initially qualified provider capability cards. */ + public static NotificationProviderCapabilityCard initial(String cardId) { + return switch (cardId) { + case "slack-web-api-inline-single-local-v1" -> + new NotificationProviderCapabilityCard( + cardId, + NotificationChannel.SLACK, + NotificationMode.BEST_EFFORT_INLINE, + NotificationRouteStrategy.SINGLE, + false, + false, + true, + true, + 1, + 1, + 0, + 1, + 32_000); + case "slack-web-api-durable-single-local-v1" -> + new NotificationProviderCapabilityCard( + cardId, + NotificationChannel.SLACK, + NotificationMode.DURABLE_ASYNC, + NotificationRouteStrategy.SINGLE, + false, + false, + true, + true, + 1, + 1, + 0, + 1, + 32_000); + case "aws-ses-v2-durable-single-local-sns-v1" -> + new NotificationProviderCapabilityCard( + cardId, + NotificationChannel.EMAIL, + NotificationMode.DURABLE_ASYNC, + NotificationRouteStrategy.SINGLE, + true, + true, + true, + true, + 1, + 1, + 1, + 2, + 64_000); + default -> + throw new NotificationCatalogException( + "unknown initial provider capability card: " + cardId); + }; + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/catalog/NotificationProviderCapabilityDescriptorSource.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/catalog/NotificationProviderCapabilityDescriptorSource.java new file mode 100644 index 00000000..a5f6322e --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/catalog/NotificationProviderCapabilityDescriptorSource.java @@ -0,0 +1,43 @@ +package dev.caskeleton.adapter.outbound.notification.catalog; + +import dev.caskeleton.application.notification.NotificationProviderCapabilityDescriptor; +import java.util.Comparator; +import java.util.List; +import java.util.Objects; +import java.util.Set; + +/** Derives application-owned actual capability facts from the compiled provider cards. */ +public final class NotificationProviderCapabilityDescriptorSource { + + private final List descriptors; + + public NotificationProviderCapabilityDescriptorSource( + NotificationBindingCompiler.CompiledGraph graph) { + Objects.requireNonNull(graph, "compiled notification graph must be non-null"); + this.descriptors = + graph.bindings().stream() + .flatMap(binding -> binding.targets().stream()) + .map(CompiledNotificationBinding.CompiledTarget::capabilityCard) + .distinct() + .sorted(Comparator.comparing(NotificationProviderCapabilityCard::cardId)) + .map(NotificationProviderCapabilityDescriptorSource::toApplication) + .toList(); + } + + public List descriptors() { + return descriptors; + } + + private static NotificationProviderCapabilityDescriptor toApplication( + NotificationProviderCapabilityCard card) { + return new NotificationProviderCapabilityDescriptor( + card.cardId(), + card.channel(), + Set.of(card.mode()), + card.receiptSupported(), + card.reconciliationSupported(), + card.hiddenRetriesControlled(), + card.maximumTargets(), + card.maximumPayloadBytes()); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/catalog/NotificationProviderDescriptor.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/catalog/NotificationProviderDescriptor.java new file mode 100644 index 00000000..9ebffdeb --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/catalog/NotificationProviderDescriptor.java @@ -0,0 +1,41 @@ +package dev.caskeleton.adapter.outbound.notification.catalog; + +import dev.caskeleton.application.notification.NotificationChannel; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; + +/** Provider family descriptor; legacy fail-open providers deliberately have no canonical cards. */ +public record NotificationProviderDescriptor( + String providerId, + NotificationChannel channel, + boolean legacyFailOpen, + List capabilityCards) { + + public NotificationProviderDescriptor { + providerId = NotificationCatalogException.requireSlug("notification provider ID", providerId); + Objects.requireNonNull(channel, "notification provider channel must be non-null"); + Objects.requireNonNull(capabilityCards, "provider capability cards must be non-null"); + capabilityCards = + capabilityCards.stream() + .map(card -> Objects.requireNonNull(card, "provider capability card must be non-null")) + .sorted(Comparator.comparing(NotificationProviderCapabilityCard::cardId)) + .toList(); + if (new HashSet<>( + capabilityCards.stream().map(NotificationProviderCapabilityCard::cardId).toList()) + .size() + != capabilityCards.size()) { + throw new IllegalArgumentException("provider descriptor contains duplicate capability cards"); + } + if (legacyFailOpen && !capabilityCards.isEmpty()) { + throw new IllegalArgumentException("legacy provider cannot advertise canonical capability"); + } + if (!legacyFailOpen && capabilityCards.isEmpty()) { + throw new IllegalArgumentException("canonical provider must advertise a capability card"); + } + if (capabilityCards.stream().anyMatch(card -> card.channel() != channel)) { + throw new IllegalArgumentException("provider capability card channel must match provider"); + } + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/catalog/NotificationProviderRuntimeProfile.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/catalog/NotificationProviderRuntimeProfile.java new file mode 100644 index 00000000..da9f2cd5 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/catalog/NotificationProviderRuntimeProfile.java @@ -0,0 +1,45 @@ +package dev.caskeleton.adapter.outbound.notification.catalog; + +/** Runtime indirection for secrets and destinations; sensitive references never leave this type. */ +public record NotificationProviderRuntimeProfile( + String profileId, + String providerId, + String bindingRevision, + String capabilityCardId, + String credentialGeneration, + String credentialReference, + String destinationReference) { + + public NotificationProviderRuntimeProfile { + profileId = NotificationCatalogException.requireSlug("provider runtime profile ID", profileId); + providerId = NotificationCatalogException.requireSlug("provider ID", providerId); + bindingRevision = + NotificationCatalogException.requireSlug("provider binding revision", bindingRevision); + capabilityCardId = + NotificationCatalogException.requireSlug("provider capability card ID", capabilityCardId); + credentialGeneration = + NotificationCatalogException.requireSlug( + "provider credential generation", credentialGeneration); + credentialReference = + NotificationCatalogException.requireOpaque( + "provider credential reference", credentialReference); + destinationReference = + NotificationCatalogException.requireOpaque( + "provider destination reference", destinationReference); + } + + @Override + public String toString() { + return "NotificationProviderRuntimeProfile[profileId=" + + profileId + + ", providerId=" + + providerId + + ", bindingRevision=" + + bindingRevision + + ", capabilityCardId=" + + capabilityCardId + + ", credentialGeneration=" + + credentialGeneration + + ", credentialReference=, destinationReference=]"; + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/catalog/NotificationRouteDescriptor.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/catalog/NotificationRouteDescriptor.java new file mode 100644 index 00000000..e473bfa6 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/catalog/NotificationRouteDescriptor.java @@ -0,0 +1,93 @@ +package dev.caskeleton.adapter.outbound.notification.catalog; + +import dev.caskeleton.application.notification.NotificationAdmissionClass; +import dev.caskeleton.application.notification.NotificationChannel; +import dev.caskeleton.application.notification.NotificationMode; +import dev.caskeleton.application.notification.NotificationRouteId; +import dev.caskeleton.application.notification.NotificationRouteStrategy; +import dev.caskeleton.application.notification.NotificationTemplateRef; +import java.time.Duration; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +/** Provider-neutral route declaration compiled against explicit local catalogs. */ +public record NotificationRouteDescriptor( + NotificationRouteId routeId, + int routeRevision, + NotificationChannel channel, + NotificationMode mode, + NotificationAdmissionClass admissionClass, + NotificationRouteStrategy routeStrategy, + NotificationTemplateRef templateRef, + boolean receiptRequired, + boolean fallbackAfterIndeterminate, + int maximumTargets, + int maximumPhysicalAttempts, + int maximumFallbackActivations, + int maximumReconcileCalls, + int maximumTotalProviderCalls, + Duration perAttemptDeadline, + List targets) { + + public NotificationRouteDescriptor { + Objects.requireNonNull(routeId, "notification route ID must be non-null"); + if (routeRevision < 1 || routeRevision > 1_000_000) { + throw new IllegalArgumentException("notification route revision must be in 1..1000000"); + } + Objects.requireNonNull(channel, "notification route channel must be non-null"); + Objects.requireNonNull(mode, "notification route mode must be non-null"); + Objects.requireNonNull(admissionClass, "notification admission class must be non-null"); + Objects.requireNonNull(routeStrategy, "notification route strategy must be non-null"); + Objects.requireNonNull(templateRef, "notification template reference must be non-null"); + if (maximumTargets < 1 + || maximumTargets > 16 + || maximumPhysicalAttempts < 1 + || maximumPhysicalAttempts > 10 + || maximumFallbackActivations < 0 + || maximumFallbackActivations > 15 + || maximumReconcileCalls < 0 + || maximumReconcileCalls > 10 + || maximumTotalProviderCalls < 1 + || maximumTotalProviderCalls > 64) { + throw new IllegalArgumentException("notification route bounds are outside supported limits"); + } + Objects.requireNonNull(perAttemptDeadline, "per-attempt deadline must be non-null"); + if (perAttemptDeadline.isZero() + || perAttemptDeadline.isNegative() + || perAttemptDeadline.compareTo(Duration.ofMinutes(5)) > 0) { + throw new IllegalArgumentException( + "per-attempt deadline must be positive and at most five minutes"); + } + Objects.requireNonNull(targets, "notification route targets must be non-null"); + targets = + targets.stream() + .map(target -> Objects.requireNonNull(target, "notification target must be non-null")) + .sorted(Comparator.comparing(Target::targetId)) + .toList(); + if (targets.isEmpty() || targets.size() > 16) { + throw new IllegalArgumentException("notification route targets must contain 1..16 entries"); + } + if (new HashSet<>(targets.stream().map(Target::targetId).toList()).size() != targets.size()) { + throw new IllegalArgumentException("notification route contains duplicate target IDs"); + } + } + + /** One provider leg and optional next fallback leg. */ + public record Target( + String targetId, String runtimeProfileId, Optional fallbackTargetId) { + + public Target { + targetId = NotificationCatalogException.requireSlug("notification target ID", targetId); + runtimeProfileId = + NotificationCatalogException.requireSlug( + "notification runtime profile ID", runtimeProfileId); + Objects.requireNonNull(fallbackTargetId, "fallback target ID container must be non-null"); + fallbackTargetId = + fallbackTargetId.map( + value -> NotificationCatalogException.requireSlug("fallback target ID", value)); + } + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/catalog/NotificationTemplateDescriptor.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/catalog/NotificationTemplateDescriptor.java new file mode 100644 index 00000000..b8d3e170 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/catalog/NotificationTemplateDescriptor.java @@ -0,0 +1,81 @@ +package dev.caskeleton.adapter.outbound.notification.catalog; + +import dev.caskeleton.application.notification.NotificationChannel; +import dev.caskeleton.application.notification.NotificationTemplateRef; +import java.util.Comparator; +import java.util.HashSet; +import java.util.Locale; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +/** Immutable checked-in template manifest metadata used by the binding compiler. */ +public record NotificationTemplateDescriptor( + NotificationTemplateRef templateRef, + NotificationChannel channel, + String rendererRevision, + String checksum, + Set supportedLocales, + Locale fallbackLocale, + Set parameterNames, + int maximumRenderedBytes) { + + public NotificationTemplateDescriptor { + Objects.requireNonNull(templateRef, "notification template reference must be non-null"); + Objects.requireNonNull(channel, "notification template channel must be non-null"); + rendererRevision = + NotificationCatalogException.requireSlug( + "notification renderer revision", rendererRevision); + checksum = + NotificationCatalogException.requireDigest("notification template checksum", checksum); + Objects.requireNonNull(supportedLocales, "supported template locales must be non-null"); + supportedLocales = + supportedLocales.stream() + .map(locale -> requireLocale("supported template locale", locale)) + .sorted(Comparator.comparing(Locale::toLanguageTag)) + .collect(Collectors.toUnmodifiableSet()); + if (supportedLocales.isEmpty() || supportedLocales.size() > 32) { + throw new IllegalArgumentException("supported template locales must contain 1..32 entries"); + } + fallbackLocale = requireLocale("template fallback locale", fallbackLocale); + if (!supportedLocales.contains(fallbackLocale)) { + throw new IllegalArgumentException("template fallback locale must be supported"); + } + Objects.requireNonNull(parameterNames, "template parameter names must be non-null"); + parameterNames = + parameterNames.stream() + .map(NotificationTemplateDescriptor::requireParameterName) + .collect(Collectors.toUnmodifiableSet()); + if (parameterNames.size() > 32) { + throw new IllegalArgumentException("template parameter names exceed 32 entries"); + } + if (new HashSet<>(parameterNames).size() != parameterNames.size()) { + throw new IllegalArgumentException("template parameter names contain duplicates"); + } + if (maximumRenderedBytes < 1 || maximumRenderedBytes > 10_000_000) { + throw new IllegalArgumentException("maximum rendered bytes must be in 1..10000000"); + } + } + + Locale selectLocale(Locale requested) { + Locale normalized = requireLocale("requested template locale", requested); + return supportedLocales.contains(normalized) ? normalized : fallbackLocale; + } + + private static Locale requireLocale(String field, Locale locale) { + Objects.requireNonNull(locale, field + " must be non-null"); + String tag = locale.toLanguageTag(); + if (locale.equals(Locale.ROOT) || tag.equals("und") || tag.isBlank() || tag.length() > 35) { + throw new IllegalArgumentException(field + " must be an explicit bounded locale"); + } + return Locale.forLanguageTag(tag); + } + + private static String requireParameterName(String name) { + if (name == null || !name.matches("[a-z][A-Za-z0-9]{0,63}")) { + throw new IllegalArgumentException( + "template parameter name must match [a-z][A-Za-z0-9]{0,63}"); + } + return name; + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/provider/AttemptCorrelationId.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/provider/AttemptCorrelationId.java new file mode 100644 index 00000000..3a1929a0 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/provider/AttemptCorrelationId.java @@ -0,0 +1,34 @@ +package dev.caskeleton.adapter.outbound.notification.provider; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; + +/** Opaque non-PII correlation identity created before a provider send. */ +public record AttemptCorrelationId(String value) + implements NotificationProviderAttemptClient.ReconciliationReference { + + public AttemptCorrelationId { + value = requireOpaque("attempt correlation ID", value); + } + + static AttemptCorrelationId derive(String seed) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return new AttemptCorrelationId( + "ca-" + HexFormat.of().formatHex(digest.digest(seed.getBytes(StandardCharsets.UTF_8)))); + } catch (NoSuchAlgorithmException unavailable) { + throw new IllegalStateException( + "SHA-256 must be available on every Java runtime", unavailable); + } + } + + static String requireOpaque(String field, String value) { + if (value == null || !value.matches("[A-Za-z0-9][A-Za-z0-9._:-]{0,127}")) { + throw new IllegalArgumentException( + field + " must contain 1..128 opaque identifier characters"); + } + return value; + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/provider/InlineNotificationAttemptAdapter.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/provider/InlineNotificationAttemptAdapter.java new file mode 100644 index 00000000..8426e145 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/provider/InlineNotificationAttemptAdapter.java @@ -0,0 +1,54 @@ +package dev.caskeleton.adapter.outbound.notification.provider; + +import dev.caskeleton.application.notification.InlineNotificationAttemptPort; +import dev.caskeleton.application.notification.NotificationAttemptId; +import dev.caskeleton.application.notification.NotificationDeliveryId; +import dev.caskeleton.application.notification.NotificationFrozenPlan; +import dev.caskeleton.application.notification.NotificationMode; +import dev.caskeleton.application.notification.NotificationRequestResult; +import dev.caskeleton.application.notification.TargetAttemptOutcome; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +/** Bounded inline port using the same internal one-authorized-attempt protocol. */ +public final class InlineNotificationAttemptAdapter implements InlineNotificationAttemptPort { + + private final NotificationProviderAttemptAdapter attempts; + + public InlineNotificationAttemptAdapter(NotificationProviderAttemptAdapter attempts) { + this.attempts = Objects.requireNonNull(attempts, "provider attempt adapter must be non-null"); + } + + @Override + public NotificationRequestResult.InlineCompleted attempt(NotificationFrozenPlan plan) { + Objects.requireNonNull(plan, "notification frozen plan must be non-null"); + if (plan.mode() != NotificationMode.BEST_EFFORT_INLINE) { + throw new IllegalArgumentException("inline attempt requires BEST_EFFORT_INLINE plan"); + } + List outcomes = new ArrayList<>(); + for (int ordinal = 0; ordinal < plan.binding().targets().size(); ordinal++) { + String suffix = + AttemptCorrelationId.derive(plan.intentId().value() + ":" + ordinal) + .value() + .substring(3, 35); + NotificationDeliveryId deliveryId = new NotificationDeliveryId("inline-delivery-" + suffix); + NotificationAttemptId attemptId = new NotificationAttemptId("inline-attempt-" + suffix); + String executionToken = "inline-execution-" + suffix; + Instant deadline = + minimum(plan.expiresAt(), attempts.now().plus(plan.binding().perAttemptDeadline())); + outcomes.add( + new TargetAttemptOutcome( + ordinal, + deliveryId, + attempts.attemptInline( + plan, ordinal, deliveryId, attemptId, executionToken, deadline))); + } + return new NotificationRequestResult.InlineCompleted(plan.intentId(), outcomes); + } + + private static Instant minimum(Instant left, Instant right) { + return left.isBefore(right) ? left : right; + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/provider/NotificationAdmissionReadinessAdapter.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/provider/NotificationAdmissionReadinessAdapter.java new file mode 100644 index 00000000..df65ef07 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/provider/NotificationAdmissionReadinessAdapter.java @@ -0,0 +1,163 @@ +package dev.caskeleton.adapter.outbound.notification.provider; + +import dev.caskeleton.adapter.outbound.notification.catalog.NotificationProviderRuntimeProfile; +import dev.caskeleton.application.notification.NotificationAdmissionReadinessPort; +import dev.caskeleton.application.notification.NotificationReasonCode; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Function; + +/** + * Bridges application admission operations to exact adapter-internal provider readiness evidence. + */ +public final class NotificationAdmissionReadinessAdapter + implements NotificationAdmissionReadinessPort { + + private static final int MAXIMUM_PENDING_PROBES = 1_024; + private static final Duration PROBE_DEADLINE = Duration.ofSeconds(30); + private static final NotificationReasonCode PROBE_FAILED = + new NotificationReasonCode("READINESS_PROBE_FAILED"); + private static final NotificationReasonCode TOKEN_CONFLICT = + new NotificationReasonCode("READINESS_TOKEN_CONFLICT"); + private static final NotificationReasonCode CAPACITY_EXCEEDED = + new NotificationReasonCode("READINESS_CAPACITY_EXCEEDED"); + + private final NotificationAdmissionReadinessPort state; + private final Function profiles; + private final NotificationProviderReadinessProbe readiness; + private final Clock clock; + private final ConcurrentHashMap fresh = new ConcurrentHashMap<>(); + + public NotificationAdmissionReadinessAdapter( + NotificationAdmissionReadinessPort state, + Function profiles, + NotificationProviderReadinessProbe readiness, + Clock clock) { + this.state = Objects.requireNonNull(state, "admission state port must be non-null"); + this.profiles = Objects.requireNonNull(profiles, "runtime profile resolver must be non-null"); + this.readiness = Objects.requireNonNull(readiness, "provider readiness probe must be non-null"); + this.clock = Objects.requireNonNull(clock, "provider readiness clock must be non-null"); + } + + @Override + public ParkResult park(ParkRequest request) { + return Objects.requireNonNull(state.park(request), "admission park result must be non-null"); + } + + @Override + public ReadinessProbe probe(ResumeRequest request) { + Objects.requireNonNull(request, "admission resume request must be non-null"); + Instant probeStartedAt = clock.instant(); + evictExpired(probeStartedAt); + try { + NotificationProviderRuntimeProfile profile = + Objects.requireNonNull(profiles.apply(request), "runtime profile must be non-null"); + CapturedProbe existing = fresh.get(request.operationToken()); + if (existing != null) { + if (!existing.request().equals(request) || !existing.profile().equals(profile)) { + return new ReadinessProbe(false, TOKEN_CONFLICT); + } + return new ReadinessProbe(existing.snapshot().ready(), existing.snapshot().reasonCode()); + } + NotificationProviderReadinessSnapshot snapshot = + Objects.requireNonNull( + readiness.probe(profile, probeStartedAt.plus(PROBE_DEADLINE)), + "provider readiness snapshot must be non-null"); + Instant validatedAt = clock.instant(); + validateExact(profile, snapshot, validatedAt); + if (snapshot.ready()) { + if (fresh.size() >= MAXIMUM_PENDING_PROBES) { + return new ReadinessProbe(false, CAPACITY_EXCEEDED); + } + CapturedProbe captured = new CapturedProbe(request, profile, snapshot); + CapturedProbe collision = fresh.putIfAbsent(request.operationToken(), captured); + if (collision != null && !collision.equals(captured)) { + return new ReadinessProbe(false, TOKEN_CONFLICT); + } + } + return new ReadinessProbe(snapshot.ready(), snapshot.reasonCode()); + } catch (RuntimeException probeFailure) { + return new ReadinessProbe(false, PROBE_FAILED); + } + } + + @Override + public ResumeResult resume(ResumeRequest request, ReadinessProbe probe, Instant resumedAt) { + Objects.requireNonNull(request, "admission resume request must be non-null"); + Objects.requireNonNull(probe, "application readiness probe must be non-null"); + Objects.requireNonNull(resumedAt, "admission resume time must be non-null"); + CapturedProbe captured = fresh.remove(request.operationToken()); + if (captured == null + || !captured.request().equals(request) + || !probe.ready() + || !captured.snapshot().reasonCode().equals(probe.reasonCode()) + || !captured.snapshot().expiresAt().isAfter(resumedAt)) { + throw new IllegalStateException( + "admission resume requires fresh exact provider readiness evidence"); + } + NotificationProviderRuntimeProfile currentProfile; + try { + currentProfile = + Objects.requireNonNull(profiles.apply(request), "runtime profile must be non-null"); + } catch (RuntimeException profileFailure) { + throw new IllegalStateException( + "admission resume requires fresh exact provider readiness evidence"); + } + if (!captured.profile().equals(currentProfile)) { + throw new IllegalStateException( + "admission resume requires the exact probed provider generation"); + } + Instant freshAt = clock.instant(); + if (resumedAt.isAfter(freshAt) || !captured.snapshot().expiresAt().isAfter(freshAt)) { + throw new IllegalStateException( + "admission resume requires currently fresh provider readiness evidence"); + } + return Objects.requireNonNull( + state.resume(request, probe, freshAt), "admission resume result must be non-null"); + } + + @Override + public String toString() { + return "NotificationAdmissionReadinessAdapter[state=, profiles=, " + + "readiness=, pendingProbeCount=" + + fresh.size() + + "]"; + } + + private static void validateExact( + NotificationProviderRuntimeProfile profile, + NotificationProviderReadinessSnapshot snapshot, + Instant now) { + if (!snapshot.profileId().equals(profile.profileId()) + || !snapshot.bindingRevision().equals(profile.bindingRevision()) + || !snapshot.capabilityCardId().equals(profile.capabilityCardId()) + || !snapshot.credentialGeneration().equals(profile.credentialGeneration())) { + throw new IllegalStateException( + "provider readiness snapshot does not match the exact runtime profile"); + } + if (snapshot.observedAt().isAfter(now) + || !snapshot.expiresAt().isAfter(now) + || snapshot.observedAt().isAfter(snapshot.expiresAt())) { + throw new IllegalStateException("provider readiness snapshot is not currently valid"); + } + } + + private void evictExpired(Instant now) { + fresh.entrySet().removeIf(entry -> !entry.getValue().snapshot().expiresAt().isAfter(now)); + } + + private record CapturedProbe( + ResumeRequest request, + NotificationProviderRuntimeProfile profile, + NotificationProviderReadinessSnapshot snapshot) { + + private CapturedProbe { + Objects.requireNonNull(request, "captured resume request must be non-null"); + Objects.requireNonNull(profile, "captured runtime profile must be non-null"); + Objects.requireNonNull(snapshot, "captured readiness snapshot must be non-null"); + } + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/provider/NotificationAttemptContext.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/provider/NotificationAttemptContext.java new file mode 100644 index 00000000..28f03e15 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/provider/NotificationAttemptContext.java @@ -0,0 +1,54 @@ +package dev.caskeleton.adapter.outbound.notification.provider; + +import dev.caskeleton.application.notification.NotificationAttemptId; +import dev.caskeleton.application.notification.NotificationDeliveryId; +import java.time.Instant; +import java.util.Objects; +import java.util.Optional; + +/** Immutable one-attempt identities and absolute deadline passed to provider preparation. */ +public record NotificationAttemptContext( + NotificationDeliveryId deliveryId, + NotificationAttemptId attemptId, + int targetOrdinal, + AttemptCorrelationId correlationId, + Optional clientOperationKey, + Instant absoluteDeadline) { + + public NotificationAttemptContext { + Objects.requireNonNull(deliveryId, "notification delivery ID must be non-null"); + Objects.requireNonNull(attemptId, "notification attempt ID must be non-null"); + if (targetOrdinal < 0 || targetOrdinal > 15) { + throw new IllegalArgumentException("notification target ordinal must be in 0..15"); + } + Objects.requireNonNull(correlationId, "attempt correlation ID must be non-null"); + Objects.requireNonNull( + clientOperationKey, "provider client operation key container must be non-null"); + Objects.requireNonNull(absoluteDeadline, "attempt absolute deadline must be non-null"); + } + + @Override + public String toString() { + return "NotificationAttemptContext[deliveryId=, attemptId=" + + attemptId + + ", targetOrdinal=" + + targetOrdinal + + ", correlationId=, clientOperationKey=, absoluteDeadline=" + + absoluteDeadline + + "]"; + } + + /** Native provider operation key, distinct from local correlation and provider message IDs. */ + public record ProviderClientOperationKey(String value) + implements NotificationProviderAttemptClient.ReconciliationReference { + + public ProviderClientOperationKey { + value = AttemptCorrelationId.requireOpaque("provider client operation key", value); + } + + @Override + public String toString() { + return "ProviderClientOperationKey[value=]"; + } + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/provider/NotificationProviderAttemptAdapter.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/provider/NotificationProviderAttemptAdapter.java new file mode 100644 index 00000000..83b67ce2 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/provider/NotificationProviderAttemptAdapter.java @@ -0,0 +1,363 @@ +package dev.caskeleton.adapter.outbound.notification.provider; + +import dev.caskeleton.adapter.outbound.notification.catalog.CompiledNotificationBinding; +import dev.caskeleton.adapter.outbound.notification.catalog.NotificationBindingCompiler; +import dev.caskeleton.adapter.outbound.notification.template.NotificationTemplateRenderer; +import dev.caskeleton.adapter.outbound.notification.template.RenderedNotification; +import dev.caskeleton.application.notification.NotificationAttemptId; +import dev.caskeleton.application.notification.NotificationDeliveryId; +import dev.caskeleton.application.notification.NotificationDeliveryStorePort; +import dev.caskeleton.application.notification.NotificationFaultScope; +import dev.caskeleton.application.notification.NotificationFrozenPlan; +import dev.caskeleton.application.notification.NotificationProviderAttemptPort; +import dev.caskeleton.application.notification.NotificationReasonCode; +import dev.caskeleton.application.notification.ProviderAttemptOutcome; +import dev.caskeleton.application.notification.RetryDisposition; +import dev.caskeleton.application.notification.SubmissionCertainty; +import java.time.Clock; +import java.time.Instant; +import java.util.Comparator; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +/** Application provider port backed by one compiled binding, renderer and internal client call. */ +public final class NotificationProviderAttemptAdapter implements NotificationProviderAttemptPort { + + private final NotificationBindingCompiler.CompiledGraph graph; + private final Map> renderers; + private final Map clients; + private final NotificationProviderRateAdmission rateAdmission; + private final NotificationProviderSecretMaterialProvider secrets; + private final Clock clock; + + public NotificationProviderAttemptAdapter( + NotificationBindingCompiler.CompiledGraph graph, + Map> renderers, + Map clients, + NotificationProviderRateAdmission rateAdmission, + NotificationProviderSecretMaterialProvider secrets, + Clock clock) { + this.graph = Objects.requireNonNull(graph, "compiled notification graph must be non-null"); + this.renderers = Map.copyOf(Objects.requireNonNull(renderers, "renderers must be non-null")); + this.clients = Map.copyOf(Objects.requireNonNull(clients, "provider clients must be non-null")); + this.rateAdmission = + Objects.requireNonNull(rateAdmission, "provider rate admission must be non-null"); + this.secrets = + Objects.requireNonNull(secrets, "provider secret material provider must be non-null"); + this.clock = Objects.requireNonNull(clock, "provider attempt clock must be non-null"); + } + + @Override + public ProviderAttemptOutcome attempt(NotificationDeliveryStorePort.AuthorizedAttempt attempt) { + Objects.requireNonNull(attempt, "authorized notification attempt must be non-null"); + Instant deadline = + minimum( + attempt.absoluteDeadline(), + clock.instant().plus(attempt.plan().binding().perAttemptDeadline())); + return attemptOne( + attempt.plan(), + attempt.targetOrdinal(), + attempt.deliveryId(), + attempt.attemptId(), + attempt.executionToken(), + deadline); + } + + ProviderAttemptOutcome attemptInline( + NotificationFrozenPlan plan, + int targetOrdinal, + NotificationDeliveryId deliveryId, + NotificationAttemptId attemptId, + String executionToken, + Instant deadline) { + return attemptOne(plan, targetOrdinal, deliveryId, attemptId, executionToken, deadline); + } + + Instant now() { + return clock.instant(); + } + + private ProviderAttemptOutcome attemptOne( + NotificationFrozenPlan plan, + int targetOrdinal, + NotificationDeliveryId deliveryId, + NotificationAttemptId attemptId, + String executionToken, + Instant deadline) { + Objects.requireNonNull(plan, "notification frozen plan must be non-null"); + AttemptCorrelationId correlation = + AttemptCorrelationId.derive(executionToken + ":" + targetOrdinal); + if (!deadline.isAfter(clock.instant())) { + return definitelyNotApplied( + correlation, + RetryDisposition.TERMINAL, + NotificationFaultScope.DELIVERY, + "ATTEMPT_DEADLINE_EXPIRED", + Optional.empty()); + } + + CompiledNotificationBinding binding; + CompiledNotificationBinding.CompiledTarget target; + try { + binding = requireBinding(plan); + target = requireTarget(binding, plan, targetOrdinal); + } catch (RuntimeException retainedBindingFailure) { + return definitelyNotApplied( + correlation, + RetryDisposition.PARK_BINDING, + NotificationFaultScope.ROUTE_REVISION, + "FROZEN_BINDING_UNAVAILABLE", + Optional.empty()); + } + NotificationProviderRateAdmission.Decision admission; + try { + admission = + Objects.requireNonNull( + rateAdmission.admit(target.runtimeProfile(), deadline), + "provider rate admission decision must be non-null"); + } catch (RuntimeException admissionFailure) { + return definitelyNotApplied( + correlation, + RetryDisposition.PARK_BINDING, + NotificationFaultScope.PROVIDER_BINDING, + "PROVIDER_RATE_ADMISSION_FAILED", + Optional.empty()); + } + if (!admission.admitted()) { + Instant retryAt = admission.retryNotBefore().orElseThrow(); + if (!retryAt.isAfter(clock.instant()) || retryAt.isAfter(deadline)) { + return definitelyNotApplied( + correlation, + RetryDisposition.PARK_BINDING, + NotificationFaultScope.PROVIDER_BINDING, + "PROVIDER_RETRY_BOUND_INVALID", + Optional.empty()); + } + return definitelyNotApplied( + correlation, + RetryDisposition.RETRY_AT, + NotificationFaultScope.PROVIDER_BINDING, + admission.reasonCode().value(), + admission.retryNotBefore()); + } + + NotificationTemplateRenderer renderer = + renderers.get(binding.template().rendererRevision()); + NotificationProviderAttemptClient client = clients.get(target.capabilityCard().cardId()); + if (renderer == null || client == null) { + return definitelyNotApplied( + correlation, + RetryDisposition.PARK_BINDING, + NotificationFaultScope.ROUTE_REVISION, + "PROVIDER_BINDING_COMPONENT_UNAVAILABLE", + Optional.empty()); + } + + NotificationAttemptContext context = + new NotificationAttemptContext( + deliveryId, attemptId, targetOrdinal, correlation, Optional.empty(), deadline); + RenderedNotification rendered; + try { + rendered = + Objects.requireNonNull(renderer.render(plan), "rendered notification must be non-null"); + } catch (RuntimeException renderingFailure) { + return definitelyNotApplied( + correlation, + RetryDisposition.PARK_BINDING, + NotificationFaultScope.ROUTE_REVISION, + "TEMPLATE_RENDERING_FAILED", + Optional.empty()); + } + if (rendered.utf8Bytes() > binding.template().maximumRenderedBytes() + || rendered.utf8Bytes() > target.capabilityCard().maximumPayloadBytes()) { + return definitelyNotApplied( + correlation, + RetryDisposition.PARK_BINDING, + NotificationFaultScope.ROUTE_REVISION, + "RENDERED_PAYLOAD_BOUND_EXCEEDED", + Optional.empty()); + } + PreparedNotificationAttempt prepared; + try { + prepared = + Objects.requireNonNull( + client.prepare(rendered, target, context), + "prepared notification attempt must be non-null"); + if (!prepared.rendered().equals(rendered) + || !prepared.target().equals(target) + || !prepared.context().equals(context)) { + return definitelyNotApplied( + correlation, + RetryDisposition.PARK_BINDING, + NotificationFaultScope.PROVIDER_BINDING, + "PROVIDER_PREPARED_ATTEMPT_MISMATCH", + Optional.empty()); + } + } catch (NotificationProviderAttemptClient.PreWireDeliveryRejectedException rejected) { + return definitelyNotApplied( + correlation, + RetryDisposition.TERMINAL, + NotificationFaultScope.DELIVERY, + "PROVIDER_PREWIRE_VALIDATION_FAILED", + Optional.empty()); + } catch (RuntimeException bindingFailure) { + return definitelyNotApplied( + correlation, + RetryDisposition.PARK_BINDING, + NotificationFaultScope.PROVIDER_BINDING, + "PROVIDER_PREWIRE_BINDING_FAILED", + Optional.empty()); + } + + NotificationSecretMaterialHandle acquired; + try { + acquired = + Objects.requireNonNull( + secrets.acquire(target.runtimeProfile()), + "provider secret material handle must be non-null"); + } catch (RuntimeException secretFailure) { + return definitelyNotApplied( + correlation, + RetryDisposition.PARK_BINDING, + NotificationFaultScope.PROVIDER_BINDING, + "PROVIDER_SECRET_ACQUISITION_FAILED", + Optional.empty()); + } + try (NotificationSecretMaterialHandle secret = acquired) { + if (!secret.revision().equals(target.runtimeProfile().credentialGeneration())) { + return definitelyNotApplied( + correlation, + RetryDisposition.PARK_BINDING, + NotificationFaultScope.PROVIDER_BINDING, + "PROVIDER_SECRET_GENERATION_MISMATCH", + Optional.empty()); + } + Instant beforeWire = clock.instant(); + if (!deadline.isAfter(beforeWire)) { + return definitelyNotApplied( + correlation, + RetryDisposition.TERMINAL, + NotificationFaultScope.DELIVERY, + "ATTEMPT_DEADLINE_EXPIRED", + Optional.empty()); + } + try { + NotificationProviderAttemptClient.ClientAttemptResult result = + Objects.requireNonNull( + client.sendOneAuthorizedAttempt(prepared, executionToken, secret, deadline), + "provider client result must be non-null"); + return map(result, correlation, clock.instant(), deadline); + } catch (RuntimeException possibleWriteFailure) { + return new ProviderAttemptOutcome( + SubmissionCertainty.INDETERMINATE, + RetryDisposition.NOT_APPLICABLE, + NotificationFaultScope.DELIVERY, + new NotificationReasonCode("PROVIDER_RESPONSE_INDETERMINATE"), + Optional.empty(), + correlation.value(), + Optional.empty()); + } + } + } + + private CompiledNotificationBinding requireBinding(NotificationFrozenPlan plan) { + return graph.bindings().stream() + .filter(binding -> binding.bindingDigest().equals(plan.binding().bindingDigest())) + .filter(binding -> binding.route().routeId().equals(plan.routeId())) + .filter(binding -> binding.route().routeRevision() == plan.binding().routeRevision()) + .findFirst() + .orElseThrow( + () -> + new IllegalArgumentException( + "frozen plan is outside the compiled notification binding graph")); + } + + private static CompiledNotificationBinding.CompiledTarget requireTarget( + CompiledNotificationBinding binding, NotificationFrozenPlan plan, int ordinal) { + java.util.List ordered = + binding.targets().stream() + .sorted(Comparator.comparing(target -> target.target().targetId())) + .toList(); + if (ordinal < 0 || ordinal >= ordered.size()) { + throw new IllegalArgumentException("notification target ordinal is outside binding"); + } + CompiledNotificationBinding.CompiledTarget target = ordered.get(ordinal); + NotificationFrozenPlan.FrozenTarget frozen = plan.binding().targets().get(ordinal); + if (!frozen.targetReference().equals(target.target().targetId()) + || !frozen.providerCapabilityReference().equals(target.capabilityCard().cardId()) + || !frozen.providerBindingRevision().equals(target.runtimeProfile().bindingRevision()) + || !frozen.credentialGeneration().equals(target.runtimeProfile().credentialGeneration())) { + throw new IllegalArgumentException( + "frozen notification target does not match compiled binding"); + } + return target; + } + + private static ProviderAttemptOutcome map( + NotificationProviderAttemptClient.ClientAttemptResult result, + AttemptCorrelationId correlation, + Instant observedAt, + Instant deadline) { + return switch (result) { + case NotificationProviderAttemptClient.ClientAttemptResult.Accepted accepted -> + new ProviderAttemptOutcome( + SubmissionCertainty.PROVIDER_ACCEPTED, + RetryDisposition.NOT_APPLICABLE, + NotificationFaultScope.DELIVERY, + new NotificationReasonCode("PROVIDER_ACCEPTED"), + Optional.empty(), + correlation.value(), + Optional.of(accepted.providerMessageReference().value())); + case NotificationProviderAttemptClient.ClientAttemptResult.DefinitelyNotApplied rejected -> { + if (rejected.retryDisposition() == RetryDisposition.RETRY_AT + && (rejected.retryNotBefore().orElseThrow().isAfter(deadline) + || !rejected.retryNotBefore().orElseThrow().isAfter(observedAt))) { + yield definitelyNotApplied( + correlation, + RetryDisposition.PARK_BINDING, + NotificationFaultScope.PROVIDER_BINDING, + "PROVIDER_RETRY_BOUND_INVALID", + Optional.empty()); + } + yield new ProviderAttemptOutcome( + SubmissionCertainty.DEFINITELY_NOT_APPLIED, + rejected.retryDisposition(), + rejected.faultScope(), + rejected.reasonCode(), + rejected.retryNotBefore(), + correlation.value(), + Optional.empty()); + } + case NotificationProviderAttemptClient.ClientAttemptResult.Indeterminate indeterminate -> + new ProviderAttemptOutcome( + SubmissionCertainty.INDETERMINATE, + RetryDisposition.NOT_APPLICABLE, + NotificationFaultScope.DELIVERY, + indeterminate.reasonCode(), + Optional.empty(), + correlation.value(), + Optional.empty()); + }; + } + + private static ProviderAttemptOutcome definitelyNotApplied( + AttemptCorrelationId correlation, + RetryDisposition retryDisposition, + NotificationFaultScope scope, + String reasonCode, + Optional retryNotBefore) { + return new ProviderAttemptOutcome( + SubmissionCertainty.DEFINITELY_NOT_APPLIED, + retryDisposition, + scope, + new NotificationReasonCode(reasonCode), + retryNotBefore, + correlation.value(), + Optional.empty()); + } + + private static Instant minimum(Instant left, Instant right) { + return left.isBefore(right) ? left : right; + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/provider/NotificationProviderAttemptClient.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/provider/NotificationProviderAttemptClient.java new file mode 100644 index 00000000..75e1fb21 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/provider/NotificationProviderAttemptClient.java @@ -0,0 +1,103 @@ +package dev.caskeleton.adapter.outbound.notification.provider; + +import dev.caskeleton.adapter.outbound.notification.catalog.CompiledNotificationBinding; +import dev.caskeleton.adapter.outbound.notification.template.RenderedNotification; +import dev.caskeleton.application.notification.NotificationFaultScope; +import dev.caskeleton.application.notification.NotificationReasonCode; +import dev.caskeleton.application.notification.RetryDisposition; +import dev.caskeleton.application.notification.SubmissionCertainty; +import java.time.Instant; +import java.util.Objects; +import java.util.Optional; + +/** Adapter-internal provider protocol: pure prepare, then one authorized wire attempt. */ +public interface NotificationProviderAttemptClient { + + PreparedNotificationAttempt prepare( + RenderedNotification rendered, + CompiledNotificationBinding.CompiledTarget target, + NotificationAttemptContext context); + + ClientAttemptResult sendOneAuthorizedAttempt( + PreparedNotificationAttempt prepared, + String executionToken, + NotificationSecretMaterialHandle secret, + Instant absoluteDeadline); + + default ClientReconciliationResult reconcile( + ReconciliationReference reference, + ReconciliationLookupMode lookupMode, + NotificationSecretMaterialHandle secret, + Instant absoluteDeadline) { + return new ClientReconciliationResult( + SubmissionCertainty.INDETERMINATE, + new NotificationReasonCode("RECONCILIATION_UNSUPPORTED")); + } + + sealed interface ReconciliationReference + permits AttemptCorrelationId, + ProviderMessageReference, + NotificationAttemptContext.ProviderClientOperationKey { + + String value(); + } + + sealed interface ClientAttemptResult + permits ClientAttemptResult.Accepted, + ClientAttemptResult.DefinitelyNotApplied, + ClientAttemptResult.Indeterminate { + + record Accepted(ProviderMessageReference providerMessageReference) + implements ClientAttemptResult { + + public Accepted { + Objects.requireNonNull( + providerMessageReference, "provider message reference must be non-null"); + } + } + + record DefinitelyNotApplied( + RetryDisposition retryDisposition, + NotificationFaultScope faultScope, + NotificationReasonCode reasonCode, + Optional retryNotBefore) + implements ClientAttemptResult { + + public DefinitelyNotApplied { + Objects.requireNonNull(retryDisposition, "retry disposition must be non-null"); + Objects.requireNonNull(faultScope, "notification fault scope must be non-null"); + Objects.requireNonNull(reasonCode, "notification reason code must be non-null"); + Objects.requireNonNull(retryNotBefore, "retry-not-before container must be non-null"); + if (retryDisposition == RetryDisposition.NOT_APPLICABLE + || (retryDisposition == RetryDisposition.RETRY_AT) != retryNotBefore.isPresent()) { + throw new IllegalArgumentException( + "definite non-application requires an explicit consistent disposition"); + } + } + } + + record Indeterminate(NotificationReasonCode reasonCode) implements ClientAttemptResult { + + public Indeterminate { + Objects.requireNonNull(reasonCode, "notification reason code must be non-null"); + } + } + } + + record ClientReconciliationResult( + SubmissionCertainty submissionCertainty, NotificationReasonCode reasonCode) { + + public ClientReconciliationResult { + Objects.requireNonNull(submissionCertainty, "submission certainty must be non-null"); + Objects.requireNonNull(reasonCode, "notification reason code must be non-null"); + } + } + + /** Explicit provider-proven invalid recipient/content detected before wire I/O. */ + final class PreWireDeliveryRejectedException extends RuntimeException { + + public PreWireDeliveryRejectedException() { + super("provider rejected the prepared delivery before wire I/O"); + } + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/provider/NotificationProviderRateAdmission.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/provider/NotificationProviderRateAdmission.java new file mode 100644 index 00000000..49fbf9f8 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/provider/NotificationProviderRateAdmission.java @@ -0,0 +1,35 @@ +package dev.caskeleton.adapter.outbound.notification.provider; + +import dev.caskeleton.adapter.outbound.notification.catalog.NotificationProviderRuntimeProfile; +import dev.caskeleton.application.notification.NotificationReasonCode; +import java.time.Instant; +import java.util.Objects; +import java.util.Optional; + +/** Provider-local bounded quota/rate admission checked before a wire attempt. */ +@FunctionalInterface +public interface NotificationProviderRateAdmission { + + Decision admit(NotificationProviderRuntimeProfile profile, Instant absoluteDeadline); + + record Decision( + boolean admitted, Optional retryNotBefore, NotificationReasonCode reasonCode) { + + public Decision { + Objects.requireNonNull(retryNotBefore, "retry-not-before container must be non-null"); + Objects.requireNonNull(reasonCode, "rate admission reason code must be non-null"); + if (admitted == retryNotBefore.isPresent()) { + throw new IllegalArgumentException( + "admitted rate decision must not have retry time and rejection must have one"); + } + } + + public static Decision admitted(NotificationReasonCode reasonCode) { + return new Decision(true, Optional.empty(), reasonCode); + } + + public static Decision retryAt(Instant retryAt, NotificationReasonCode reasonCode) { + return new Decision(false, Optional.of(retryAt), reasonCode); + } + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/provider/NotificationProviderReadinessProbe.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/provider/NotificationProviderReadinessProbe.java new file mode 100644 index 00000000..90f5abc4 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/provider/NotificationProviderReadinessProbe.java @@ -0,0 +1,12 @@ +package dev.caskeleton.adapter.outbound.notification.provider; + +import dev.caskeleton.adapter.outbound.notification.catalog.NotificationProviderRuntimeProfile; +import java.time.Instant; + +/** Bounded control-plane probe that returns non-secret provider identity and capability facts. */ +@FunctionalInterface +public interface NotificationProviderReadinessProbe { + + NotificationProviderReadinessSnapshot probe( + NotificationProviderRuntimeProfile profile, Instant absoluteDeadline); +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/provider/NotificationProviderReadinessSnapshot.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/provider/NotificationProviderReadinessSnapshot.java new file mode 100644 index 00000000..3c4eee63 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/provider/NotificationProviderReadinessSnapshot.java @@ -0,0 +1,42 @@ +package dev.caskeleton.adapter.outbound.notification.provider; + +import dev.caskeleton.application.notification.NotificationReasonCode; +import java.time.Duration; +import java.time.Instant; +import java.util.Objects; + +/** Bounded, non-secret, exact provider readiness observation. */ +public record NotificationProviderReadinessSnapshot( + String profileId, + String bindingRevision, + String capabilityCardId, + String credentialGeneration, + boolean ready, + NotificationReasonCode reasonCode, + Instant observedAt, + Instant expiresAt) { + + public NotificationProviderReadinessSnapshot { + profileId = requireSlug("provider profile ID", profileId); + bindingRevision = requireSlug("provider binding revision", bindingRevision); + capabilityCardId = requireSlug("provider capability card ID", capabilityCardId); + credentialGeneration = requireSlug("provider credential generation", credentialGeneration); + Objects.requireNonNull(reasonCode, "provider readiness reason code must be non-null"); + Objects.requireNonNull(observedAt, "provider readiness observation time must be non-null"); + Objects.requireNonNull(expiresAt, "provider readiness expiry time must be non-null"); + Duration validity = Duration.between(observedAt, expiresAt); + if (validity.isZero() + || validity.isNegative() + || validity.compareTo(Duration.ofMinutes(5)) > 0) { + throw new IllegalArgumentException( + "provider readiness validity must be positive and at most five minutes"); + } + } + + private static String requireSlug(String field, String value) { + if (value == null || !value.matches("[a-z][a-z0-9.-]{0,62}")) { + throw new IllegalArgumentException(field + " must match [a-z][a-z0-9.-]{0,62}"); + } + return value; + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/provider/NotificationProviderSecretMaterialProvider.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/provider/NotificationProviderSecretMaterialProvider.java new file mode 100644 index 00000000..effdc813 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/provider/NotificationProviderSecretMaterialProvider.java @@ -0,0 +1,10 @@ +package dev.caskeleton.adapter.outbound.notification.provider; + +import dev.caskeleton.adapter.outbound.notification.catalog.NotificationProviderRuntimeProfile; + +/** Acquires one operation-scoped mutable secret copy for a runtime profile. */ +@FunctionalInterface +public interface NotificationProviderSecretMaterialProvider { + + NotificationSecretMaterialHandle acquire(NotificationProviderRuntimeProfile profile); +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/provider/NotificationReconciliationAdapter.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/provider/NotificationReconciliationAdapter.java new file mode 100644 index 00000000..a869b593 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/provider/NotificationReconciliationAdapter.java @@ -0,0 +1,151 @@ +package dev.caskeleton.adapter.outbound.notification.provider; + +import dev.caskeleton.adapter.outbound.notification.catalog.CompiledNotificationBinding; +import dev.caskeleton.adapter.outbound.notification.catalog.NotificationProviderRuntimeProfile; +import dev.caskeleton.application.notification.NotificationDeliveryStorePort; +import dev.caskeleton.application.notification.NotificationReasonCode; +import dev.caskeleton.application.notification.NotificationReconciliationPort; +import dev.caskeleton.application.notification.SubmissionCertainty; +import java.time.Clock; +import java.util.Objects; +import java.util.function.Function; + +/** Maps retained lookup identities to bounded provider reconciliation calls. */ +public final class NotificationReconciliationAdapter implements NotificationReconciliationPort { + + private final Function + bindings; + private final NotificationProviderSecretMaterialProvider secrets; + private final Clock clock; + + public NotificationReconciliationAdapter( + Function bindings, + NotificationProviderSecretMaterialProvider secrets, + Clock clock) { + this.bindings = + Objects.requireNonNull(bindings, "reconciliation binding resolver must be non-null"); + this.secrets = + Objects.requireNonNull(secrets, "reconciliation secret provider must be non-null"); + this.clock = Objects.requireNonNull(clock, "reconciliation clock must be non-null"); + } + + @Override + public ReconciliationOutcome reconcile(NotificationDeliveryStorePort.ReconciliationClaim claim) { + Objects.requireNonNull(claim, "notification reconciliation claim must be non-null"); + if (!claim.absoluteDeadline().isAfter(clock.instant())) { + return indeterminate("RECONCILIATION_DEADLINE_EXPIRED"); + } + try { + ReconciliationBinding binding = + Objects.requireNonNull(bindings.apply(claim), "reconciliation binding must be non-null"); + if (!binding.matches(claim)) { + return indeterminate("RECONCILIATION_BINDING_MISMATCH"); + } + if (!supportsLookup(binding.target(), claim.lookupKind())) { + return indeterminate("RECONCILIATION_UNSUPPORTED"); + } + NotificationProviderAttemptClient.ReconciliationReference reference = reference(claim); + ReconciliationLookupMode mode = mode(claim.lookupKind()); + NotificationProviderRuntimeProfile profile = binding.target().runtimeProfile(); + try (NotificationSecretMaterialHandle secret = secrets.acquire(profile)) { + if (!secret.revision().equals(profile.credentialGeneration())) { + return indeterminate("RECONCILIATION_SECRET_GENERATION_MISMATCH"); + } + if (!claim.absoluteDeadline().isAfter(clock.instant())) { + return indeterminate("RECONCILIATION_DEADLINE_EXPIRED"); + } + NotificationProviderAttemptClient.ClientReconciliationResult result = + Objects.requireNonNull( + binding.client().reconcile(reference, mode, secret, claim.absoluteDeadline()), + "provider reconciliation result must be non-null"); + if (result.submissionCertainty() == SubmissionCertainty.DEFINITELY_NOT_APPLIED) { + return indeterminate("RECONCILIATION_NONAPPLICATION_UNPROVEN"); + } + return new ReconciliationOutcome(result.submissionCertainty(), result.reasonCode()); + } + } catch (RuntimeException providerFailure) { + return indeterminate("RECONCILIATION_INDETERMINATE"); + } + } + + private static NotificationProviderAttemptClient.ReconciliationReference reference( + NotificationDeliveryStorePort.ReconciliationClaim claim) { + return switch (claim.lookupKind()) { + case PRE_SEND_CORRELATION -> new AttemptCorrelationId(claim.lookupReference()); + case CLIENT_OPERATION_KEY -> + new NotificationAttemptContext.ProviderClientOperationKey(claim.lookupReference()); + case MESSAGE_REFERENCE -> new ProviderMessageReference(claim.lookupReference()); + }; + } + + private static ReconciliationLookupMode mode( + NotificationDeliveryStorePort.ReconciliationLookupKind kind) { + return ReconciliationLookupMode.valueOf(kind.name()); + } + + private static boolean supportsLookup( + CompiledNotificationBinding.CompiledTarget target, + NotificationDeliveryStorePort.ReconciliationLookupKind lookupKind) { + return target.capabilityCard().reconciliationSupported() + && target.capabilityCard().maximumReconcileCalls() > 0 + && target.capabilityCard().cardId().equals("aws-ses-v2-durable-single-local-sns-v1") + && lookupKind + == NotificationDeliveryStorePort.ReconciliationLookupKind.PRE_SEND_CORRELATION; + } + + private static ReconciliationOutcome indeterminate(String reason) { + return new ReconciliationOutcome( + SubmissionCertainty.INDETERMINATE, new NotificationReasonCode(reason)); + } + + public static final class ReconciliationBinding { + + private final CompiledNotificationBinding binding; + private final int targetOrdinal; + private final NotificationProviderAttemptClient client; + private final CompiledNotificationBinding.CompiledTarget target; + + public ReconciliationBinding( + CompiledNotificationBinding binding, + int targetOrdinal, + NotificationProviderAttemptClient client) { + this.binding = + Objects.requireNonNull(binding, "reconciliation compiled binding must be non-null"); + if (targetOrdinal < 0 || targetOrdinal >= binding.targets().size()) { + throw new IllegalArgumentException( + "reconciliation target ordinal is outside the compiled binding"); + } + this.targetOrdinal = targetOrdinal; + this.client = Objects.requireNonNull(client, "reconciliation client must be non-null"); + this.target = binding.targets().get(targetOrdinal); + } + + public NotificationProviderAttemptClient client() { + return client; + } + + public CompiledNotificationBinding.CompiledTarget target() { + return target; + } + + private boolean matches(NotificationDeliveryStorePort.ReconciliationClaim claim) { + return binding.route().routeId().equals(claim.routeId()) + && binding.route().routeRevision() == claim.routeRevision() + && binding.bindingDigest().equals(claim.bindingDigest()) + && targetOrdinal == claim.targetOrdinal() + && target.target().targetId().equals(claim.targetReference()) + && target.capabilityCard().cardId().equals(claim.providerCapabilityReference()) + && target.runtimeProfile().bindingRevision().equals(claim.providerBindingRevision()) + && target.runtimeProfile().credentialGeneration().equals(claim.credentialGeneration()); + } + + @Override + public String toString() { + return "ReconciliationBinding[bindingDigest=" + + binding.bindingDigest() + + ", targetOrdinal=" + + targetOrdinal + + ", client=, target=]"; + } + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/provider/NotificationSecretMaterialHandle.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/provider/NotificationSecretMaterialHandle.java new file mode 100644 index 00000000..3b4d8e83 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/provider/NotificationSecretMaterialHandle.java @@ -0,0 +1,93 @@ +package dev.caskeleton.adapter.outbound.notification.provider; + +import java.util.Arrays; +import java.util.Objects; +import java.util.function.Function; + +/** Versioned operation-scoped mutable secret copy that wipes itself on close. */ +public final class NotificationSecretMaterialHandle implements AutoCloseable { + + private final String revision; + private byte[] bytes; + private char[] characters; + private boolean closed; + + private NotificationSecretMaterialHandle(String revision, byte[] bytes, char[] characters) { + this.revision = requireSlug(revision); + this.bytes = bytes; + this.characters = characters; + } + + public static NotificationSecretMaterialHandle fromBytes(String revision, byte[] value) { + Objects.requireNonNull(value, "secret byte material must be non-null"); + if (value.length < 1 || value.length > 65_536) { + throw new IllegalArgumentException("secret byte material must contain 1..65536 bytes"); + } + return new NotificationSecretMaterialHandle(revision, value.clone(), null); + } + + public static NotificationSecretMaterialHandle fromCharacters(String revision, char[] value) { + Objects.requireNonNull(value, "secret character material must be non-null"); + if (value.length < 1 || value.length > 65_536) { + throw new IllegalArgumentException("secret character material must contain 1..65536 values"); + } + return new NotificationSecretMaterialHandle(revision, null, value.clone()); + } + + public String revision() { + return revision; + } + + public synchronized T readBytes(Function reader) { + Objects.requireNonNull(reader, "secret byte reader must be non-null"); + requireOpen(); + if (bytes == null) { + throw new IllegalStateException("secret handle does not contain byte material"); + } + return reader.apply(bytes); + } + + public synchronized T readCharacters(Function reader) { + Objects.requireNonNull(reader, "secret character reader must be non-null"); + requireOpen(); + if (characters == null) { + throw new IllegalStateException("secret handle does not contain character material"); + } + return reader.apply(characters); + } + + @Override + public synchronized void close() { + if (!closed) { + if (bytes != null) { + Arrays.fill(bytes, (byte) 0); + } + if (characters != null) { + Arrays.fill(characters, '\0'); + } + closed = true; + } + } + + @Override + public String toString() { + return "NotificationSecretMaterialHandle[revision=" + + revision + + ", material=, closed=" + + closed + + "]"; + } + + private void requireOpen() { + if (closed) { + throw new IllegalStateException("secret material handle is closed"); + } + } + + private static String requireSlug(String value) { + if (value == null || !value.matches("[a-z][a-z0-9.-]{0,62}")) { + throw new IllegalArgumentException("secret revision must match [a-z][a-z0-9.-]{0,62}"); + } + return value; + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/provider/PreparedNotificationAttempt.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/provider/PreparedNotificationAttempt.java new file mode 100644 index 00000000..157d1b26 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/provider/PreparedNotificationAttempt.java @@ -0,0 +1,31 @@ +package dev.caskeleton.adapter.outbound.notification.provider; + +import dev.caskeleton.adapter.outbound.notification.catalog.CompiledNotificationBinding; +import dev.caskeleton.adapter.outbound.notification.template.RenderedNotification; +import java.util.Objects; + +/** Side-effect-free provider preparation result for exactly one compiled target. */ +public record PreparedNotificationAttempt( + RenderedNotification rendered, + CompiledNotificationBinding.CompiledTarget target, + NotificationAttemptContext context, + String payloadDigest) { + + public PreparedNotificationAttempt { + Objects.requireNonNull(rendered, "rendered notification must be non-null"); + Objects.requireNonNull(target, "compiled notification target must be non-null"); + Objects.requireNonNull(context, "notification attempt context must be non-null"); + if (payloadDigest == null || !payloadDigest.matches("[0-9a-f]{64}")) { + throw new IllegalArgumentException("prepared payload digest must be lowercase SHA-256"); + } + } + + @Override + public String toString() { + return "PreparedNotificationAttempt[target=" + + target.target().targetId() + + ", rendered=, context=, payloadDigest=" + + payloadDigest + + "]"; + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/provider/ProviderMessageReference.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/provider/ProviderMessageReference.java new file mode 100644 index 00000000..a1641478 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/provider/ProviderMessageReference.java @@ -0,0 +1,15 @@ +package dev.caskeleton.adapter.outbound.notification.provider; + +/** Opaque provider message identity available only after an accepted response or receipt. */ +public record ProviderMessageReference(String value) + implements NotificationProviderAttemptClient.ReconciliationReference { + + public ProviderMessageReference { + value = AttemptCorrelationId.requireOpaque("provider message reference", value); + } + + @Override + public String toString() { + return "ProviderMessageReference[value=]"; + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/provider/ReconciliationLookupMode.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/provider/ReconciliationLookupMode.java new file mode 100644 index 00000000..fdd03f4d --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/provider/ReconciliationLookupMode.java @@ -0,0 +1,9 @@ +package dev.caskeleton.adapter.outbound.notification.provider; + +/** Provider-documented lookup identity available for reconciliation. */ +public enum ReconciliationLookupMode { + PRE_SEND_CORRELATION, + CLIENT_OPERATION_KEY, + MESSAGE_REFERENCE, + UNSUPPORTED +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/template/LocalEmailRenderer.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/template/LocalEmailRenderer.java new file mode 100644 index 00000000..46528403 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/template/LocalEmailRenderer.java @@ -0,0 +1,473 @@ +package dev.caskeleton.adapter.outbound.notification.template; + +import dev.caskeleton.application.notification.NotificationChannel; +import dev.caskeleton.application.notification.NotificationFrozenPlan; +import dev.caskeleton.application.notification.NotificationTemplateValue; +import java.nio.charset.StandardCharsets; +import java.util.ArrayDeque; +import java.util.Map; +import java.util.Objects; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** Context-aware local email renderer for reviewed subject, text and HTML resources. */ +public final class LocalEmailRenderer + implements NotificationTemplateRenderer { + + private static final Pattern TOKEN = + Pattern.compile("\\{\\{([a-z][A-Za-z0-9]{0,63})\\|([a-z_]+)}}"); + private static final Pattern HTML_NAME = Pattern.compile("[a-z][a-z0-9-]{0,31}"); + private static final java.util.Set APPROVED_HTML_TAGS = + java.util.Set.of( + "a", "b", "body", "br", "div", "em", "h1", "h2", "h3", "h4", "h5", "h6", "head", "html", + "i", "img", "li", "ol", "p", "span", "strong", "table", "tbody", "td", "th", "thead", + "tr", "ul"); + private static final java.util.Set APPROVED_INERT_ATTRIBUTES = + java.util.Set.of("alt", "aria-label", "title"); + + private final NotificationTemplateCatalog catalog; + + public LocalEmailRenderer(NotificationTemplateCatalog catalog) { + this.catalog = Objects.requireNonNull(catalog, "template catalog must be non-null"); + } + + @Override + public RenderedNotification.Email render(NotificationFrozenPlan plan) { + Objects.requireNonNull(plan, "notification frozen plan must be non-null"); + NotificationTemplateManifest manifest = catalog.require(plan.policy().templateRef()); + validatePlan(plan, manifest, NotificationChannel.EMAIL); + NotificationTemplateCatalog.LoadedResourceSet sources = + catalog.sources(manifest, plan.selectedLocale()); + String subjectTemplate = sources.subject().orElseThrow(); + String textTemplate = sources.text().orElseThrow(); + String htmlTemplate = sources.html().orElseThrow(); + validateTemplateUsage( + java.util.List.of(subjectTemplate, textTemplate, htmlTemplate), manifest.parameterNames()); + validateContexts(subjectTemplate, java.util.Set.of("header"), "email subject"); + validateContexts(textTemplate, java.util.Set.of("text"), "email text"); + validateHtmlContexts(htmlTemplate); + requireAggregateBoundedExpansion( + java.util.List.of(subjectTemplate, textTemplate, htmlTemplate), + plan.parameters().values(), + manifest.maximumRenderedBytes(), + 12); + String subject = + stripOneTrailingLineBreak( + render( + subjectTemplate, + plan.parameters().values(), + manifest.parameterNames(), + manifest.maximumRenderedBytes())); + if (subject.indexOf('\r') >= 0 || subject.indexOf('\n') >= 0) { + throw new TemplateRenderingException("rendered email header contains a line break"); + } + String text = + render( + textTemplate, + plan.parameters().values(), + manifest.parameterNames(), + manifest.maximumRenderedBytes()); + String html = + render( + htmlTemplate, + plan.parameters().values(), + manifest.parameterNames(), + manifest.maximumRenderedBytes()); + int bytes = + subject.getBytes(StandardCharsets.UTF_8).length + + text.getBytes(StandardCharsets.UTF_8).length + + html.getBytes(StandardCharsets.UTF_8).length; + if (bytes > manifest.maximumRenderedBytes()) { + throw new TemplateRenderingException("rendered email exceeds the manifest byte bound"); + } + return new RenderedNotification.Email(sources.locale(), subject, text, html, bytes); + } + + static void validatePlan( + NotificationFrozenPlan plan, + NotificationTemplateManifest manifest, + NotificationChannel expectedChannel) { + if (plan.policy().channel() != expectedChannel || manifest.channel() != expectedChannel) { + throw new TemplateRenderingException("template channel does not match renderer"); + } + if (!plan.binding().templateChecksum().equals(manifest.checksum())) { + throw new TemplateRenderingException("frozen template checksum does not match manifest"); + } + if (!plan.binding().rendererRevision().equals(manifest.rendererRevision())) { + throw new TemplateRenderingException("frozen renderer revision does not match manifest"); + } + if (!plan.parameters().values().keySet().equals(manifest.parameterNames())) { + throw new TemplateRenderingException( + "template parameter names must exactly match the manifest"); + } + } + + static String scalar(NotificationTemplateValue value) { + return switch (value) { + case NotificationTemplateValue.SafeText safeText -> safeText.value(); + case NotificationTemplateValue.TrustedAbsoluteLinkReference link -> link.value(); + case NotificationTemplateValue.LocalDateValue date -> date.value().toString(); + case NotificationTemplateValue.LocalDateTimeValue dateTime -> + dateTime.value().atZone(dateTime.zone()).toString(); + case NotificationTemplateValue.IntegerValue integer -> Long.toString(integer.value()); + case NotificationTemplateValue.MoneyValue money -> + money.amount().toPlainString() + " " + money.currency().getCurrencyCode(); + }; + } + + static String html(String value) { + return value + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + .replace("'", "'"); + } + + static String urlComponent(String value) { + StringBuilder encoded = new StringBuilder(); + for (byte item : value.getBytes(StandardCharsets.UTF_8)) { + int unsigned = item & 0xff; + char character = (char) unsigned; + if ((character >= 'a' && character <= 'z') + || (character >= 'A' && character <= 'Z') + || (character >= '0' && character <= '9') + || character == '-' + || character == '.' + || character == '_' + || character == '~') { + encoded.append(character); + } else { + encoded.append('%'); + encoded.append(Character.toUpperCase(Character.forDigit((unsigned >>> 4) & 0xf, 16))); + encoded.append(Character.toUpperCase(Character.forDigit(unsigned & 0xf, 16))); + } + } + return encoded.toString(); + } + + static void validateTemplateUsage( + java.util.List templates, java.util.Set manifestNames) { + java.util.Set used = new java.util.HashSet<>(); + templates.forEach( + template -> { + Matcher matcher = TOKEN.matcher(template); + while (matcher.find()) { + used.add(matcher.group(1)); + } + }); + if (!used.equals(manifestNames)) { + throw new TemplateRenderingException( + "template parameter usage must exactly match the manifest"); + } + } + + private static void validateContexts( + String template, java.util.Set allowedContexts, String assetRole) { + Matcher matcher = TOKEN.matcher(template); + while (matcher.find()) { + if (!allowedContexts.contains(matcher.group(2))) { + throw new TemplateRenderingException( + assetRole + " uses a rendering context that is not approved for that asset"); + } + } + } + + static void validateHtmlContexts(String template) { + validateRestrictedHtml(template); + Matcher matcher = TOKEN.matcher(template); + while (matcher.find()) { + boolean insideTag = isInsideMarkupTag(template, matcher.start()); + switch (matcher.group(2)) { + case "html_text" -> { + if (insideTag) { + throw new TemplateRenderingException( + "HTML text parameter must occur outside markup tags"); + } + } + case "html_attr" -> { + if (!insideTag || !isApprovedHtmlAttribute(template, matcher)) { + throw new TemplateRenderingException( + "HTML attribute parameter is outside an approved inert attribute"); + } + } + case "url_component" -> { + if (!insideTag || !isApprovedUrlComponent(template, matcher)) { + throw new TemplateRenderingException( + "URL component parameter is outside an approved HTTPS link attribute"); + } + } + default -> + throw new TemplateRenderingException( + "email HTML uses a rendering context that is not approved for that asset"); + } + } + } + + private static String render( + String template, + Map parameters, + java.util.Set manifestNames, + int maximumBytes) { + requireBoundedExpansion(template, parameters, maximumBytes); + Matcher matcher = TOKEN.matcher(template); + StringBuilder output = new StringBuilder(Math.min(template.length(), maximumBytes)); + while (matcher.find()) { + String name = matcher.group(1); + NotificationTemplateValue parameter = parameters.get(name); + if (parameter == null || !manifestNames.contains(name)) { + throw new TemplateRenderingException("template references an unknown parameter"); + } + String raw = scalar(parameter); + String replacement = + switch (matcher.group(2)) { + case "header" -> { + if (raw.indexOf('\r') >= 0 || raw.indexOf('\n') >= 0) { + throw new TemplateRenderingException( + "rendered email header parameter contains a line break"); + } + yield raw; + } + case "text" -> raw; + case "html_text" -> html(raw); + case "html_attr" -> htmlAttribute(template, matcher, raw); + case "url_component" -> { + if (!(parameter instanceof NotificationTemplateValue.SafeText)) { + throw new TemplateRenderingException( + "URL component parameters must use bounded safe text values"); + } + yield urlComponent(raw); + } + default -> throw new TemplateRenderingException("unknown email rendering context"); + }; + matcher.appendReplacement(output, Matcher.quoteReplacement(replacement)); + } + matcher.appendTail(output); + if (output.indexOf("{{") >= 0) { + throw new TemplateRenderingException("template has an unresolved parameter"); + } + return output.toString(); + } + + private static void requireBoundedExpansion( + String template, Map parameters, int maximumBytes) { + requireAggregateBoundedExpansion(java.util.List.of(template), parameters, maximumBytes, 12); + } + + static void requireAggregateBoundedExpansion( + java.util.List templates, + Map parameters, + int maximumBytes, + int maximumEscapedBytesPerInputByte) { + long projectedBytes = 0; + for (String template : templates) { + projectedBytes = + Math.addExact(projectedBytes, template.getBytes(StandardCharsets.UTF_8).length); + Matcher matcher = TOKEN.matcher(template); + while (matcher.find()) { + NotificationTemplateValue value = parameters.get(matcher.group(1)); + if (value != null) { + long rawBytes = scalar(value).getBytes(StandardCharsets.UTF_8).length; + projectedBytes = + Math.addExact( + projectedBytes, + Math.multiplyExact(rawBytes, (long) maximumEscapedBytesPerInputByte)); + } + if (projectedBytes > maximumBytes) { + throw new TemplateRenderingException( + "rendered template expansion exceeds the manifest byte bound"); + } + } + } + } + + private static String htmlAttribute(String template, Matcher matcher, String raw) { + if (!isApprovedHtmlAttribute(template, matcher) + || raw.indexOf('`') >= 0 + || raw.chars().anyMatch(Character::isISOControl)) { + throw new TemplateRenderingException( + "HTML attribute parameter is outside an approved quoted non-URL context"); + } + return html(raw); + } + + private static boolean isApprovedHtmlAttribute(String template, Matcher matcher) { + boolean exactlyQuoted = + matcher.start() > 0 + && matcher.end() < template.length() + && template.charAt(matcher.start() - 1) == '"' + && template.charAt(matcher.end()) == '"'; + String prefix = template.substring(Math.max(0, matcher.start() - 64), matcher.start()); + return exactlyQuoted + && prefix + .toLowerCase(java.util.Locale.ROOT) + .matches("(?s).*(title|alt|aria-label)\\s*=\\s*\"$"); + } + + private static boolean isApprovedUrlComponent(String template, Matcher matcher) { + int tagStart = template.lastIndexOf('<', matcher.start()); + int quoteEnd = template.indexOf('"', matcher.end()); + int tagEnd = template.indexOf('>', matcher.end()); + if (tagStart < 0 || quoteEnd < matcher.end() || tagEnd < quoteEnd) { + return false; + } + String attributePrefix = + template.substring(tagStart, matcher.start()).toLowerCase(java.util.Locale.ROOT); + String attributeSuffix = template.substring(matcher.end(), quoteEnd); + return attributePrefix.matches( + "(?s).*\\shref\\s*=\\s*\"https://" + + "[a-z0-9](?:[a-z0-9.-]{0,251}[a-z0-9])?(?::[0-9]{1,5})?" + + "(?:/[^\"{}]*|\\?[^\"{}]*|#[^\"{}]*)$") + && !attributeSuffix.contains("{{") + && !attributeSuffix.contains("}}"); + } + + private static boolean isInsideMarkupTag(String template, int position) { + boolean insideTag = false; + char quote = 0; + for (int index = 0; index < position; index++) { + char current = template.charAt(index); + if (!insideTag) { + if (current == '<') { + insideTag = true; + } + } else if (quote != 0) { + if (current == quote) { + quote = 0; + } + } else if (current == '"' || current == '\'') { + quote = current; + } else if (current == '>') { + insideTag = false; + } + } + return insideTag; + } + + private static void validateRestrictedHtml(String template) { + ArrayDeque openTags = new ArrayDeque<>(); + int cursor = 0; + while (cursor < template.length()) { + int tagStart = template.indexOf('<', cursor); + if (tagStart < 0) { + break; + } + int tagEnd = findTagEnd(template, tagStart + 1); + if (tagEnd < 0) { + throw new TemplateRenderingException("email HTML contains an unterminated markup tag"); + } + String body = template.substring(tagStart + 1, tagEnd).trim(); + if (body.isEmpty() || body.startsWith("!") || body.startsWith("?")) { + throw new TemplateRenderingException("email HTML contains unsupported markup"); + } + if (body.startsWith("/")) { + String closingName = body.substring(1).trim().toLowerCase(java.util.Locale.ROOT); + if (!HTML_NAME.matcher(closingName).matches() + || openTags.isEmpty() + || !openTags.removeLast().equals(closingName)) { + throw new TemplateRenderingException("email HTML contains mismatched markup"); + } + } else { + boolean selfClosing = body.endsWith("/"); + String opening = selfClosing ? body.substring(0, body.length() - 1).trim() : body; + int nameEnd = 0; + while (nameEnd < opening.length() + && (Character.isLetterOrDigit(opening.charAt(nameEnd)) + || opening.charAt(nameEnd) == '-')) { + nameEnd++; + } + String tagName = opening.substring(0, nameEnd).toLowerCase(java.util.Locale.ROOT); + if (!HTML_NAME.matcher(tagName).matches() || !APPROVED_HTML_TAGS.contains(tagName)) { + throw new TemplateRenderingException("email HTML contains an unsupported tag"); + } + validateAttributes(tagName, opening.substring(nameEnd)); + boolean voidElement = tagName.equals("br") || tagName.equals("img"); + if (selfClosing != voidElement && (selfClosing || voidElement)) { + throw new TemplateRenderingException("email HTML uses an invalid void element shape"); + } + if (!voidElement) { + openTags.addLast(tagName); + } + } + cursor = tagEnd + 1; + } + if (!openTags.isEmpty()) { + throw new TemplateRenderingException("email HTML contains unclosed markup"); + } + } + + private static int findTagEnd(String template, int start) { + char quote = 0; + for (int index = start; index < template.length(); index++) { + char current = template.charAt(index); + if (quote != 0) { + if (current == quote) { + quote = 0; + } + } else if (current == '"' || current == '\'') { + quote = current; + } else if (current == '>') { + return index; + } + } + return -1; + } + + private static void validateAttributes(String tagName, String attributes) { + int cursor = 0; + while (cursor < attributes.length()) { + while (cursor < attributes.length() && Character.isWhitespace(attributes.charAt(cursor))) { + cursor++; + } + if (cursor == attributes.length()) { + return; + } + int nameStart = cursor; + while (cursor < attributes.length() + && (Character.isLetterOrDigit(attributes.charAt(cursor)) + || attributes.charAt(cursor) == '-')) { + cursor++; + } + String attributeName = + attributes.substring(nameStart, cursor).toLowerCase(java.util.Locale.ROOT); + if (!HTML_NAME.matcher(attributeName).matches()) { + throw new TemplateRenderingException("email HTML contains malformed attributes"); + } + while (cursor < attributes.length() && Character.isWhitespace(attributes.charAt(cursor))) { + cursor++; + } + if (cursor >= attributes.length() || attributes.charAt(cursor++) != '=') { + throw new TemplateRenderingException("email HTML attributes must have explicit values"); + } + while (cursor < attributes.length() && Character.isWhitespace(attributes.charAt(cursor))) { + cursor++; + } + if (cursor >= attributes.length() || attributes.charAt(cursor++) != '"') { + throw new TemplateRenderingException("email HTML attributes must be double quoted"); + } + int valueEnd = attributes.indexOf('"', cursor); + if (valueEnd < 0) { + throw new TemplateRenderingException("email HTML contains an unterminated attribute"); + } + String value = attributes.substring(cursor, valueEnd); + if (attributeName.equals("href")) { + if (!tagName.equals("a") || !value.startsWith("https://")) { + throw new TemplateRenderingException("email HTML links must be static HTTPS links"); + } + } else if (!APPROVED_INERT_ATTRIBUTES.contains(attributeName)) { + throw new TemplateRenderingException("email HTML contains an unsupported attribute"); + } + cursor = valueEnd + 1; + } + } + + private static String stripOneTrailingLineBreak(String value) { + if (value.endsWith("\r\n")) { + return value.substring(0, value.length() - 2); + } + if (value.endsWith("\n")) { + return value.substring(0, value.length() - 1); + } + return value; + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/template/NotificationTemplateCatalog.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/template/NotificationTemplateCatalog.java new file mode 100644 index 00000000..17f3acb4 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/template/NotificationTemplateCatalog.java @@ -0,0 +1,253 @@ +package dev.caskeleton.adapter.outbound.notification.template; + +import dev.caskeleton.application.notification.NotificationTemplateRef; +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; + +/** + * Loads only manifest-named classpath assets, validates exact checksums, and retains immutable + * text. + */ +public final class NotificationTemplateCatalog { + + private static final int MAXIMUM_RESOURCE_BYTES = 1_000_000; + private static final long MAXIMUM_CATALOG_BYTES = 64L * 1024L * 1024L; + private static final int MAXIMUM_CATALOG_RESOURCES = 4_096; + private static final long MAXIMUM_MANIFEST_BYTES = 16L * 1024L * 1024L; + private static final int MAXIMUM_MANIFEST_RESOURCES = 256; + + private final Map templates; + + public NotificationTemplateCatalog( + ClassLoader classLoader, List manifests) { + Objects.requireNonNull(classLoader, "template class loader must be non-null"); + Objects.requireNonNull(manifests, "template manifests must be non-null"); + Map loaded = new HashMap<>(); + LoadBudget budget = new LoadBudget(MAXIMUM_CATALOG_BYTES, MAXIMUM_CATALOG_RESOURCES); + manifests.stream() + .sorted( + Comparator.comparing( + (NotificationTemplateManifest manifest) -> manifest.templateRef().templateId()) + .thenComparingInt(manifest -> manifest.templateRef().version())) + .forEach( + manifest -> { + Objects.requireNonNull(manifest, "template manifest must be non-null"); + if (loaded.containsKey(manifest.templateRef())) { + throw new TemplateRenderingException("duplicate template manifest"); + } + Map sources = + load(classLoader, manifest.resources(), budget); + String actual = checksumLoaded(sources); + if (!actual.equals(manifest.checksum())) { + throw new TemplateRenderingException("template manifest checksum mismatch"); + } + sources.values().forEach(NotificationTemplateCatalog::rejectDirectives); + loaded.put(manifest.templateRef(), new LoadedTemplate(manifest, sources)); + }); + if (loaded.isEmpty() || loaded.size() > 100) { + throw new TemplateRenderingException("template catalog must contain 1..100 manifests"); + } + this.templates = Map.copyOf(loaded); + } + + public NotificationTemplateManifest require(NotificationTemplateRef templateRef) { + Objects.requireNonNull(templateRef, "template reference must be non-null"); + LoadedTemplate template = templates.get(templateRef); + if (template == null) { + throw new TemplateRenderingException("unknown template revision"); + } + return template.manifest(); + } + + LoadedResourceSet sources(NotificationTemplateManifest manifest, Locale requested) { + LoadedTemplate loaded = templates.get(manifest.templateRef()); + if (loaded == null || !loaded.manifest().equals(manifest)) { + throw new TemplateRenderingException("template manifest is outside the loaded catalog"); + } + Objects.requireNonNull(requested, "frozen selected locale must be non-null"); + Locale selected = Locale.forLanguageTag(requested.toLanguageTag()); + if (!manifest.supportedLocales().contains(selected)) { + throw new TemplateRenderingException( + "frozen selected locale is outside the exact template manifest"); + } + LoadedResourceSet sources = loaded.sources().get(selected); + if (sources == null) { + throw new TemplateRenderingException("selected template locale has no exact resource set"); + } + return sources.withLocale(selected); + } + + public static String checksum( + ClassLoader classLoader, Map resources) { + Objects.requireNonNull(classLoader, "template class loader must be non-null"); + Objects.requireNonNull(resources, "template resources must be non-null"); + return checksumLoaded( + load( + classLoader, + resources, + new LoadBudget(MAXIMUM_MANIFEST_BYTES, MAXIMUM_MANIFEST_RESOURCES))); + } + + private static Map load( + ClassLoader classLoader, + Map resources, + LoadBudget budget) { + LinkedHashMap loaded = new LinkedHashMap<>(); + resources.entrySet().stream() + .sorted(Map.Entry.comparingByKey(Comparator.comparing(Locale::toLanguageTag))) + .forEach( + entry -> { + NotificationTemplateManifest.ResourceSet paths = entry.getValue(); + loaded.put( + entry.getKey(), + new LoadedResourceSet( + entry.getKey(), + paths.subject().map(path -> text(classLoader, path, budget)), + paths.text().map(path -> text(classLoader, path, budget)), + paths.html().map(path -> text(classLoader, path, budget)), + paths.slack().map(path -> text(classLoader, path, budget)))); + }); + return Map.copyOf(loaded); + } + + private static String checksumLoaded(Map sources) { + MessageDigest digest = sha256(); + sources.entrySet().stream() + .sorted(Map.Entry.comparingByKey(Comparator.comparing(Locale::toLanguageTag))) + .forEach( + entry -> { + update(digest, entry.getKey().toLanguageTag()); + LoadedResourceSet resource = entry.getValue(); + updateOptional(digest, "subject", resource.subject()); + updateOptional(digest, "text", resource.text()); + updateOptional(digest, "html", resource.html()); + updateOptional(digest, "slack", resource.slack()); + }); + return HexFormat.of().formatHex(digest.digest()); + } + + private static void updateOptional( + MessageDigest digest, String role, java.util.Optional content) { + content.ifPresent( + value -> { + update(digest, role); + byte[] encoded = value.getBytes(StandardCharsets.UTF_8); + digest.update(ByteBuffer.allocate(Integer.BYTES).putInt(encoded.length).array()); + digest.update(encoded); + }); + } + + private static void rejectDirectives(LoadedResourceSet sources) { + java.util.stream.Stream.of(sources.subject(), sources.text(), sources.html(), sources.slack()) + .flatMap(java.util.Optional::stream) + .forEach( + content -> { + if (content.contains("{{>") + || content.contains("{%") + || content.contains("${") + || content.contains("{{#")) { + throw new TemplateRenderingException( + "template contains unsupported include or reflection directive"); + } + }); + } + + private static String text(ClassLoader classLoader, String path, LoadBudget budget) { + byte[] bytes = read(classLoader, path); + budget.consume(bytes.length); + return new String(bytes, StandardCharsets.UTF_8); + } + + private static byte[] read(ClassLoader classLoader, String path) { + try (InputStream stream = classLoader.getResourceAsStream(path)) { + if (stream == null) { + throw new TemplateRenderingException("manifest resource is missing"); + } + byte[] bytes = stream.readNBytes(MAXIMUM_RESOURCE_BYTES + 1); + if (bytes.length > MAXIMUM_RESOURCE_BYTES) { + throw new TemplateRenderingException("manifest resource exceeds the byte bound"); + } + return bytes; + } catch (IOException failure) { + throw new TemplateRenderingException("manifest resource could not be loaded", failure); + } + } + + private static MessageDigest sha256() { + try { + return MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException unavailable) { + throw new IllegalStateException( + "SHA-256 must be available on every Java runtime", unavailable); + } + } + + private static void update(MessageDigest digest, String value) { + byte[] encoded = value.getBytes(StandardCharsets.UTF_8); + digest.update(ByteBuffer.allocate(Integer.BYTES).putInt(encoded.length).array()); + digest.update(encoded); + } + + record LoadedTemplate( + NotificationTemplateManifest manifest, Map sources) { + + LoadedTemplate { + Objects.requireNonNull(manifest, "loaded template manifest must be non-null"); + sources = Map.copyOf(Objects.requireNonNull(sources, "loaded sources must be non-null")); + } + } + + record LoadedResourceSet( + Locale locale, + java.util.Optional subject, + java.util.Optional text, + java.util.Optional html, + java.util.Optional slack) { + + LoadedResourceSet { + Objects.requireNonNull(locale, "loaded resource locale must be non-null"); + Objects.requireNonNull(subject, "loaded subject container must be non-null"); + Objects.requireNonNull(text, "loaded text container must be non-null"); + Objects.requireNonNull(html, "loaded HTML container must be non-null"); + Objects.requireNonNull(slack, "loaded Slack container must be non-null"); + } + + LoadedResourceSet withLocale(Locale selected) { + return new LoadedResourceSet(selected, subject, text, html, slack); + } + } + + private static final class LoadBudget { + + private final long maximumBytes; + private final int maximumResources; + private long loadedBytes; + private int loadedResources; + + private LoadBudget(long maximumBytes, int maximumResources) { + this.maximumBytes = maximumBytes; + this.maximumResources = maximumResources; + } + + private void consume(int bytes) { + loadedBytes = Math.addExact(loadedBytes, bytes); + loadedResources = Math.addExact(loadedResources, 1); + if (loadedBytes > maximumBytes || loadedResources > maximumResources) { + throw new TemplateRenderingException( + "template catalog exceeds the aggregate resource budget"); + } + } + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/template/NotificationTemplateManifest.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/template/NotificationTemplateManifest.java new file mode 100644 index 00000000..0549f405 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/template/NotificationTemplateManifest.java @@ -0,0 +1,175 @@ +package dev.caskeleton.adapter.outbound.notification.template; + +import dev.caskeleton.application.notification.NotificationChannel; +import dev.caskeleton.application.notification.NotificationTemplateRef; +import java.util.Comparator; +import java.util.HashSet; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.TreeMap; +import java.util.stream.Collectors; + +/** Exact immutable classpath resource manifest for one reviewed template revision. */ +public record NotificationTemplateManifest( + NotificationTemplateRef templateRef, + NotificationChannel channel, + String rendererRevision, + String checksum, + Set supportedLocales, + Locale fallbackLocale, + Set parameterNames, + int maximumRenderedBytes, + Map resources) { + + public NotificationTemplateManifest { + Objects.requireNonNull(templateRef, "template reference must be non-null"); + Objects.requireNonNull(channel, "template channel must be non-null"); + rendererRevision = requireSlug("renderer revision", rendererRevision); + if (checksum == null || !checksum.matches("[0-9a-f]{64}")) { + throw new IllegalArgumentException("template checksum must be a lowercase SHA-256 digest"); + } + Objects.requireNonNull(supportedLocales, "supported locales must be non-null"); + supportedLocales = + supportedLocales.stream() + .map(NotificationTemplateManifest::requireLocale) + .sorted(Comparator.comparing(Locale::toLanguageTag)) + .collect(Collectors.toUnmodifiableSet()); + if (supportedLocales.isEmpty() || supportedLocales.size() > 32) { + throw new IllegalArgumentException("supported locales must contain 1..32 entries"); + } + fallbackLocale = requireLocale(fallbackLocale); + if (!supportedLocales.contains(fallbackLocale)) { + throw new IllegalArgumentException("fallback locale must be supported"); + } + Objects.requireNonNull(parameterNames, "template parameter names must be non-null"); + parameterNames = + parameterNames.stream() + .map(NotificationTemplateManifest::requireParameter) + .collect(Collectors.toUnmodifiableSet()); + if (parameterNames.size() > 32) { + throw new IllegalArgumentException("template parameter names exceed 32 entries"); + } + Objects.requireNonNull(resources, "template resources must be non-null"); + TreeMap> sorted = new TreeMap<>(); + resources.forEach( + (locale, resourceSet) -> { + Locale normalized = requireLocale(locale); + if (sorted.put( + normalized.toLanguageTag(), + Map.entry( + normalized, + Objects.requireNonNull( + resourceSet, "template resource set must be non-null"))) + != null) { + throw new IllegalArgumentException("duplicate normalized template locale"); + } + }); + resources = + sorted.values().stream() + .collect( + Collectors.toUnmodifiableMap( + Map.Entry::getKey, Map.Entry::getValue, (left, right) -> left)); + if (!resources.keySet().equals(supportedLocales)) { + throw new IllegalArgumentException( + "template resource locales must exactly match supported locales"); + } + if (new HashSet<>(resources.keySet()).size() != resources.size()) { + throw new IllegalArgumentException("template resources contain duplicate locales"); + } + resources.values().forEach(resource -> resource.validateFor(channel)); + if (maximumRenderedBytes < 1 || maximumRenderedBytes > 10_000_000) { + throw new IllegalArgumentException("maximum rendered bytes must be in 1..10000000"); + } + } + + public Locale selectLocale(Locale requested) { + Locale normalized = requireLocale(requested); + return supportedLocales.contains(normalized) ? normalized : fallbackLocale; + } + + private static Locale requireLocale(Locale locale) { + Objects.requireNonNull(locale, "template locale must be non-null"); + String tag = locale.toLanguageTag(); + if (locale.equals(Locale.ROOT) || tag.equals("und") || tag.isBlank() || tag.length() > 35) { + throw new IllegalArgumentException("template locale must be explicit and bounded"); + } + return Locale.forLanguageTag(tag); + } + + private static String requireParameter(String name) { + if (name == null || !name.matches("[a-z][A-Za-z0-9]{0,63}")) { + throw new IllegalArgumentException("template parameter must match [a-z][A-Za-z0-9]{0,63}"); + } + return name; + } + + private static String requireSlug(String field, String value) { + if (value == null || !value.matches("[a-z][a-z0-9.-]{0,62}")) { + throw new IllegalArgumentException(field + " must match [a-z][a-z0-9.-]{0,62}"); + } + return value; + } + + /** Channel-shaped exact classpath paths; paths cannot escape the notification template root. */ + public record ResourceSet( + Optional subject, + Optional text, + Optional html, + Optional slack) { + + public ResourceSet { + subject = validateOptional(subject); + text = validateOptional(text); + html = validateOptional(html); + slack = validateOptional(slack); + } + + public static ResourceSet email(String subject, String text, String html) { + return new ResourceSet( + Optional.of(subject), Optional.of(text), Optional.of(html), Optional.empty()); + } + + public static ResourceSet slack(String slack) { + return new ResourceSet( + Optional.empty(), Optional.empty(), Optional.empty(), Optional.of(slack)); + } + + Map rolePaths() { + java.util.LinkedHashMap paths = new java.util.LinkedHashMap<>(); + subject.ifPresent(path -> paths.put("subject", path)); + text.ifPresent(path -> paths.put("text", path)); + html.ifPresent(path -> paths.put("html", path)); + slack.ifPresent(path -> paths.put("slack", path)); + return Map.copyOf(paths); + } + + void validateFor(NotificationChannel channel) { + boolean emailShape = + subject.isPresent() && text.isPresent() && html.isPresent() && slack.isEmpty(); + boolean slackShape = + subject.isEmpty() && text.isEmpty() && html.isEmpty() && slack.isPresent(); + if ((channel == NotificationChannel.EMAIL && !emailShape) + || (channel == NotificationChannel.SLACK && !slackShape)) { + throw new IllegalArgumentException("template resources do not match channel shape"); + } + } + + private static Optional validateOptional(Optional path) { + Objects.requireNonNull(path, "template resource path container must be non-null"); + return path.map( + value -> { + if (!value.matches("notification/templates/[a-z0-9._/-]{1,180}") + || value.contains("..") + || value.startsWith("/") + || value.contains("\\")) { + throw new IllegalArgumentException( + "template resource path must remain under notification/templates"); + } + return value; + }); + } + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/template/NotificationTemplateRenderer.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/template/NotificationTemplateRenderer.java new file mode 100644 index 00000000..79524df5 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/template/NotificationTemplateRenderer.java @@ -0,0 +1,10 @@ +package dev.caskeleton.adapter.outbound.notification.template; + +import dev.caskeleton.application.notification.NotificationFrozenPlan; + +/** Deterministic local renderer over an application-owned frozen plan. */ +@FunctionalInterface +public interface NotificationTemplateRenderer { + + T render(NotificationFrozenPlan plan); +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/template/RenderedNotification.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/template/RenderedNotification.java new file mode 100644 index 00000000..de1aa1ee --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/template/RenderedNotification.java @@ -0,0 +1,112 @@ +package dev.caskeleton.adapter.outbound.notification.template; + +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Locale; +import java.util.Objects; + +/** Closed typed renderer output; arbitrary provider JSON is deliberately absent. */ +public sealed interface RenderedNotification + permits RenderedNotification.Email, RenderedNotification.Slack { + + Locale locale(); + + int utf8Bytes(); + + record Email(Locale locale, String subject, String text, String html, int utf8Bytes) + implements RenderedNotification { + + public Email { + Objects.requireNonNull(locale, "rendered email locale must be non-null"); + Objects.requireNonNull(subject, "rendered email subject must be non-null"); + Objects.requireNonNull(text, "rendered email text must be non-null"); + Objects.requireNonNull(html, "rendered email HTML must be non-null"); + int actual = + subject.getBytes(StandardCharsets.UTF_8).length + + text.getBytes(StandardCharsets.UTF_8).length + + html.getBytes(StandardCharsets.UTF_8).length; + if (utf8Bytes != actual || utf8Bytes < 1) { + throw new IllegalArgumentException("rendered email byte count must be exact and positive"); + } + } + + @Override + public String toString() { + return "Email[locale=" + + locale.toLanguageTag() + + ", content=, utf8Bytes=" + + utf8Bytes + + "]"; + } + } + + record Slack(Locale locale, List blocks, int maximumDepth, int utf8Bytes) + implements RenderedNotification { + + public Slack { + Objects.requireNonNull(locale, "rendered Slack locale must be non-null"); + Objects.requireNonNull(blocks, "rendered Slack blocks must be non-null"); + blocks = + blocks.stream() + .map(block -> Objects.requireNonNull(block, "Slack block must be non-null")) + .toList(); + if (blocks.isEmpty() || blocks.size() > 50) { + throw new IllegalArgumentException("rendered Slack block count must be in 1..50"); + } + if (maximumDepth < 1 || maximumDepth > 8) { + throw new IllegalArgumentException("rendered Slack depth must be in 1..8"); + } + int actual = + blocks.stream() + .mapToInt(block -> block.text().getBytes(StandardCharsets.UTF_8).length) + .sum(); + if (utf8Bytes != actual || utf8Bytes < 1) { + throw new IllegalArgumentException("rendered Slack byte count must be exact and positive"); + } + } + + @Override + public String toString() { + return "Slack[locale=" + + locale.toLanguageTag() + + ", blocks=, maximumDepth=" + + maximumDepth + + ", utf8Bytes=" + + utf8Bytes + + "]"; + } + } + + record SlackBlock(TextType type, String text, boolean verbatim) { + + public SlackBlock { + Objects.requireNonNull(type, "Slack block text type must be non-null"); + Objects.requireNonNull(text, "Slack block text must be non-null"); + if (text.isBlank() || text.length() > 3_000) { + throw new IllegalArgumentException("Slack block text must contain 1..3000 characters"); + } + if (type == TextType.MRKDWN && !verbatim) { + throw new IllegalArgumentException( + "Slack mrkdwn blocks must disable automatic link and mention expansion"); + } + } + + public static SlackBlock mrkdwn(String text) { + return new SlackBlock(TextType.MRKDWN, text, true); + } + + public static SlackBlock plainText(String text) { + return new SlackBlock(TextType.PLAIN_TEXT, text, false); + } + + @Override + public String toString() { + return "SlackBlock[type=" + type + ", text=]"; + } + } + + enum TextType { + MRKDWN, + PLAIN_TEXT + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/template/SlackBlockKitRenderer.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/template/SlackBlockKitRenderer.java new file mode 100644 index 00000000..3e61ef29 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/template/SlackBlockKitRenderer.java @@ -0,0 +1,124 @@ +package dev.caskeleton.adapter.outbound.notification.template; + +import dev.caskeleton.application.notification.NotificationChannel; +import dev.caskeleton.application.notification.NotificationFrozenPlan; +import dev.caskeleton.application.notification.NotificationTemplateValue; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** Typed Block Kit renderer preserving plain_text versus mrkdwn context and bounded depth. */ +public final class SlackBlockKitRenderer + implements NotificationTemplateRenderer { + + private static final Pattern TOKEN = + Pattern.compile("\\{\\{([a-z][A-Za-z0-9]{0,63})\\|([a-z_]+)}}"); + + private final NotificationTemplateCatalog catalog; + + public SlackBlockKitRenderer(NotificationTemplateCatalog catalog) { + this.catalog = Objects.requireNonNull(catalog, "template catalog must be non-null"); + } + + @Override + public RenderedNotification.Slack render(NotificationFrozenPlan plan) { + Objects.requireNonNull(plan, "notification frozen plan must be non-null"); + NotificationTemplateManifest manifest = catalog.require(plan.policy().templateRef()); + LocalEmailRenderer.validatePlan(plan, manifest, NotificationChannel.SLACK); + NotificationTemplateCatalog.LoadedResourceSet sources = + catalog.sources(manifest, plan.selectedLocale()); + String template = sources.slack().orElseThrow(); + LocalEmailRenderer.validateTemplateUsage(List.of(template), manifest.parameterNames()); + LocalEmailRenderer.requireAggregateBoundedExpansion( + List.of(template), plan.parameters().values(), manifest.maximumRenderedBytes(), 6); + List blocks = + renderBlocks( + template, + plan.parameters().values(), + manifest.parameterNames(), + manifest.maximumRenderedBytes()); + int bytes = + blocks.stream() + .mapToInt(block -> block.text().getBytes(StandardCharsets.UTF_8).length) + .sum(); + if (bytes > manifest.maximumRenderedBytes()) { + throw new TemplateRenderingException( + "rendered Slack message exceeds the manifest byte bound"); + } + return new RenderedNotification.Slack(sources.locale(), blocks, 3, bytes); + } + + private static List renderBlocks( + String template, + Map parameters, + Set manifestNames, + int maximumBytes) { + List blocks = new ArrayList<>(); + java.util.Iterator lines = template.lines().iterator(); + while (lines.hasNext()) { + if (blocks.size() == 50) { + throw new TemplateRenderingException("rendered Slack block count is outside 1..50"); + } + String line = lines.next(); + boolean mrkdwn = line.contains("|mrkdwn}}"); + if (mrkdwn && (line.contains("|plain}}") || line.contains("|mention}}"))) { + throw new TemplateRenderingException( + "Slack block cannot mix mrkdwn and plain_text contexts"); + } + String rendered = renderLine(line, parameters, manifestNames, maximumBytes); + blocks.add( + mrkdwn + ? RenderedNotification.SlackBlock.mrkdwn(rendered) + : RenderedNotification.SlackBlock.plainText(rendered)); + } + if (blocks.isEmpty()) { + throw new TemplateRenderingException("rendered Slack block count is outside 1..50"); + } + return List.copyOf(blocks); + } + + private static String renderLine( + String template, + Map parameters, + Set manifestNames, + int maximumBytes) { + Matcher matcher = TOKEN.matcher(template); + StringBuilder output = new StringBuilder(Math.min(template.length(), maximumBytes)); + while (matcher.find()) { + String name = matcher.group(1); + NotificationTemplateValue parameter = parameters.get(name); + if (parameter == null || !manifestNames.contains(name)) { + throw new TemplateRenderingException("Slack template references an unknown parameter"); + } + String plain = escapeSlackPlain(LocalEmailRenderer.scalar(parameter)); + String replacement = + switch (matcher.group(2)) { + case "plain" -> plain; + case "mrkdwn" -> + plain + .replace("\\", "\\\\") + .replace("*", "\\*") + .replace("_", "\\_") + .replace("~", "\\~") + .replace("`", "\\`"); + case "mention" -> plain.replace("@", "@\u200B"); + default -> throw new TemplateRenderingException("unknown Slack rendering context"); + }; + matcher.appendReplacement(output, Matcher.quoteReplacement(replacement)); + } + matcher.appendTail(output); + if (output.indexOf("{{") >= 0) { + throw new TemplateRenderingException("Slack template has an unresolved parameter"); + } + return output.toString(); + } + + private static String escapeSlackPlain(String value) { + return value.replace("&", "&").replace("<", "<").replace(">", ">"); + } +} diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/template/TemplateRenderingException.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/template/TemplateRenderingException.java new file mode 100644 index 00000000..5656d463 --- /dev/null +++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/template/TemplateRenderingException.java @@ -0,0 +1,13 @@ +package dev.caskeleton.adapter.outbound.notification.template; + +/** Redacted fail-closed template loading or rendering error. */ +public final class TemplateRenderingException extends RuntimeException { + + public TemplateRenderingException(String safeMessage) { + super(safeMessage); + } + + public TemplateRenderingException(String safeMessage, Throwable cause) { + super(safeMessage, cause); + } +} diff --git a/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/catalog/NotificationBindingCompilerTest.java b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/catalog/NotificationBindingCompilerTest.java new file mode 100644 index 00000000..4e9c5255 --- /dev/null +++ b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/catalog/NotificationBindingCompilerTest.java @@ -0,0 +1,489 @@ +package dev.caskeleton.adapter.outbound.notification.catalog; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.application.notification.ConsentCheckMode; +import dev.caskeleton.application.notification.EmailRecipientReference; +import dev.caskeleton.application.notification.NotificationAdmissionClass; +import dev.caskeleton.application.notification.NotificationChannel; +import dev.caskeleton.application.notification.NotificationFrozenPlan; +import dev.caskeleton.application.notification.NotificationIntentDraft; +import dev.caskeleton.application.notification.NotificationIntentId; +import dev.caskeleton.application.notification.NotificationKindId; +import dev.caskeleton.application.notification.NotificationKindPolicy; +import dev.caskeleton.application.notification.NotificationMode; +import dev.caskeleton.application.notification.NotificationPlanningResult; +import dev.caskeleton.application.notification.NotificationRouteId; +import dev.caskeleton.application.notification.NotificationRouteStrategy; +import dev.caskeleton.application.notification.NotificationTemplateParameters; +import dev.caskeleton.application.notification.NotificationTemplateRef; +import dev.caskeleton.application.notification.NotificationTemplateValue; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import org.junit.jupiter.api.Test; + +class NotificationBindingCompilerTest { + + @Test + void explicitCatalogCompilesToSortedImmutableGraphAndApplicationFrozenPlan() { + NotificationBindingCompiler compiler = validCompiler(); + + NotificationBindingCompiler.CompiledGraph graph = compiler.compile(); + + assertThat(graph.bindings()) + .extracting(binding -> binding.route().routeId().value()) + .containsExactly("security-email", "security-slack"); + assertThat(graph.bindings()).isUnmodifiable(); + assertThat(graph.manifestDigest()) + .isEqualTo("de53ed4517827786b460009132c5aadad0825a12d3b8e442bb444203c7221ea7"); + assertThat(validCompiler().compile()).isEqualTo(graph); + + NotificationPlanAdapter planner = new NotificationPlanAdapter(graph); + NotificationPlanningResult result = planner.plan(emailDraft()); + assertThat(result).isInstanceOf(NotificationPlanningResult.Planned.class); + NotificationFrozenPlan plan = ((NotificationPlanningResult.Planned) result).plan(); + assertThat(plan.policy().policyRevision()).isEqualTo(17); + assertThat(plan.binding().routeRevision()).isEqualTo(3); + assertThat(plan.binding().bindingDigest()) + .isEqualTo("f9d50c2d559236a397d3607e5fed100141c8673ab8262539ecbeb736b3bfb51d"); + assertThat(plan.binding().rendererRevision()).isEqualTo("email-renderer-r1"); + assertThat(plan.binding().targets()) + .extracting(NotificationFrozenPlan.FrozenTarget::providerCapabilityReference) + .containsExactly("aws-ses-v2-durable-single-local-sns-v1"); + assertThat(plan.toString()).doesNotContain("secret-ref", "destination-ref"); + } + + @Test + void duplicateUnknownAndChannelDriftFailClosed() { + NotificationProviderDescriptor ses = sesProvider(); + assertThatThrownBy( + () -> + new NotificationBindingCompiler( + List.of(ses, ses), validTemplates(), validRoutes(), validProfiles()) + .compile()) + .isInstanceOf(NotificationCatalogException.class) + .hasMessageContaining("duplicate provider"); + + NotificationRouteDescriptor unknownProfile = + emailRoute( + List.of( + new NotificationRouteDescriptor.Target( + "email-primary", "missing-profile", Optional.empty()))); + assertThatThrownBy( + () -> + new NotificationBindingCompiler( + validProviders(), + validTemplates(), + List.of(unknownProfile), + validProfiles()) + .compile()) + .isInstanceOf(NotificationCatalogException.class) + .hasMessageContaining("unknown runtime profile"); + + NotificationProviderRuntimeProfile channelDrift = + new NotificationProviderRuntimeProfile( + "email-runtime-r1", + "slack-web-api", + "slack-binding-r1", + "slack-web-api-durable-single-local-v1", + "credential-r1", + "secret-ref-r1", + "destination-ref-r1"); + assertThatThrownBy( + () -> + new NotificationBindingCompiler( + validProviders(), + validTemplates(), + List.of(emailRoute()), + List.of(channelDrift)) + .compile()) + .isInstanceOf(NotificationCatalogException.class) + .hasMessageContaining("channel"); + + NotificationProviderDescriptor masquerading = + new NotificationProviderDescriptor( + "unknown-slack-family", + NotificationChannel.SLACK, + false, + List.of( + NotificationProviderCapabilityCard.initial( + "slack-web-api-durable-single-local-v1"))); + assertThatThrownBy( + () -> + new NotificationBindingCompiler( + List.of(masquerading), + validTemplates(), + List.of(slackRoute("slack-runtime-r1", false, false, 1)), + List.of( + new NotificationProviderRuntimeProfile( + "slack-runtime-r1", + "unknown-slack-family", + "slack-binding-r1", + "slack-web-api-durable-single-local-v1", + "credential-r1", + "secret-ref-r1", + "destination-ref-r1"))) + .compile()) + .isInstanceOf(NotificationCatalogException.class) + .hasMessageContaining("provider family"); + } + + @Test + void retainedRouteRevisionsRemainCompiledWhileOneExplicitRevisionIsActive() { + NotificationRouteDescriptor retainedEmail = withRevision(emailRoute(), 2); + NotificationBindingCompiler.CompiledGraph graph = + new NotificationBindingCompiler( + validProviders(), + validTemplates(), + List.of( + retainedEmail, emailRoute(), slackRoute("slack-runtime-r1", false, false, 1)), + validProfiles(), + Map.of( + new NotificationRouteId("security-email"), + 3, + new NotificationRouteId("security-slack"), + 3)) + .compile(); + + assertThat(graph.bindings()) + .extracting( + binding -> binding.route().routeId().value() + "#" + binding.route().routeRevision()) + .containsExactly("security-email#2", "security-email#3", "security-slack#3"); + assertThat( + graph.activeBinding(new NotificationRouteId("security-email")).route().routeRevision()) + .isEqualTo(3); + assertThat(graph.bindings()) + .anyMatch( + binding -> + binding.route().routeId().value().equals("security-email") + && binding.route().routeRevision() == 2); + + assertThatThrownBy( + () -> + new NotificationBindingCompiler( + validProviders(), + validTemplates(), + List.of(retainedEmail, emailRoute()), + validProfiles())) + .isInstanceOf(NotificationCatalogException.class) + .hasMessageContaining("explicit active revision"); + } + + @Test + void compiledArtifactsCannotBeForgedThroughPublicConstructors() { + assertThat(CompiledNotificationBinding.class.getDeclaredConstructors()) + .allMatch(constructor -> !java.lang.reflect.Modifier.isPublic(constructor.getModifiers())); + assertThat(CompiledNotificationBinding.CompiledTarget.class.getDeclaredConstructors()) + .allMatch(constructor -> !java.lang.reflect.Modifier.isPublic(constructor.getModifiers())); + assertThat(NotificationBindingCompiler.CompiledGraph.class.getDeclaredConstructors()) + .allMatch(constructor -> !java.lang.reflect.Modifier.isPublic(constructor.getModifiers())); + } + + @Test + void legacyReceiptUnsafeFallbackBoundsAndCyclesAreRejected() { + NotificationProviderDescriptor legacy = + new NotificationProviderDescriptor( + "slack-webhook", NotificationChannel.SLACK, true, List.of()); + NotificationProviderRuntimeProfile legacyProfile = + new NotificationProviderRuntimeProfile( + "legacy-runtime-r0", + "slack-webhook", + "legacy-binding-r0", + "legacy-r0", + "credential-r0", + "legacy-secret-ref", + "legacy-destination-ref"); + assertThatThrownBy( + () -> + new NotificationBindingCompiler( + List.of(legacy), + validTemplates(), + List.of(slackRoute("legacy-runtime-r0", false, false, 1)), + List.of(legacyProfile)) + .compile()) + .isInstanceOf(NotificationCatalogException.class) + .hasMessageContaining("legacy"); + + assertThatThrownBy( + () -> + new NotificationBindingCompiler( + validProviders(), + validTemplates(), + List.of(slackRoute("slack-runtime-r1", true, false, 1)), + validProfiles()) + .compile()) + .isInstanceOf(NotificationCatalogException.class) + .hasMessageContaining("receipt"); + + assertThatThrownBy( + () -> + new NotificationBindingCompiler( + validProviders(), + validTemplates(), + List.of(slackRoute("slack-runtime-r1", false, true, 1)), + validProfiles()) + .compile()) + .isInstanceOf(NotificationCatalogException.class) + .hasMessageContaining("indeterminate"); + + assertThatThrownBy( + () -> + new NotificationBindingCompiler( + validProviders(), + validTemplates(), + List.of(slackRoute("slack-runtime-r1", false, false, 2)), + validProfiles()) + .compile()) + .isInstanceOf(NotificationCatalogException.class) + .hasMessageContaining("attempt"); + + NotificationRouteDescriptor unsupportedFanOut = + new NotificationRouteDescriptor( + new NotificationRouteId("security-slack"), + 3, + NotificationChannel.SLACK, + NotificationMode.DURABLE_ASYNC, + NotificationAdmissionClass.TRANSACTIONAL, + NotificationRouteStrategy.FAN_OUT_ALL, + new NotificationTemplateRef("security-slack", 1), + false, + false, + 2, + 1, + 0, + 0, + 2, + Duration.ofSeconds(5), + List.of( + new NotificationRouteDescriptor.Target( + "target-a", "slack-runtime-r1", Optional.empty()), + new NotificationRouteDescriptor.Target( + "target-b", "slack-runtime-r1", Optional.empty()))); + assertThatThrownBy( + () -> + new NotificationBindingCompiler( + validProviders(), + validTemplates(), + List.of(unsupportedFanOut), + validProfiles()) + .compile()) + .isInstanceOf(NotificationCatalogException.class) + .hasMessageContaining("strategy"); + + NotificationRouteDescriptor cyclic = + new NotificationRouteDescriptor( + new NotificationRouteId("security-slack"), + 3, + NotificationChannel.SLACK, + NotificationMode.DURABLE_ASYNC, + NotificationAdmissionClass.TRANSACTIONAL, + NotificationRouteStrategy.ORDERED_FALLBACK, + new NotificationTemplateRef("security-slack", 1), + false, + false, + 2, + 1, + 1, + 0, + 2, + Duration.ofSeconds(5), + List.of( + new NotificationRouteDescriptor.Target( + "target-a", "slack-runtime-r1", Optional.of("target-b")), + new NotificationRouteDescriptor.Target( + "target-b", "slack-runtime-r1", Optional.of("target-a")))); + assertThatThrownBy( + () -> + new NotificationBindingCompiler( + validProviders(), validTemplates(), List.of(cyclic), validProfiles()) + .compile()) + .isInstanceOf(NotificationCatalogException.class) + .hasMessageContaining("strategy"); + } + + static NotificationBindingCompiler validCompiler() { + return new NotificationBindingCompiler( + validProviders(), validTemplates(), validRoutes(), validProfiles()); + } + + static List validProviders() { + return List.of( + sesProvider(), + new NotificationProviderDescriptor( + "slack-web-api", + NotificationChannel.SLACK, + false, + List.of( + NotificationProviderCapabilityCard.initial("slack-web-api-inline-single-local-v1"), + NotificationProviderCapabilityCard.initial( + "slack-web-api-durable-single-local-v1")))); + } + + static NotificationProviderDescriptor sesProvider() { + return new NotificationProviderDescriptor( + "aws-ses-v2", + NotificationChannel.EMAIL, + false, + List.of( + NotificationProviderCapabilityCard.initial("aws-ses-v2-durable-single-local-sns-v1"))); + } + + static List validTemplates() { + return List.of( + new NotificationTemplateDescriptor( + new NotificationTemplateRef("security-email", 1), + NotificationChannel.EMAIL, + "email-renderer-r1", + "1".repeat(64), + Set.of(Locale.ENGLISH, Locale.KOREAN), + Locale.ENGLISH, + Set.of("displayName"), + 64_000), + new NotificationTemplateDescriptor( + new NotificationTemplateRef("security-slack", 1), + NotificationChannel.SLACK, + "slack-renderer-r1", + "2".repeat(64), + Set.of(Locale.ENGLISH), + Locale.ENGLISH, + Set.of("displayName"), + 32_000)); + } + + static List validRoutes() { + return List.of(emailRoute(), slackRoute("slack-runtime-r1", false, false, 1)); + } + + static NotificationRouteDescriptor emailRoute() { + return emailRoute( + List.of( + new NotificationRouteDescriptor.Target( + "email-primary", "email-runtime-r1", Optional.empty()))); + } + + static NotificationRouteDescriptor emailRoute(List targets) { + return new NotificationRouteDescriptor( + new NotificationRouteId("security-email"), + 3, + NotificationChannel.EMAIL, + NotificationMode.DURABLE_ASYNC, + NotificationAdmissionClass.SECURITY_CRITICAL, + NotificationRouteStrategy.SINGLE, + new NotificationTemplateRef("security-email", 1), + true, + false, + 1, + 1, + 0, + 1, + 2, + Duration.ofSeconds(5), + targets); + } + + private static NotificationRouteDescriptor withRevision( + NotificationRouteDescriptor route, int revision) { + return new NotificationRouteDescriptor( + route.routeId(), + revision, + route.channel(), + route.mode(), + route.admissionClass(), + route.routeStrategy(), + route.templateRef(), + route.receiptRequired(), + route.fallbackAfterIndeterminate(), + route.maximumTargets(), + route.maximumPhysicalAttempts(), + route.maximumFallbackActivations(), + route.maximumReconcileCalls(), + route.maximumTotalProviderCalls(), + route.perAttemptDeadline(), + route.targets()); + } + + static NotificationRouteDescriptor slackRoute( + String profileId, + boolean receiptRequired, + boolean fallbackAfterIndeterminate, + int maximumAttempts) { + return new NotificationRouteDescriptor( + new NotificationRouteId("security-slack"), + 3, + NotificationChannel.SLACK, + NotificationMode.DURABLE_ASYNC, + NotificationAdmissionClass.TRANSACTIONAL, + NotificationRouteStrategy.SINGLE, + new NotificationTemplateRef("security-slack", 1), + receiptRequired, + fallbackAfterIndeterminate, + 1, + maximumAttempts, + 0, + 0, + maximumAttempts, + Duration.ofSeconds(5), + List.of( + new NotificationRouteDescriptor.Target("slack-primary", profileId, Optional.empty()))); + } + + static List validProfiles() { + return List.of( + new NotificationProviderRuntimeProfile( + "email-runtime-r1", + "aws-ses-v2", + "ses-binding-r1", + "aws-ses-v2-durable-single-local-sns-v1", + "credential-r1", + "secret-ref-r1", + "destination-ref-r1"), + new NotificationProviderRuntimeProfile( + "slack-runtime-r1", + "slack-web-api", + "slack-binding-r1", + "slack-web-api-durable-single-local-v1", + "credential-r1", + "secret-ref-r1", + "destination-ref-r1")); + } + + private static NotificationIntentDraft emailDraft() { + NotificationKindPolicy policy = + new NotificationKindPolicy( + new NotificationKindId("security-alert"), + NotificationChannel.EMAIL, + new NotificationRouteId("security-email"), + new NotificationTemplateRef("security-email", 1), + NotificationMode.DURABLE_ASYNC, + NotificationAdmissionClass.SECURITY_CRITICAL, + NotificationRouteStrategy.SINGLE, + ConsentCheckMode.RECHECK_BEFORE_EACH_DELIVERY, + 17, + 1, + 1, + 0, + 1, + 2, + Duration.ofMinutes(10)); + return new NotificationIntentDraft( + new NotificationIntentId("intent-42"), + policy, + Locale.KOREAN, + new EmailRecipientReference("recipient-ref-42"), + new NotificationTemplateParameters( + Map.of("displayName", new NotificationTemplateValue.SafeText("Ada"))), + "idempotency-scope-42", + "operation-42", + Optional.empty(), + "correlation-42", + Optional.empty(), + Instant.parse("2026-07-29T00:00:00Z"), + Instant.parse("2026-07-29T00:10:00Z")); + } +} diff --git a/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/catalog/NotificationCanonicalRouteCatalogTest.java b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/catalog/NotificationCanonicalRouteCatalogTest.java new file mode 100644 index 00000000..d0f19921 --- /dev/null +++ b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/catalog/NotificationCanonicalRouteCatalogTest.java @@ -0,0 +1,54 @@ +package dev.caskeleton.adapter.outbound.notification.catalog; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.application.notification.NotificationCanonicalWriterRouteSet; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class NotificationCanonicalRouteCatalogTest { + + @Test + void retainedKeyOnlyCatalogMapsOnlyWithExactReviewedGenerationConfig() { + NotificationCanonicalRouteCatalog catalog = + NotificationCanonicalRouteCatalog.fromCompiledGraph( + NotificationBindingCompilerTest.validCompiler().compile()); + List keys = catalog.routes(); + Map generations = + Map.of(keys.get(0), 7L, keys.get(1), 3L); + + NotificationCanonicalWriterRouteSet application = catalog.toApplication(generations); + + assertThat(application.routes()) + .extracting(route -> route.routeId().value()) + .containsExactly("security-email", "security-slack"); + assertThat(catalog.digest()) + .isEqualTo("c5bad4ab5dffa98fb73e14629e9b62dd6d62ec4c562c526b67680937484559e1"); + assertThat( + NotificationCanonicalRouteCatalog.fromCompiledGraph( + NotificationBindingCompilerTest.validCompiler().compile())) + .isEqualTo(catalog); + } + + @Test + void generationConfigAndRouteKeysMustMatchExactly() { + NotificationCanonicalRouteCatalog catalog = + NotificationCanonicalRouteCatalog.fromCompiledGraph( + NotificationBindingCompilerTest.validCompiler().compile()); + NotificationCanonicalRouteCatalog.RouteKey first = catalog.routes().getFirst(); + + assertThatThrownBy(() -> catalog.toApplication(Map.of(first, 7L))) + .isInstanceOf(NotificationCatalogException.class) + .hasMessageContaining("exactly"); + assertThatThrownBy( + () -> + NotificationCanonicalRouteCatalog.fromRoutes( + List.of( + NotificationBindingCompilerTest.emailRoute(), + NotificationBindingCompilerTest.emailRoute()))) + .isInstanceOf(NotificationCatalogException.class) + .hasMessageContaining("duplicate"); + } +} diff --git a/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/catalog/NotificationCutoverRouteCatalogTest.java b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/catalog/NotificationCutoverRouteCatalogTest.java new file mode 100644 index 00000000..e162f1a8 --- /dev/null +++ b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/catalog/NotificationCutoverRouteCatalogTest.java @@ -0,0 +1,123 @@ +package dev.caskeleton.adapter.outbound.notification.catalog; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.application.notification.NotificationWriterRouteSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.Test; + +class NotificationCutoverRouteCatalogTest { + + @Test + void preDecoratorCannotChangeCanonicalKeysAndDigestCoversCompleteProofRegistry() { + NotificationCanonicalRouteCatalog canonical = + NotificationCanonicalRouteCatalog.fromCompiledGraph( + NotificationBindingCompilerTest.validCompiler().compile()); + NotificationCutoverRouteCatalog catalog = + new NotificationCutoverRouteCatalog(canonical, validCutoverRoutes(canonical)); + Map generations = + Map.of(canonical.routes().get(0), 7L, canonical.routes().get(1), 3L); + + NotificationWriterRouteSet application = catalog.toApplication(generations); + + assertThat(application.routeProfiles()).hasSize(2); + assertThat(application.routeProfiles().getFirst().transportProfiles()) + .extracting(NotificationWriterRouteSet.TransportProfile::profileId) + .containsExactly("legacy-http-v1", "legacy-http-v2"); + assertThat(catalog.digest()) + .isEqualTo("62b2da4489adbec34cc124da093b1e95e8d0c4999ca3f3e824b2859592474faa"); + + NotificationCutoverRouteCatalog changed = + new NotificationCutoverRouteCatalog( + canonical, + List.of( + new NotificationCutoverRouteCatalog.CutoverRoute( + canonical.routes().get(0), + Optional.of("legacy-email"), + List.of( + new NotificationCutoverRouteCatalog.LegacyTransportProfile( + "legacy-http-v1", + NotificationWriterRouteSet.ProofClass.QUIESCENCE_REQUIRED, + "evidence-r2", + true, + false))), + validCutoverRoutes(canonical).get(1))); + assertThat(changed.digest()).isNotEqualTo(catalog.digest()); + } + + @Test + void missingKeysAliasesProfilesAndUnreviewedHardBoundsFailClosed() { + NotificationCanonicalRouteCatalog canonical = + NotificationCanonicalRouteCatalog.fromCompiledGraph( + NotificationBindingCompilerTest.validCompiler().compile()); + + assertThatThrownBy( + () -> + new NotificationCutoverRouteCatalog( + canonical, List.of(validCutoverRoutes(canonical).getFirst()))) + .isInstanceOf(NotificationCatalogException.class) + .hasMessageContaining("exactly"); + + List duplicateAlias = + List.of( + validCutoverRoutes(canonical).get(0), + new NotificationCutoverRouteCatalog.CutoverRoute( + canonical.routes().get(1), + Optional.of("legacy-email"), + List.of( + new NotificationCutoverRouteCatalog.LegacyTransportProfile( + "legacy-slack-v1", + NotificationWriterRouteSet.ProofClass.QUIESCENCE_REQUIRED, + "evidence-r1", + true, + false)))); + assertThatThrownBy(() -> new NotificationCutoverRouteCatalog(canonical, duplicateAlias)) + .isInstanceOf(NotificationCatalogException.class) + .hasMessageContaining("alias"); + + assertThatThrownBy( + () -> + new NotificationCutoverRouteCatalog.LegacyTransportProfile( + "legacy-hard-v1", + NotificationWriterRouteSet.ProofClass.HARD_BOUND_PROVEN, + "hard-bound-r1", + true, + false)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("reviewed"); + } + + static List validCutoverRoutes( + NotificationCanonicalRouteCatalog canonical) { + return List.of( + new NotificationCutoverRouteCatalog.CutoverRoute( + canonical.routes().get(0), + Optional.of("legacy-email"), + List.of( + new NotificationCutoverRouteCatalog.LegacyTransportProfile( + "legacy-http-v1", + NotificationWriterRouteSet.ProofClass.QUIESCENCE_REQUIRED, + "evidence-r1", + false, + false), + new NotificationCutoverRouteCatalog.LegacyTransportProfile( + "legacy-http-v2", + NotificationWriterRouteSet.ProofClass.QUIESCENCE_REQUIRED, + "evidence-r2", + true, + false))), + new NotificationCutoverRouteCatalog.CutoverRoute( + canonical.routes().get(1), + Optional.of("legacy-slack"), + List.of( + new NotificationCutoverRouteCatalog.LegacyTransportProfile( + "legacy-slack-v1", + NotificationWriterRouteSet.ProofClass.HARD_BOUND_PROVEN, + "hard-bound-r2", + true, + true)))); + } +} diff --git a/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/provider/InlineNotificationAttemptAdapterTest.java b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/provider/InlineNotificationAttemptAdapterTest.java new file mode 100644 index 00000000..a2bff37f --- /dev/null +++ b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/provider/InlineNotificationAttemptAdapterTest.java @@ -0,0 +1,55 @@ +package dev.caskeleton.adapter.outbound.notification.provider; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.notification.template.RenderedNotification; +import dev.caskeleton.application.notification.NotificationMode; +import dev.caskeleton.application.notification.NotificationRequestResult; +import java.time.Instant; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; + +class InlineNotificationAttemptAdapterTest { + + @Test + void inlinePlanInvokesOneClientPerFrozenTargetAndDurablePlanIsRejected() { + AtomicInteger sends = new AtomicInteger(); + NotificationProviderAttemptClient client = + new NotificationProviderAttemptClient() { + @Override + public PreparedNotificationAttempt prepare( + RenderedNotification rendered, + dev.caskeleton.adapter.outbound.notification.catalog.CompiledNotificationBinding + .CompiledTarget + target, + NotificationAttemptContext context) { + return new PreparedNotificationAttempt(rendered, target, context, "c".repeat(64)); + } + + @Override + public ClientAttemptResult sendOneAuthorizedAttempt( + PreparedNotificationAttempt prepared, + String executionToken, + NotificationSecretMaterialHandle secret, + Instant absoluteDeadline) { + sends.incrementAndGet(); + return new ClientAttemptResult.Accepted( + new ProviderMessageReference("provider-message-42")); + } + }; + InlineNotificationAttemptAdapter adapter = + new InlineNotificationAttemptAdapter( + ProviderTestFixtures.attemptAdapter(client, NotificationMode.BEST_EFFORT_INLINE)); + + NotificationRequestResult.InlineCompleted result = + adapter.attempt(ProviderTestFixtures.plan(NotificationMode.BEST_EFFORT_INLINE)); + + assertThat(sends).hasValue(1); + assertThat(result.outcomes()).hasSize(1); + assertThatThrownBy( + () -> adapter.attempt(ProviderTestFixtures.plan(NotificationMode.DURABLE_ASYNC))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("inline"); + } +} diff --git a/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/provider/NotificationAdmissionReadinessAdapterTest.java b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/provider/NotificationAdmissionReadinessAdapterTest.java new file mode 100644 index 00000000..6e46da9d --- /dev/null +++ b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/provider/NotificationAdmissionReadinessAdapterTest.java @@ -0,0 +1,195 @@ +package dev.caskeleton.adapter.outbound.notification.provider; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.notification.catalog.NotificationProviderRuntimeProfile; +import dev.caskeleton.application.notification.NotificationAdmissionReadinessPort; +import dev.caskeleton.application.notification.NotificationFaultScope; +import dev.caskeleton.application.notification.NotificationReasonCode; +import dev.caskeleton.application.notification.NotificationRouteId; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; + +class NotificationAdmissionReadinessAdapterTest { + + @Test + void internalNonSecretProbeMustBeFreshAndExactBeforeDelegatedResume() { + AtomicBoolean resumed = new AtomicBoolean(); + NotificationAdmissionReadinessPort state = + new NotificationAdmissionReadinessPort() { + @Override + public ParkResult park(ParkRequest request) { + return ParkResult.PARKED; + } + + @Override + public ResumeResult resume( + ResumeRequest request, ReadinessProbe probe, Instant resumedAt) { + resumed.set(true); + return new ResumeResult( + ResumeStatus.RESUMED, request.expectedGeneration() + 1, 1, 0, 0, 0, 0, 0); + } + }; + NotificationProviderRuntimeProfile profile = profile("credential-r1"); + NotificationAdmissionReadinessAdapter adapter = + new NotificationAdmissionReadinessAdapter( + state, + request -> profile, + (selected, deadline) -> + new NotificationProviderReadinessSnapshot( + selected.profileId(), + selected.bindingRevision(), + selected.capabilityCardId(), + selected.credentialGeneration(), + true, + new NotificationReasonCode("READINESS_CONFIRMED"), + ProviderTestFixtures.NOW, + ProviderTestFixtures.NOW.plusSeconds(10)), + Clock.fixed(ProviderTestFixtures.NOW, ZoneOffset.UTC)); + NotificationAdmissionReadinessPort.ResumeRequest request = request(); + + NotificationAdmissionReadinessPort.ReadinessProbe probe = adapter.probe(request); + var result = adapter.resume(request, probe, ProviderTestFixtures.NOW); + + assertThat(probe.ready()).isTrue(); + assertThat(result.status()).isEqualTo(NotificationAdmissionReadinessPort.ResumeStatus.RESUMED); + assertThat(resumed).isTrue(); + assertThat(adapter.toString()).doesNotContain("secret-ref-r1", "destination-ref-r1"); + assertThatThrownBy( + () -> adapter.resume(request, probe, ProviderTestFixtures.NOW.plusSeconds(2))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("fresh"); + } + + @Test + void probeFailuresTokenSubstitutionAndCredentialRotationFailClosed() { + AtomicReference current = + new AtomicReference<>(profile("credential-r1")); + NotificationAdmissionReadinessAdapter adapter = + new NotificationAdmissionReadinessAdapter( + request -> NotificationAdmissionReadinessPort.ParkResult.PARKED, + request -> current.get(), + (selected, deadline) -> + new NotificationProviderReadinessSnapshot( + selected.profileId(), + selected.bindingRevision(), + selected.capabilityCardId(), + selected.credentialGeneration(), + true, + new NotificationReasonCode("READINESS_CONFIRMED"), + ProviderTestFixtures.NOW, + ProviderTestFixtures.NOW.plusSeconds(10)), + Clock.fixed(ProviderTestFixtures.NOW, ZoneOffset.UTC)); + NotificationAdmissionReadinessPort.ResumeRequest original = request(); + NotificationAdmissionReadinessPort.ReadinessProbe ready = adapter.probe(original); + NotificationAdmissionReadinessPort.ResumeRequest substituted = + new NotificationAdmissionReadinessPort.ResumeRequest( + original.operationToken(), + original.routeId(), + original.policyRevision(), + original.faultScope(), + original.scopeReference(), + original.expectedGeneration(), + original.maximumParkedLegs(), + "different-operator", + original.reasonCode()); + + NotificationAdmissionReadinessPort.ReadinessProbe conflict = adapter.probe(substituted); + + assertThat(conflict.ready()).isFalse(); + assertThat(conflict.reasonCode().value()).isEqualTo("READINESS_TOKEN_CONFLICT"); + current.set(profile("credential-r2")); + assertThatThrownBy( + () -> adapter.resume(original, ready, ProviderTestFixtures.NOW.plusSeconds(1))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("generation"); + + NotificationAdmissionReadinessAdapter failing = + new NotificationAdmissionReadinessAdapter( + request -> NotificationAdmissionReadinessPort.ParkResult.PARKED, + request -> { + throw new IllegalStateException("secret reference must not escape"); + }, + (selected, deadline) -> { + throw new AssertionError("probe must not run"); + }, + Clock.fixed(ProviderTestFixtures.NOW, ZoneOffset.UTC)); + NotificationAdmissionReadinessPort.ReadinessProbe failed = failing.probe(request()); + assertThat(failed.ready()).isFalse(); + assertThat(failed.reasonCode().value()).isEqualTo("READINESS_PROBE_FAILED"); + assertThat(failed.toString()).doesNotContain("secret reference"); + } + + @Test + void readinessObservationAfterProbeIoIsValidatedAgainstPostCallTime() { + AtomicReference currentTime = new AtomicReference<>(ProviderTestFixtures.NOW); + Clock advancingClock = + new Clock() { + @Override + public ZoneId getZone() { + return ZoneOffset.UTC; + } + + @Override + public Clock withZone(ZoneId zone) { + return this; + } + + @Override + public Instant instant() { + return currentTime.get(); + } + }; + NotificationAdmissionReadinessAdapter adapter = + new NotificationAdmissionReadinessAdapter( + request -> NotificationAdmissionReadinessPort.ParkResult.PARKED, + request -> profile("credential-r1"), + (selected, deadline) -> { + currentTime.set(ProviderTestFixtures.NOW.plusSeconds(1)); + return new NotificationProviderReadinessSnapshot( + selected.profileId(), + selected.bindingRevision(), + selected.capabilityCardId(), + selected.credentialGeneration(), + true, + new NotificationReasonCode("READINESS_CONFIRMED"), + currentTime.get(), + currentTime.get().plusSeconds(10)); + }, + advancingClock); + + NotificationAdmissionReadinessPort.ReadinessProbe probe = adapter.probe(request()); + + assertThat(probe.ready()).isTrue(); + } + + private static NotificationProviderRuntimeProfile profile(String credentialGeneration) { + return new NotificationProviderRuntimeProfile( + "slack-runtime-r1", + "slack-web-api", + "slack-binding-r1", + "slack-web-api-durable-single-local-v1", + credentialGeneration, + "secret-ref-r1", + "destination-ref-r1"); + } + + private static NotificationAdmissionReadinessPort.ResumeRequest request() { + return new NotificationAdmissionReadinessPort.ResumeRequest( + "resume-operation-42", + new NotificationRouteId("security-slack"), + 3, + NotificationFaultScope.PROVIDER_BINDING, + "provider-binding-scope-42", + 7, + 10, + "operator-42", + new NotificationReasonCode("OPERATOR_RESUME")); + } +} diff --git a/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/provider/NotificationProviderAttemptContractTest.java b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/provider/NotificationProviderAttemptContractTest.java new file mode 100644 index 00000000..a2558bf4 --- /dev/null +++ b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/provider/NotificationProviderAttemptContractTest.java @@ -0,0 +1,402 @@ +package dev.caskeleton.adapter.outbound.notification.provider; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.notification.catalog.NotificationBindingCompiler; +import dev.caskeleton.adapter.outbound.notification.catalog.NotificationPlanAdapter; +import dev.caskeleton.adapter.outbound.notification.catalog.NotificationProviderCapabilityCard; +import dev.caskeleton.adapter.outbound.notification.catalog.NotificationProviderDescriptor; +import dev.caskeleton.adapter.outbound.notification.catalog.NotificationProviderRuntimeProfile; +import dev.caskeleton.adapter.outbound.notification.catalog.NotificationRouteDescriptor; +import dev.caskeleton.adapter.outbound.notification.catalog.NotificationTemplateDescriptor; +import dev.caskeleton.adapter.outbound.notification.template.RenderedNotification; +import dev.caskeleton.application.notification.ConsentCheckMode; +import dev.caskeleton.application.notification.NotificationAdmissionClass; +import dev.caskeleton.application.notification.NotificationChannel; +import dev.caskeleton.application.notification.NotificationDeliveryId; +import dev.caskeleton.application.notification.NotificationDeliveryStorePort; +import dev.caskeleton.application.notification.NotificationFrozenPlan; +import dev.caskeleton.application.notification.NotificationIntentDraft; +import dev.caskeleton.application.notification.NotificationIntentId; +import dev.caskeleton.application.notification.NotificationKindId; +import dev.caskeleton.application.notification.NotificationKindPolicy; +import dev.caskeleton.application.notification.NotificationMode; +import dev.caskeleton.application.notification.NotificationPlanningResult; +import dev.caskeleton.application.notification.NotificationProviderAttemptPort; +import dev.caskeleton.application.notification.NotificationReasonCode; +import dev.caskeleton.application.notification.NotificationRouteId; +import dev.caskeleton.application.notification.NotificationRouteStrategy; +import dev.caskeleton.application.notification.NotificationTemplateParameters; +import dev.caskeleton.application.notification.NotificationTemplateRef; +import dev.caskeleton.application.notification.NotificationTemplateValue; +import dev.caskeleton.application.notification.RetryDisposition; +import dev.caskeleton.application.notification.SlackAudienceReference; +import dev.caskeleton.application.notification.SubmissionCertainty; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; + +class NotificationProviderAttemptContractTest { + + @Test + void oneAuthorizationPreparesWithoutWireIoAndInvokesClientExactlyOnceWithinAbsoluteDeadline() { + AtomicInteger prepareCalls = new AtomicInteger(); + AtomicInteger wireCalls = new AtomicInteger(); + AtomicReference observedDeadline = new AtomicReference<>(); + NotificationProviderAttemptClient client = + new NotificationProviderAttemptClient() { + @Override + public PreparedNotificationAttempt prepare( + RenderedNotification rendered, + dev.caskeleton.adapter.outbound.notification.catalog.CompiledNotificationBinding + .CompiledTarget + target, + NotificationAttemptContext context) { + prepareCalls.incrementAndGet(); + assertThat(wireCalls).hasValue(0); + return new PreparedNotificationAttempt(rendered, target, context, "c".repeat(64)); + } + + @Override + public ClientAttemptResult sendOneAuthorizedAttempt( + PreparedNotificationAttempt prepared, + String executionToken, + NotificationSecretMaterialHandle secret, + Instant absoluteDeadline) { + wireCalls.incrementAndGet(); + observedDeadline.set(absoluteDeadline); + return new ClientAttemptResult.Accepted( + new ProviderMessageReference("provider-message-42")); + } + }; + Instant now = ProviderTestFixtures.NOW; + NotificationDeliveryStorePort.AuthorizedAttempt authorized = + ProviderTestFixtures.authorized(NotificationMode.DURABLE_ASYNC, now.plusSeconds(3)); + NotificationProviderAttemptPort adapter = + ProviderTestFixtures.attemptAdapter(client, NotificationMode.DURABLE_ASYNC); + + var outcome = adapter.attempt(authorized); + + assertThat(prepareCalls).hasValue(1); + assertThat(wireCalls).hasValue(1); + assertThat(observedDeadline.get()).isEqualTo(now.plusSeconds(3)); + assertThat(outcome.submissionCertainty()).isEqualTo(SubmissionCertainty.PROVIDER_ACCEPTED); + assertThat(outcome.providerMessageReference()).contains("provider-message-42"); + assertThat(outcome.attemptCorrelationReference()).isNotEqualTo(authorized.executionToken()); + } + + @Test + void preWireValidationAndPossibleWriteFailuresHaveDifferentCertaintyAndSdkErrorsNeverEscape() { + NotificationProviderAttemptClient invalidBeforeWire = + new NotificationProviderAttemptClient() { + @Override + public PreparedNotificationAttempt prepare( + RenderedNotification rendered, + dev.caskeleton.adapter.outbound.notification.catalog.CompiledNotificationBinding + .CompiledTarget + target, + NotificationAttemptContext context) { + throw new NotificationProviderAttemptClient.PreWireDeliveryRejectedException(); + } + + @Override + public ClientAttemptResult sendOneAuthorizedAttempt( + PreparedNotificationAttempt prepared, + String executionToken, + NotificationSecretMaterialHandle secret, + Instant absoluteDeadline) { + throw new AssertionError("wire call must not run"); + } + }; + NotificationProviderAttemptClient responseLost = + new NotificationProviderAttemptClient() { + @Override + public PreparedNotificationAttempt prepare( + RenderedNotification rendered, + dev.caskeleton.adapter.outbound.notification.catalog.CompiledNotificationBinding + .CompiledTarget + target, + NotificationAttemptContext context) { + return new PreparedNotificationAttempt(rendered, target, context, "c".repeat(64)); + } + + @Override + public ClientAttemptResult sendOneAuthorizedAttempt( + PreparedNotificationAttempt prepared, + String executionToken, + NotificationSecretMaterialHandle secret, + Instant absoluteDeadline) { + throw new IllegalStateException("SDK body and token must not escape"); + } + }; + + var preWire = + ProviderTestFixtures.attemptAdapter(invalidBeforeWire, NotificationMode.DURABLE_ASYNC) + .attempt( + ProviderTestFixtures.authorized( + NotificationMode.DURABLE_ASYNC, ProviderTestFixtures.NOW.plusSeconds(3))); + var possibleWrite = + ProviderTestFixtures.attemptAdapter(responseLost, NotificationMode.DURABLE_ASYNC) + .attempt( + ProviderTestFixtures.authorized( + NotificationMode.DURABLE_ASYNC, ProviderTestFixtures.NOW.plusSeconds(3))); + + assertThat(preWire.submissionCertainty()).isEqualTo(SubmissionCertainty.DEFINITELY_NOT_APPLIED); + assertThat(preWire.retryDisposition()).isEqualTo(RetryDisposition.TERMINAL); + assertThat(possibleWrite.submissionCertainty()).isEqualTo(SubmissionCertainty.INDETERMINATE); + assertThat(possibleWrite.toString()).doesNotContain("SDK body", "token must not escape"); + } + + @Test + void preparedAttemptMustRemainBoundToTheAuthorizedRenderedTargetAndContext() { + AtomicInteger wireCalls = new AtomicInteger(); + NotificationProviderAttemptClient misbindingClient = + new NotificationProviderAttemptClient() { + @Override + public PreparedNotificationAttempt prepare( + RenderedNotification rendered, + dev.caskeleton.adapter.outbound.notification.catalog.CompiledNotificationBinding + .CompiledTarget + target, + NotificationAttemptContext context) { + NotificationAttemptContext differentContext = + new NotificationAttemptContext( + context.deliveryId(), + context.attemptId(), + context.targetOrdinal(), + context.correlationId(), + Optional.of( + new NotificationAttemptContext.ProviderClientOperationKey( + "different-operation-key")), + context.absoluteDeadline()); + return new PreparedNotificationAttempt( + rendered, target, differentContext, "c".repeat(64)); + } + + @Override + public ClientAttemptResult sendOneAuthorizedAttempt( + PreparedNotificationAttempt prepared, + String executionToken, + NotificationSecretMaterialHandle secret, + Instant absoluteDeadline) { + wireCalls.incrementAndGet(); + throw new AssertionError("misbound prepared attempt must not reach the wire"); + } + }; + + var outcome = + ProviderTestFixtures.attemptAdapter(misbindingClient, NotificationMode.DURABLE_ASYNC) + .attempt( + ProviderTestFixtures.authorized( + NotificationMode.DURABLE_ASYNC, ProviderTestFixtures.NOW.plusSeconds(3))); + + assertThat(wireCalls).hasValue(0); + assertThat(outcome.submissionCertainty()).isEqualTo(SubmissionCertainty.DEFINITELY_NOT_APPLIED); + assertThat(outcome.retryDisposition()).isEqualTo(RetryDisposition.PARK_BINDING); + assertThat(outcome.reasonCode().value()).isEqualTo("PROVIDER_PREPARED_ATTEMPT_MISMATCH"); + } +} + +final class ProviderTestFixtures { + + static final Instant NOW = Instant.parse("2026-07-29T00:00:00Z"); + + private ProviderTestFixtures() {} + + static NotificationProviderAttemptAdapter attemptAdapter( + NotificationProviderAttemptClient client, NotificationMode mode) { + NotificationBindingCompiler.CompiledGraph graph = graph(mode); + return new NotificationProviderAttemptAdapter( + graph, + Map.of( + "slack-renderer-r1", + plan -> + new RenderedNotification.Slack( + plan.selectedLocale(), + List.of(RenderedNotification.SlackBlock.mrkdwn("safe message")), + 3, + "safe message".getBytes(java.nio.charset.StandardCharsets.UTF_8).length)), + Map.of(cardId(mode), client), + (profile, deadline) -> + NotificationProviderRateAdmission.Decision.admitted( + new NotificationReasonCode("RATE_ADMISSION_GRANTED")), + profile -> + NotificationSecretMaterialHandle.fromBytes( + profile.credentialGeneration(), + "provider-test-secret-material-32" + .getBytes(java.nio.charset.StandardCharsets.UTF_8)), + Clock.fixed(NOW, ZoneOffset.UTC)); + } + + static NotificationDeliveryStorePort.AuthorizedAttempt authorized( + NotificationMode mode, Instant deadline) { + NotificationFrozenPlan plan = plan(mode); + return new NotificationDeliveryStorePort.AuthorizedAttempt( + new NotificationDeliveryId("delivery-42"), + new dev.caskeleton.application.notification.NotificationAttemptId("attempt-42"), + plan, + 0, + "claim-token-42", + "execution-token-42", + 3, + 7, + "provider-binding-scope-42", + deadline); + } + + static NotificationFrozenPlan plan(NotificationMode mode) { + NotificationKindPolicy policy = + new NotificationKindPolicy( + new NotificationKindId("security-slack"), + NotificationChannel.SLACK, + new NotificationRouteId("security-slack"), + new NotificationTemplateRef("security-slack", 1), + mode, + NotificationAdmissionClass.TRANSACTIONAL, + NotificationRouteStrategy.SINGLE, + ConsentCheckMode.SNAPSHOT_AT_APPEND, + 3, + 1, + 1, + 0, + 0, + 1, + Duration.ofMinutes(10)); + NotificationIntentDraft draft = + new NotificationIntentDraft( + new NotificationIntentId("intent-42"), + policy, + Locale.ENGLISH, + new SlackAudienceReference("workspace-ref-42", "audience-ref-42"), + new NotificationTemplateParameters( + Map.of("displayName", new NotificationTemplateValue.SafeText("Ada"))), + "scope-42", + "operation-42", + Optional.empty(), + "correlation-42", + Optional.empty(), + NOW, + NOW.plusSeconds(60)); + NotificationPlanningResult result = new NotificationPlanAdapter(graph(mode)).plan(draft); + return ((NotificationPlanningResult.Planned) result).plan(); + } + + static NotificationBindingCompiler.CompiledGraph graph(NotificationMode mode) { + String cardId = cardId(mode); + NotificationProviderDescriptor provider = + new NotificationProviderDescriptor( + "slack-web-api", + NotificationChannel.SLACK, + false, + List.of(NotificationProviderCapabilityCard.initial(cardId))); + NotificationTemplateDescriptor template = + new NotificationTemplateDescriptor( + new NotificationTemplateRef("security-slack", 1), + NotificationChannel.SLACK, + "slack-renderer-r1", + "b".repeat(64), + Set.of(Locale.ENGLISH), + Locale.ENGLISH, + Set.of("displayName"), + 32_000); + NotificationRouteDescriptor route = + new NotificationRouteDescriptor( + new NotificationRouteId("security-slack"), + 3, + NotificationChannel.SLACK, + mode, + NotificationAdmissionClass.TRANSACTIONAL, + NotificationRouteStrategy.SINGLE, + template.templateRef(), + false, + false, + 1, + 1, + 0, + 0, + 1, + Duration.ofSeconds(5), + List.of( + new NotificationRouteDescriptor.Target( + "slack-primary", "slack-runtime-r1", Optional.empty()))); + NotificationProviderRuntimeProfile profile = + new NotificationProviderRuntimeProfile( + "slack-runtime-r1", + "slack-web-api", + "slack-binding-r1", + cardId, + "credential-r1", + "secret-ref-r1", + "destination-ref-r1"); + return new NotificationBindingCompiler( + List.of(provider), List.of(template), List.of(route), List.of(profile)) + .compile(); + } + + static NotificationBindingCompiler.CompiledGraph emailGraph() { + String cardId = "aws-ses-v2-durable-single-local-sns-v1"; + NotificationProviderDescriptor provider = + new NotificationProviderDescriptor( + "aws-ses-v2", + NotificationChannel.EMAIL, + false, + List.of(NotificationProviderCapabilityCard.initial(cardId))); + NotificationTemplateDescriptor template = + new NotificationTemplateDescriptor( + new NotificationTemplateRef("security-email", 1), + NotificationChannel.EMAIL, + "email-renderer-r1", + "e".repeat(64), + Set.of(Locale.ENGLISH), + Locale.ENGLISH, + Set.of("displayName"), + 64_000); + NotificationRouteDescriptor route = + new NotificationRouteDescriptor( + new NotificationRouteId("security-email"), + 3, + NotificationChannel.EMAIL, + NotificationMode.DURABLE_ASYNC, + NotificationAdmissionClass.SECURITY_CRITICAL, + NotificationRouteStrategy.SINGLE, + template.templateRef(), + true, + false, + 1, + 1, + 0, + 1, + 2, + Duration.ofSeconds(5), + List.of( + new NotificationRouteDescriptor.Target( + "email-primary", "email-runtime-r1", Optional.empty()))); + NotificationProviderRuntimeProfile profile = + new NotificationProviderRuntimeProfile( + "email-runtime-r1", + "aws-ses-v2", + "ses-binding-r1", + cardId, + "credential-r1", + "secret-ref-r1", + "destination-ref-r1"); + return new NotificationBindingCompiler( + List.of(provider), List.of(template), List.of(route), List.of(profile)) + .compile(); + } + + static String cardId(NotificationMode mode) { + return mode == NotificationMode.BEST_EFFORT_INLINE + ? "slack-web-api-inline-single-local-v1" + : "slack-web-api-durable-single-local-v1"; + } +} diff --git a/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/provider/NotificationReconciliationAdapterTest.java b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/provider/NotificationReconciliationAdapterTest.java new file mode 100644 index 00000000..0fdad560 --- /dev/null +++ b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/provider/NotificationReconciliationAdapterTest.java @@ -0,0 +1,194 @@ +package dev.caskeleton.adapter.outbound.notification.provider; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.application.notification.NotificationDeliveryId; +import dev.caskeleton.application.notification.NotificationDeliveryStorePort; +import dev.caskeleton.application.notification.NotificationReasonCode; +import dev.caskeleton.application.notification.SubmissionCertainty; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; + +class NotificationReconciliationAdapterTest { + + @Test + void expiredLookupRemainsIndeterminateAndDoesNotCallProvider() { + NotificationDeliveryStorePort.ReconciliationClaim expired = + claim( + "correlation-42", + NotificationDeliveryStorePort.ReconciliationLookupKind.PRE_SEND_CORRELATION, + ProviderTestFixtures.NOW); + NotificationReconciliationAdapter adapter = + new NotificationReconciliationAdapter( + ignored -> { + throw new AssertionError("expired reconciliation must not resolve a client"); + }, + ignored -> { + throw new AssertionError("expired reconciliation must not acquire a secret"); + }, + Clock.fixed(ProviderTestFixtures.NOW, ZoneOffset.UTC)); + + var result = adapter.reconcile(expired); + + assertThat(result.submissionCertainty()).isEqualTo(SubmissionCertainty.INDETERMINATE); + assertThat(result.reasonCode().value()).isEqualTo("RECONCILIATION_DEADLINE_EXPIRED"); + } + + @Test + void approvedPreSendCorrelationLookupIsBoundedAndClientFailuresBecomeIndeterminate() { + AtomicReference mode = new AtomicReference<>(); + AtomicReference clientResult = + new AtomicReference<>( + new NotificationProviderAttemptClient.ClientReconciliationResult( + SubmissionCertainty.PROVIDER_ACCEPTED, + new NotificationReasonCode("PROVIDER_ACCEPTED"))); + NotificationProviderAttemptClient client = + new NotificationProviderAttemptClient() { + @Override + public PreparedNotificationAttempt prepare( + dev.caskeleton.adapter.outbound.notification.template.RenderedNotification rendered, + dev.caskeleton.adapter.outbound.notification.catalog.CompiledNotificationBinding + .CompiledTarget + target, + NotificationAttemptContext context) { + throw new UnsupportedOperationException(); + } + + @Override + public ClientAttemptResult sendOneAuthorizedAttempt( + PreparedNotificationAttempt prepared, + String executionToken, + NotificationSecretMaterialHandle secret, + Instant absoluteDeadline) { + throw new UnsupportedOperationException(); + } + + @Override + public ClientReconciliationResult reconcile( + ReconciliationReference reference, + ReconciliationLookupMode lookupMode, + NotificationSecretMaterialHandle secret, + Instant absoluteDeadline) { + mode.set(lookupMode); + return clientResult.get(); + } + }; + NotificationDeliveryStorePort.ReconciliationClaim claim = + claim( + "attempt-correlation-42", + NotificationDeliveryStorePort.ReconciliationLookupKind.PRE_SEND_CORRELATION, + ProviderTestFixtures.NOW.plusSeconds(3)); + NotificationReconciliationAdapter adapter = + new NotificationReconciliationAdapter( + ignored -> binding(client), + profile -> + NotificationSecretMaterialHandle.fromBytes( + profile.credentialGeneration(), new byte[] {1}), + Clock.fixed(ProviderTestFixtures.NOW, ZoneOffset.UTC)); + + var result = adapter.reconcile(claim); + + assertThat(mode).hasValue(ReconciliationLookupMode.PRE_SEND_CORRELATION); + assertThat(result.submissionCertainty()).isEqualTo(SubmissionCertainty.PROVIDER_ACCEPTED); + + clientResult.set( + new NotificationProviderAttemptClient.ClientReconciliationResult( + SubmissionCertainty.DEFINITELY_NOT_APPLIED, + new NotificationReasonCode("PROVIDER_EVENT_NOT_FOUND"))); + var unprovenNonApplication = adapter.reconcile(claim); + assertThat(unprovenNonApplication.submissionCertainty()) + .isEqualTo(SubmissionCertainty.INDETERMINATE); + assertThat(unprovenNonApplication.reasonCode().value()) + .isEqualTo("RECONCILIATION_NONAPPLICATION_UNPROVEN"); + + mode.set(null); + var unsupported = + adapter.reconcile( + claim( + "provider-message-42", + NotificationDeliveryStorePort.ReconciliationLookupKind.MESSAGE_REFERENCE, + ProviderTestFixtures.NOW.plusSeconds(3))); + assertThat(mode.get()).isNull(); + assertThat(unsupported.submissionCertainty()).isEqualTo(SubmissionCertainty.INDETERMINATE); + assertThat(unsupported.reasonCode().value()).isEqualTo("RECONCILIATION_UNSUPPORTED"); + + NotificationReconciliationAdapter failing = + new NotificationReconciliationAdapter( + ignored -> binding(new ThrowingReconciliationClient()), + profile -> + NotificationSecretMaterialHandle.fromBytes( + profile.credentialGeneration(), new byte[] {1}), + Clock.fixed(ProviderTestFixtures.NOW, ZoneOffset.UTC)); + assertThat(failing.reconcile(claim).submissionCertainty()) + .isEqualTo(SubmissionCertainty.INDETERMINATE); + } + + private static NotificationDeliveryStorePort.ReconciliationClaim claim( + String lookupReference, + NotificationDeliveryStorePort.ReconciliationLookupKind lookupKind, + Instant deadline) { + var compiled = compiledBinding(); + var target = compiled.targets().getFirst(); + return new NotificationDeliveryStorePort.ReconciliationClaim( + new NotificationDeliveryId("delivery-42"), + "reconcile-token-42", + 3, + compiled.route().routeId(), + compiled.route().routeRevision(), + compiled.bindingDigest(), + 0, + target.target().targetId(), + target.capabilityCard().cardId(), + target.runtimeProfile().bindingRevision(), + target.runtimeProfile().credentialGeneration(), + lookupReference, + lookupKind, + deadline); + } + + private static NotificationReconciliationAdapter.ReconciliationBinding binding( + NotificationProviderAttemptClient client) { + var compiled = compiledBinding(); + return new NotificationReconciliationAdapter.ReconciliationBinding(compiled, 0, client); + } + + private static dev.caskeleton.adapter.outbound.notification.catalog.CompiledNotificationBinding + compiledBinding() { + return ProviderTestFixtures.emailGraph().bindings().getFirst(); + } + + private static final class ThrowingReconciliationClient + implements NotificationProviderAttemptClient { + + @Override + public PreparedNotificationAttempt prepare( + dev.caskeleton.adapter.outbound.notification.template.RenderedNotification rendered, + dev.caskeleton.adapter.outbound.notification.catalog.CompiledNotificationBinding + .CompiledTarget + target, + NotificationAttemptContext context) { + throw new UnsupportedOperationException(); + } + + @Override + public ClientAttemptResult sendOneAuthorizedAttempt( + PreparedNotificationAttempt prepared, + String executionToken, + NotificationSecretMaterialHandle secret, + Instant absoluteDeadline) { + throw new UnsupportedOperationException(); + } + + @Override + public ClientReconciliationResult reconcile( + ReconciliationReference reference, + ReconciliationLookupMode lookupMode, + NotificationSecretMaterialHandle secret, + Instant absoluteDeadline) { + throw new IllegalStateException("raw SDK reconciliation body"); + } + } +} diff --git a/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/provider/NotificationSecretMaterialHandleTest.java b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/provider/NotificationSecretMaterialHandleTest.java new file mode 100644 index 00000000..d2e8ba7b --- /dev/null +++ b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/provider/NotificationSecretMaterialHandleTest.java @@ -0,0 +1,38 @@ +package dev.caskeleton.adapter.outbound.notification.provider; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; + +class NotificationSecretMaterialHandleTest { + + @Test + void mutableCopyIsVersionedRedactedWipedAndRejectedAfterClose() { + byte[] caller = "sensitive-token".getBytes(StandardCharsets.UTF_8); + AtomicReference borrowed = new AtomicReference<>(); + NotificationSecretMaterialHandle handle = + NotificationSecretMaterialHandle.fromBytes("secret-r7", caller); + Arrays.fill(caller, (byte) 0); + + String observed = + handle.readBytes( + bytes -> { + borrowed.set(bytes); + return new String(bytes, StandardCharsets.UTF_8); + }); + + assertThat(observed).isEqualTo("sensitive-token"); + assertThat(handle.revision()).isEqualTo("secret-r7"); + assertThat(handle.toString()).doesNotContain("sensitive-token"); + handle.close(); + assertThat(borrowed.get()).containsOnly((byte) 0); + assertThatThrownBy(() -> handle.readBytes(bytes -> bytes.length)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("closed") + .hasMessageNotContaining("sensitive-token"); + } +} diff --git a/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/template/NotificationTemplateRendererTest.java b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/template/NotificationTemplateRendererTest.java new file mode 100644 index 00000000..979fe69f --- /dev/null +++ b/src/adapter/outbound/notification/src/test/java/dev/caskeleton/adapter/outbound/notification/template/NotificationTemplateRendererTest.java @@ -0,0 +1,390 @@ +package dev.caskeleton.adapter.outbound.notification.template; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.application.notification.ConsentCheckMode; +import dev.caskeleton.application.notification.EmailRecipientReference; +import dev.caskeleton.application.notification.NotificationAdmissionClass; +import dev.caskeleton.application.notification.NotificationChannel; +import dev.caskeleton.application.notification.NotificationFrozenPlan; +import dev.caskeleton.application.notification.NotificationIntentDraft; +import dev.caskeleton.application.notification.NotificationIntentId; +import dev.caskeleton.application.notification.NotificationKindId; +import dev.caskeleton.application.notification.NotificationKindPolicy; +import dev.caskeleton.application.notification.NotificationMode; +import dev.caskeleton.application.notification.NotificationRouteId; +import dev.caskeleton.application.notification.NotificationRouteStrategy; +import dev.caskeleton.application.notification.NotificationTemplateParameters; +import dev.caskeleton.application.notification.NotificationTemplateRef; +import dev.caskeleton.application.notification.NotificationTemplateValue; +import dev.caskeleton.application.notification.SlackAudienceReference; +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import org.junit.jupiter.api.Test; + +class NotificationTemplateRendererTest { + + private static final NotificationTemplateRef EMAIL_REF = + new NotificationTemplateRef("contract-email", 1); + private static final NotificationTemplateRef SLACK_REF = + new NotificationTemplateRef("contract-slack", 1); + + @Test + void exactManifestChecksumAndRendererRevisionProduceBoundedEmail() { + NotificationTemplateCatalog catalog = validCatalog(2_000); + NotificationFrozenPlan plan = + emailPlan( + Locale.KOREAN, + Map.of( + "displayName", new NotificationTemplateValue.SafeText(""), + "query", new NotificationTemplateValue.SafeText("alpha beta&gamma")), + catalog.require(EMAIL_REF)); + + RenderedNotification.Email rendered = new LocalEmailRenderer(catalog).render(plan); + + assertThat(rendered.locale()).isEqualTo(Locale.KOREAN); + assertThat(rendered.subject()).isEqualTo("Contract notice for "); + assertThat(rendered.html()) + .contains("<Ada & "Lovelace">") + .contains("alpha%20beta%26gamma") + .doesNotContain(" + new LocalEmailRenderer(catalog) + .render( + emailPlan( + Locale.ENGLISH, + Map.of( + "displayName", new NotificationTemplateValue.SafeText("Ada"), + "query", new NotificationTemplateValue.SafeText("secret-value")), + "0".repeat(64), + manifest.rendererRevision()))) + .isInstanceOf(TemplateRenderingException.class) + .hasMessageContaining("checksum") + .hasMessageNotContaining("secret-value"); + assertThatThrownBy( + () -> + new LocalEmailRenderer(catalog) + .render( + emailPlan( + Locale.ENGLISH, + Map.of( + "displayName", new NotificationTemplateValue.SafeText("Ada"), + "query", new NotificationTemplateValue.SafeText("secret-value")), + manifest.checksum(), + "different-renderer"))) + .isInstanceOf(TemplateRenderingException.class) + .hasMessageContaining("renderer") + .hasMessageNotContaining("secret-value"); + assertThatThrownBy( + () -> + new LocalEmailRenderer(catalog) + .render( + emailPlan( + Locale.ENGLISH, + Map.of("displayName", new NotificationTemplateValue.SafeText("Ada")), + manifest))) + .isInstanceOf(TemplateRenderingException.class) + .hasMessageContaining("parameter"); + assertThatThrownBy( + () -> + new LocalEmailRenderer(roomyCatalog) + .render( + emailPlan( + Locale.ENGLISH, + Map.of( + "displayName", + new NotificationTemplateValue.SafeText("Ada\r\nBcc: victim"), + "query", new NotificationTemplateValue.SafeText("secret-value")), + roomyManifest))) + .isInstanceOf(TemplateRenderingException.class) + .hasMessageContaining("header") + .hasMessageNotContaining("victim"); + assertThatThrownBy( + () -> + new LocalEmailRenderer(catalog) + .render( + emailPlan( + Locale.ENGLISH, + Map.of( + "displayName", + new NotificationTemplateValue.SafeText("A".repeat(100)), + "query", new NotificationTemplateValue.SafeText("secret-value")), + manifest))) + .isInstanceOf(TemplateRenderingException.class) + .hasMessageContaining("bound") + .hasMessageNotContaining("secret-value"); + } + + @Test + void slackBuilderEscapesMrkdwnPlainTextAndBroadcastMentions() { + NotificationTemplateCatalog catalog = validCatalog(2_000); + NotificationTemplateManifest manifest = catalog.require(SLACK_REF); + NotificationFrozenPlan plan = + slackPlan( + Map.of( + "displayName", new NotificationTemplateValue.SafeText("*Ada* "), + "audience", new NotificationTemplateValue.SafeText(" & guests"), + "mention", new NotificationTemplateValue.SafeText("@here <@U123>")), + manifest); + + RenderedNotification.Slack rendered = new SlackBlockKitRenderer(catalog).render(plan); + + assertThat(rendered.blocks()).hasSize(3); + assertThat(rendered.blocks().get(0).type()).isEqualTo(RenderedNotification.TextType.MRKDWN); + assertThat(rendered.blocks().get(0).verbatim()).isTrue(); + assertThat(rendered.blocks().get(0).text()) + .contains("\\*Ada\\* <admin>") + .doesNotContain(""); + assertThat(rendered.blocks().get(1).type()).isEqualTo(RenderedNotification.TextType.PLAIN_TEXT); + assertThat(rendered.blocks().get(1).text()) + .contains("<!channel> & guests") + .doesNotContain(""); + assertThat(rendered.blocks().get(2).text()) + .contains("@\u200Bhere <@\u200BU123>") + .doesNotContain("<@U123>"); + assertThat(rendered.maximumDepth()).isLessThanOrEqualTo(8); + } + + @Test + void htmlContextsCannotCrossIntoActiveAttributesOrMarkup() { + assertThatThrownBy( + () -> + LocalEmailRenderer.validateHtmlContexts( + "")) + .isInstanceOf(TemplateRenderingException.class) + .hasMessageContaining("attribute"); + assertThatThrownBy( + () -> + LocalEmailRenderer.validateHtmlContexts( + "
")) + .isInstanceOf(TemplateRenderingException.class) + .hasMessageContaining("attribute"); + assertThatThrownBy( + () -> + LocalEmailRenderer.validateHtmlContexts( + "")) + .isInstanceOf(TemplateRenderingException.class) + .hasMessageContaining("attribute"); + assertThatThrownBy( + () -> + LocalEmailRenderer.validateHtmlContexts( + "open")) + .isInstanceOf(TemplateRenderingException.class) + .hasMessageContaining("HTTPS link"); + LocalEmailRenderer.validateHtmlContexts( + "

{{displayName|html_text}}

" + + "open"); + } + + @Test + void includeDirectivesAndUnknownResourcesAreRejectedWithoutFileOrNetworkFallback() { + String path = "notification/templates/email/unsafe.txt"; + ClassLoader loader = + new ClassLoader(null) { + @Override + public InputStream getResourceAsStream(String name) { + if (path.equals(name)) { + return new ByteArrayInputStream( + "{{> file:/etc/passwd}}".getBytes(StandardCharsets.UTF_8)); + } + return null; + } + }; + NotificationTemplateManifest.ResourceSet resources = + NotificationTemplateManifest.ResourceSet.email(path, path, path); + String checksum = + NotificationTemplateCatalog.checksum(loader, Map.of(Locale.ENGLISH, resources)); + NotificationTemplateManifest manifest = + new NotificationTemplateManifest( + new NotificationTemplateRef("unsafe-email", 1), + NotificationChannel.EMAIL, + "email-renderer-r1", + checksum, + Set.of(Locale.ENGLISH), + Locale.ENGLISH, + Set.of(), + 1_000, + Map.of(Locale.ENGLISH, resources)); + + assertThatThrownBy(() -> new NotificationTemplateCatalog(loader, List.of(manifest))) + .isInstanceOf(TemplateRenderingException.class) + .hasMessageContaining("directive") + .hasMessageNotContaining("/etc/passwd"); + } + + private static NotificationTemplateCatalog validCatalog(int emailMaximumBytes) { + ClassLoader loader = NotificationTemplateRendererTest.class.getClassLoader(); + NotificationTemplateManifest.ResourceSet emailResources = + NotificationTemplateManifest.ResourceSet.email( + "notification/templates/email/contract-v1.subject.txt", + "notification/templates/email/contract-v1.text.txt", + "notification/templates/email/contract-v1.html"); + NotificationTemplateManifest.ResourceSet slackResources = + NotificationTemplateManifest.ResourceSet.slack( + "notification/templates/slack/contract-v1.txt"); + NotificationTemplateManifest email = + new NotificationTemplateManifest( + EMAIL_REF, + NotificationChannel.EMAIL, + "email-renderer-r1", + NotificationTemplateCatalog.checksum( + loader, Map.of(Locale.ENGLISH, emailResources, Locale.KOREAN, emailResources)), + Set.of(Locale.ENGLISH, Locale.KOREAN), + Locale.ENGLISH, + Set.of("displayName", "query"), + emailMaximumBytes, + Map.of(Locale.ENGLISH, emailResources, Locale.KOREAN, emailResources)); + NotificationTemplateManifest slack = + new NotificationTemplateManifest( + SLACK_REF, + NotificationChannel.SLACK, + "slack-renderer-r1", + NotificationTemplateCatalog.checksum(loader, Map.of(Locale.ENGLISH, slackResources)), + Set.of(Locale.ENGLISH), + Locale.ENGLISH, + Set.of("displayName", "audience", "mention"), + 2_000, + Map.of(Locale.ENGLISH, slackResources)); + return new NotificationTemplateCatalog(loader, List.of(slack, email)); + } + + private static NotificationFrozenPlan emailPlan( + Locale locale, + Map values, + NotificationTemplateManifest manifest) { + return emailPlan(locale, values, manifest.checksum(), manifest.rendererRevision()); + } + + private static NotificationFrozenPlan emailPlan( + Locale locale, + Map values, + String checksum, + String rendererRevision) { + return plan( + EMAIL_REF, + NotificationChannel.EMAIL, + locale, + new EmailRecipientReference("recipient-ref-42"), + values, + checksum, + rendererRevision); + } + + private static NotificationFrozenPlan slackPlan( + Map values, NotificationTemplateManifest manifest) { + return plan( + SLACK_REF, + NotificationChannel.SLACK, + Locale.ENGLISH, + new SlackAudienceReference("workspace-ref-42", "audience-ref-42"), + values, + manifest.checksum(), + manifest.rendererRevision()); + } + + private static NotificationFrozenPlan plan( + NotificationTemplateRef templateRef, + NotificationChannel channel, + Locale locale, + dev.caskeleton.application.notification.NotificationRecipientReference recipient, + Map values, + String checksum, + String rendererRevision) { + NotificationKindPolicy policy = + new NotificationKindPolicy( + new NotificationKindId("contract-notice"), + channel, + new NotificationRouteId( + channel == NotificationChannel.EMAIL ? "email-route" : "slack-route"), + templateRef, + NotificationMode.DURABLE_ASYNC, + NotificationAdmissionClass.TRANSACTIONAL, + NotificationRouteStrategy.SINGLE, + ConsentCheckMode.SNAPSHOT_AT_APPEND, + 1, + 1, + 1, + 0, + channel == NotificationChannel.EMAIL ? 1 : 0, + channel == NotificationChannel.EMAIL ? 2 : 1, + Duration.ofHours(1)); + Instant now = Instant.parse("2026-07-29T00:00:00Z"); + NotificationIntentDraft draft = + new NotificationIntentDraft( + new NotificationIntentId("intent-42"), + policy, + locale, + recipient, + new NotificationTemplateParameters(values), + "scope-42", + "operation-42", + Optional.empty(), + "correlation-42", + Optional.empty(), + now, + now.plusSeconds(60)); + return NotificationFrozenPlan.from( + draft, + locale, + new NotificationFrozenPlan.BindingSnapshot( + 1, + "a".repeat(64), + checksum, + rendererRevision, + List.of( + new NotificationFrozenPlan.FrozenTarget( + 0, + "target-42", + channel == NotificationChannel.EMAIL + ? "aws-ses-v2-durable-single-local-sns-v1" + : "slack-web-api-durable-single-local-v1", + "binding-r1", + "credential-r1")), + channel == NotificationChannel.EMAIL, + Duration.ofSeconds(5))); + } +} diff --git a/src/adapter/outbound/notification/src/test/resources/notification/templates/email/contract-v1.html b/src/adapter/outbound/notification/src/test/resources/notification/templates/email/contract-v1.html new file mode 100644 index 00000000..c1000b00 --- /dev/null +++ b/src/adapter/outbound/notification/src/test/resources/notification/templates/email/contract-v1.html @@ -0,0 +1,2 @@ +

Hello {{displayName|html_text}}.

+Open contract diff --git a/src/adapter/outbound/notification/src/test/resources/notification/templates/email/contract-v1.subject.txt b/src/adapter/outbound/notification/src/test/resources/notification/templates/email/contract-v1.subject.txt new file mode 100644 index 00000000..bea116c3 --- /dev/null +++ b/src/adapter/outbound/notification/src/test/resources/notification/templates/email/contract-v1.subject.txt @@ -0,0 +1 @@ +Contract notice for {{displayName|header}} diff --git a/src/adapter/outbound/notification/src/test/resources/notification/templates/email/contract-v1.text.txt b/src/adapter/outbound/notification/src/test/resources/notification/templates/email/contract-v1.text.txt new file mode 100644 index 00000000..756df6f3 --- /dev/null +++ b/src/adapter/outbound/notification/src/test/resources/notification/templates/email/contract-v1.text.txt @@ -0,0 +1,2 @@ +Hello {{displayName|text}}. +Reference: {{query|text}} diff --git a/src/adapter/outbound/notification/src/test/resources/notification/templates/slack/contract-v1.txt b/src/adapter/outbound/notification/src/test/resources/notification/templates/slack/contract-v1.txt new file mode 100644 index 00000000..ce4b5909 --- /dev/null +++ b/src/adapter/outbound/notification/src/test/resources/notification/templates/slack/contract-v1.txt @@ -0,0 +1,3 @@ +*Alert for {{displayName|mrkdwn}}* +Audience: {{audience|plain}} +Mention: {{mention|mention}} diff --git a/src/adapter/outbound/persistence-jpa/CLAUDE.md b/src/adapter/outbound/persistence-jpa/CLAUDE.md index 705d6dab..f0996bf1 100644 --- a/src/adapter/outbound/persistence-jpa/CLAUDE.md +++ b/src/adapter/outbound/persistence-jpa/CLAUDE.md @@ -63,11 +63,20 @@ adapters implement application/domain ports directly and must not depend on this | Mode | Propagation | Isolation | Read-only | |---|---|---|---| | `inWrite` | `REQUIRED` | `READ_COMMITTED` | `false` | +| `inRootWrite` | `REQUIRED` | `READ_COMMITTED` | `false` | | `inRead` | `REQUIRED` | `READ_COMMITTED` | `true` | | `inNew` | `REQUIRES_NEW` | `READ_COMMITTED` | `false` | Pre-built templates are immutable after construction so concurrent callers cannot -observe each other's reconfiguration. +observe each other's reconfiguration. `inRootWrite` reuses the pre-built write template, +but first checks `TransactionSynchronizationManager.isActualTransactionActive()`. +When an actual ambient transaction exists it MUST throw +`NestedRootTransactionRejectedException` before invoking either the action or the +`PlatformTransactionManager`. It MUST NOT use `NEVER` or `REQUIRES_NEW`. + +`inRootWrite` returns its action value only after `TransactionTemplate.execute` has +committed. A commit failure propagates the transaction exception and no success value +is returned to the caller. ### `inNew` pool-sizing constraint (D12 of feature-application-port-usecase-contract) diff --git a/src/adapter/outbound/persistence-jpa/README.md b/src/adapter/outbound/persistence-jpa/README.md index 84182a90..5b02f9cf 100644 --- a/src/adapter/outbound/persistence-jpa/README.md +++ b/src/adapter/outbound/persistence-jpa/README.md @@ -17,6 +17,19 @@ readOnly 를 바꿔 쓰면 같은 빈을 공유하는 동시 요청 사이에 ra 사라지고, 각 모드를 따로 감사(audit)할 수 있다. 세 템플릿 모두 isolation 을 `READ_COMMITTED` 로 고정한다(모드표는 CLAUDE.md §TransactionPort implementation contract). +### 왜 `inRootWrite`가 별도 템플릿이나 `NEVER` propagation을 만들지 않나 +`inRootWrite`의 실행 속성은 `inWrite`와 같은 `WRITE + REQUIRED + READ_COMMITTED`라 기존 +write template을 재사용한다. 차이는 실행 전 precondition이다. +`TransactionSynchronizationManager.isActualTransactionActive()`가 `true`이면 action과 +`PlatformTransactionManager`를 호출하기 전에 +`NestedRootTransactionRejectedException`으로 fail-fast한다. `REQUIRES_NEW`로 suspend해서 +"root처럼 보이게" 하지 않으므로 호출자 transaction과 독립 commit되는 silent 의미 변경이 없다. + +`TransactionTemplate.execute`는 commit까지 성공한 다음 값을 반환한다. 따라서 +`inRootWrite`의 결과는 post-commit에만 호출자에게 보이고, commit 실패는 값 대신 원래 transaction +예외로 전파된다. 이 보장은 action이 외부 객체를 직접 변경하는 것을 되돌리는 보상이 아니라, +경계의 반환값을 성공으로 노출하지 않는 계약이다. + ## audit — `AuditableEntity` / `AuditContextPort` / `DomainContextAuditContextPort` ### 캡처 메커니즘 — Manual explicit-set (D1 현재 스켈레톤 기본값) diff --git a/src/adapter/outbound/persistence-jpa/build.gradle b/src/adapter/outbound/persistence-jpa/build.gradle index e4bf00bf..434e4dbb 100644 --- a/src/adapter/outbound/persistence-jpa/build.gradle +++ b/src/adapter/outbound/persistence-jpa/build.gradle @@ -18,6 +18,9 @@ dependencies { runtimeOnly 'org.postgresql:postgresql' runtimeOnly 'org.flywaydb:flyway-database-postgresql' annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' + + testImplementation 'org.testcontainers:testcontainers-postgresql' + testImplementation 'org.testcontainers:testcontainers-junit-jupiter' } tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8' } diff --git a/src/adapter/outbound/persistence-jpa/gradle.lockfile b/src/adapter/outbound/persistence-jpa/gradle.lockfile index 14cf6ab1..a4e3da3e 100644 --- a/src/adapter/outbound/persistence-jpa/gradle.lockfile +++ b/src/adapter/outbound/persistence-jpa/gradle.lockfile @@ -10,6 +10,9 @@ com.fasterxml.jackson.core:jackson-databind:2.20.1=compileClasspath,runtimeClass com.fasterxml.jackson:jackson-bom:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath com.fasterxml:classmate:1.7.1=runtimeClasspath,testRuntimeClasspath com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor +com.github.docker-java:docker-java-api:3.7.0=testCompileClasspath,testRuntimeClasspath +com.github.docker-java:docker-java-transport-zerodep:3.7.0=testCompileClasspath,testRuntimeClasspath +com.github.docker-java:docker-java-transport:3.7.0=testCompileClasspath,testRuntimeClasspath com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,testCompileClasspath @@ -41,7 +44,9 @@ com.sun.istack:istack-commons-runtime:4.1.2=runtimeClasspath,testRuntimeClasspat com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath com.zaxxer:HikariCP:7.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath commons-beanutils:commons-beanutils:1.11.0=checkstyle +commons-codec:commons-codec:1.19.0=testCompileClasspath,testRuntimeClasspath commons-collections:commons-collections:3.2.2=checkstyle +commons-io:commons-io:2.20.0=testCompileClasspath,testRuntimeClasspath commons-io:commons-io:2.21.0=spotbugs commons-logging:commons-logging:1.3.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath info.picocli:picocli:4.7.7=checkstyle @@ -60,12 +65,14 @@ javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor jaxen:jaxen:2.0.0=spotbugs net.bytebuddy:byte-buddy-agent:1.17.8=testCompileClasspath,testRuntimeClasspath net.bytebuddy:byte-buddy:1.17.8=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +net.java.dev.jna:jna:5.18.1=testCompileClasspath,testRuntimeClasspath net.minidev:accessors-smart:2.6.0=testCompileClasspath,testRuntimeClasspath net.minidev:json-smart:2.6.0=testCompileClasspath,testRuntimeClasspath net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs org.antlr:antlr4-runtime:4.13.2=checkstyle,compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.apache.bcel:bcel:6.12.0=spotbugs -org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs +org.apache.commons:commons-compress:1.28.0=testCompileClasspath,testRuntimeClasspath +org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs,testCompileClasspath,testRuntimeClasspath org.apache.commons:commons-text:1.15.0=spotbugs org.apache.commons:commons-text:1.3=checkstyle org.apache.httpcomponents:httpclient:4.5.13=checkstyle @@ -102,6 +109,7 @@ org.hibernate.models:hibernate-models:1.0.1=runtimeClasspath,testRuntimeClasspat org.hibernate.orm:hibernate-core:7.1.8.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.javassist:javassist:3.28.0-GA=checkstyle org.jboss.logging:jboss-logging:3.6.1.Final=runtimeClasspath,testRuntimeClasspath +org.jetbrains:annotations:17.0.0=testCompileClasspath,testRuntimeClasspath org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath @@ -130,6 +138,7 @@ org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor org.postgresql:postgresql:42.7.8=runtimeClasspath,testRuntimeClasspath org.reactivestreams:reactive-streams:1.0.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.reflections:reflections:0.10.2=checkstyle +org.rnorth.duct-tape:duct-tape:1.0.8=testCompileClasspath,testRuntimeClasspath org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath @@ -187,6 +196,11 @@ org.springframework:spring-test:7.0.1=testCompileClasspath,testRuntimeClasspath org.springframework:spring-tx:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-web:7.0.1=testCompileClasspath,testRuntimeClasspath org.springframework:spring-webmvc:7.0.1=testCompileClasspath,testRuntimeClasspath +org.testcontainers:testcontainers-database-commons:2.0.2=testCompileClasspath,testRuntimeClasspath +org.testcontainers:testcontainers-jdbc:2.0.2=testCompileClasspath,testRuntimeClasspath +org.testcontainers:testcontainers-junit-jupiter:2.0.2=testCompileClasspath,testRuntimeClasspath +org.testcontainers:testcontainers-postgresql:2.0.2=testCompileClasspath,testRuntimeClasspath +org.testcontainers:testcontainers:2.0.2=testCompileClasspath,testRuntimeClasspath org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath org.yaml:snakeyaml:2.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/crypto/DirectAeadNotificationPayloadCrypto.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/crypto/DirectAeadNotificationPayloadCrypto.java new file mode 100644 index 00000000..652d9e58 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/crypto/DirectAeadNotificationPayloadCrypto.java @@ -0,0 +1,120 @@ +package dev.caskeleton.adapter.outbound.persistence.notification.crypto; + +import java.security.GeneralSecurityException; +import java.security.SecureRandom; +import java.util.Arrays; +import java.util.Objects; +import javax.crypto.Cipher; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +/** Direct AES-256-GCM field encryption with a fresh 96-bit nonce and 128-bit tag. */ +public final class DirectAeadNotificationPayloadCrypto { + + private static final String ALGORITHM = "AES-256-GCM"; + private static final String TRANSFORMATION = "AES/GCM/NoPadding"; + private static final String PROFILE = "notification-direct-aead-v1"; + private static final String AAD_REVISION = "notification-aad-v1"; + private static final int TAG_BITS = 128; + + private final NotificationKeyMaterialProvider keys; + private final SecureRandom random; + + public DirectAeadNotificationPayloadCrypto( + NotificationKeyMaterialProvider keys, SecureRandom random) { + this.keys = Objects.requireNonNull(keys, "notification key provider must be non-null"); + this.random = Objects.requireNonNull(random, "notification secure random must be non-null"); + } + + public NotificationCiphertext encrypt( + byte[] plaintext, + NotificationCiphertext.AadContext context, + String keyReference, + String keyVersion) { + Objects.requireNonNull(plaintext, "notification plaintext must be non-null"); + Objects.requireNonNull(context, "notification AAD context must be non-null"); + if (plaintext.length < 1 || plaintext.length > 10_000_000) { + throw new IllegalArgumentException( + "notification plaintext must contain 1..10000000 bytes"); + } + requireProfile(context); + byte[] nonce = new byte[12]; + random.nextBytes(nonce); + try (NotificationKeyMaterialHandle handle = keys.acquire(keyReference, keyVersion)) { + byte[] encrypted = + handle.readBytes( + material -> transform(Cipher.ENCRYPT_MODE, material, nonce, context, plaintext)); + return new NotificationCiphertext( + ALGORITHM, + handle.keyReference(), + handle.keyVersion(), + PROFILE, + AAD_REVISION, + nonce, + encrypted); + } + } + + public byte[] decrypt( + NotificationCiphertext encrypted, NotificationCiphertext.AadContext context) { + Objects.requireNonNull(encrypted, "notification ciphertext must be non-null"); + Objects.requireNonNull(context, "notification AAD context must be non-null"); + requireProfile(context); + if (!ALGORITHM.equals(encrypted.algorithm()) + || !PROFILE.equals(encrypted.cryptoProfileVersion()) + || !AAD_REVISION.equals(encrypted.aadRevision())) { + throw new NotificationCryptoException( + "notification ciphertext cryptographic profile is unsupported"); + } + try (NotificationKeyMaterialHandle handle = + keys.acquire(encrypted.keyReference(), encrypted.keyVersion())) { + return handle.readBytes( + material -> + transform( + Cipher.DECRYPT_MODE, + material, + encrypted.nonce(), + context, + encrypted.ciphertext())); + } catch (NotificationCryptoException failure) { + throw failure; + } catch (RuntimeException failure) { + throw new NotificationCryptoException( + "notification ciphertext authentication failed", failure); + } + } + + private static byte[] transform( + int mode, + byte[] material, + byte[] nonce, + NotificationCiphertext.AadContext context, + byte[] input) { + if (material.length != 32) { + throw new NotificationCryptoException( + "notification AES-256 key revision has an invalid length"); + } + byte[] keyCopy = material.clone(); + try { + Cipher cipher = Cipher.getInstance(TRANSFORMATION); + cipher.init( + mode, + new SecretKeySpec(keyCopy, "AES"), + new GCMParameterSpec(TAG_BITS, nonce)); + cipher.updateAAD(context.canonicalBytes()); + return cipher.doFinal(input); + } catch (GeneralSecurityException failure) { + throw new NotificationCryptoException( + "notification ciphertext authentication failed", failure); + } finally { + Arrays.fill(keyCopy, (byte) 0); + } + } + + private static void requireProfile(NotificationCiphertext.AadContext context) { + if (!PROFILE.equals(context.cryptoProfileVersion())) { + throw new NotificationCryptoException( + "notification AAD crypto profile is unsupported"); + } + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/crypto/NotificationCiphertext.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/crypto/NotificationCiphertext.java new file mode 100644 index 00000000..043c87b1 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/crypto/NotificationCiphertext.java @@ -0,0 +1,166 @@ +package dev.caskeleton.adapter.outbound.persistence.notification.crypto; + +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Objects; +import java.util.Optional; + +/** Non-secret AES-GCM metadata and ciphertext; mutable arrays are defensively copied. */ +public record NotificationCiphertext( + String algorithm, + String keyReference, + String keyVersion, + String cryptoProfileVersion, + String aadRevision, + byte[] nonce, + byte[] ciphertext) { + + public NotificationCiphertext { + if (!"AES-256-GCM".equals(algorithm)) { + throw new IllegalArgumentException("notification ciphertext algorithm must be AES-256-GCM"); + } + keyReference = + NotificationKeyMaterialHandle.requireSlug( + "notification ciphertext key reference", keyReference); + keyVersion = + NotificationKeyMaterialHandle.requireSlug( + "notification ciphertext key version", keyVersion); + cryptoProfileVersion = + NotificationKeyMaterialHandle.requireSlug( + "notification crypto profile version", cryptoProfileVersion); + aadRevision = + NotificationKeyMaterialHandle.requireSlug( + "notification AAD revision", aadRevision); + Objects.requireNonNull(nonce, "notification ciphertext nonce must be non-null"); + Objects.requireNonNull(ciphertext, "notification ciphertext bytes must be non-null"); + if (nonce.length != 12 || ciphertext.length < 17 || ciphertext.length > 10_000_016) { + throw new IllegalArgumentException( + "notification ciphertext nonce/tag/payload bounds are invalid"); + } + nonce = nonce.clone(); + ciphertext = ciphertext.clone(); + } + + @Override + public byte[] nonce() { + return nonce.clone(); + } + + @Override + public byte[] ciphertext() { + return ciphertext.clone(); + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (!(other instanceof NotificationCiphertext that)) { + return false; + } + return algorithm.equals(that.algorithm) + && keyReference.equals(that.keyReference) + && keyVersion.equals(that.keyVersion) + && cryptoProfileVersion.equals(that.cryptoProfileVersion) + && aadRevision.equals(that.aadRevision) + && Arrays.equals(nonce, that.nonce) + && Arrays.equals(ciphertext, that.ciphertext); + } + + @Override + public int hashCode() { + int result = + Objects.hash( + algorithm, keyReference, keyVersion, cryptoProfileVersion, aadRevision); + result = 31 * result + Arrays.hashCode(nonce); + return 31 * result + Arrays.hashCode(ciphertext); + } + + @Override + public String toString() { + return "NotificationCiphertext[algorithm=" + + algorithm + + ", keyReference=" + + keyReference + + ", keyVersion=" + + keyVersion + + ", cryptoProfileVersion=" + + cryptoProfileVersion + + ", aadRevision=" + + aadRevision + + ", nonce=, ciphertext=]"; + } + + /** Exact approved length-prefixed AAD hierarchy for one encrypted notification field. */ + public record AadContext( + String schemaTable, + String recordId, + String notificationId, + Optional deliveryId, + Optional attemptId, + String fieldPurpose, + String providerBindingRevision, + String cryptoProfileVersion) { + + public AadContext { + if (schemaTable == null || !schemaTable.matches("[a-z][a-z0-9_]{0,62}")) { + throw new IllegalArgumentException( + "notification AAD table must match [a-z][a-z0-9_]{0,62}"); + } + recordId = requireOpaque("notification AAD record ID", recordId); + notificationId = requireOpaque("notification AAD notification ID", notificationId); + deliveryId = requireOptional("notification AAD delivery ID", deliveryId); + attemptId = requireOptional("notification AAD attempt ID", attemptId); + fieldPurpose = + NotificationKeyMaterialHandle.requireSlug( + "notification AAD field purpose", fieldPurpose); + providerBindingRevision = + NotificationKeyMaterialHandle.requireSlug( + "notification AAD provider binding revision", providerBindingRevision); + cryptoProfileVersion = + NotificationKeyMaterialHandle.requireSlug( + "notification AAD crypto profile", cryptoProfileVersion); + } + + byte[] canonicalBytes() { + java.io.ByteArrayOutputStream output = new java.io.ByteArrayOutputStream(); + update(output, schemaTable); + update(output, recordId); + update(output, notificationId); + updateOptional(output, deliveryId); + updateOptional(output, attemptId); + update(output, fieldPurpose); + update(output, providerBindingRevision); + update(output, cryptoProfileVersion); + return output.toByteArray(); + } + + private static Optional requireOptional( + String field, Optional value) { + Objects.requireNonNull(value, field + " container must be non-null"); + return value.map(item -> requireOpaque(field, item)); + } + + private static String requireOpaque(String field, String value) { + if (value == null || !value.matches("[A-Za-z0-9][A-Za-z0-9._:-]{0,127}")) { + throw new IllegalArgumentException( + field + " must contain 1..128 opaque identifier characters"); + } + return value; + } + + private static void update(java.io.ByteArrayOutputStream output, String value) { + byte[] bytes = value.getBytes(StandardCharsets.UTF_8); + output.writeBytes(ByteBuffer.allocate(Integer.BYTES).putInt(bytes.length).array()); + output.writeBytes(bytes); + } + + private static void updateOptional( + java.io.ByteArrayOutputStream output, Optional value) { + output.write(value.isPresent() ? 1 : 0); + value.ifPresent(item -> update(output, item)); + } + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/crypto/NotificationCryptoException.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/crypto/NotificationCryptoException.java new file mode 100644 index 00000000..126da8bd --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/crypto/NotificationCryptoException.java @@ -0,0 +1,13 @@ +package dev.caskeleton.adapter.outbound.persistence.notification.crypto; + +/** Redacted fail-closed notification cryptographic operation error. */ +public final class NotificationCryptoException extends RuntimeException { + + public NotificationCryptoException(String safeMessage) { + super(safeMessage); + } + + public NotificationCryptoException(String safeMessage, Throwable cause) { + super(safeMessage, cause); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/crypto/NotificationHmacDigester.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/crypto/NotificationHmacDigester.java new file mode 100644 index 00000000..5ed43970 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/crypto/NotificationHmacDigester.java @@ -0,0 +1,136 @@ +package dev.caskeleton.adapter.outbound.persistence.notification.crypto; + +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.MessageDigest; +import java.util.Arrays; +import java.util.HashSet; +import java.util.HexFormat; +import java.util.List; +import java.util.Objects; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +/** Purpose-separated length-prefixed HMAC-SHA-256 over non-secret canonical tuple fields. */ +public final class NotificationHmacDigester { + + private static final String ALGORITHM = "HmacSHA256"; + private static final int MAXIMUM_VERIFICATION_VERSIONS = 4; + + private final NotificationKeyMaterialProvider keys; + + public NotificationHmacDigester(NotificationKeyMaterialProvider keys) { + this.keys = Objects.requireNonNull(keys, "notification HMAC key provider must be non-null"); + } + + public Digest digest( + String purpose, + List fields, + String keyReference, + String keyVersion) { + byte[] canonical = canonical(purpose, fields); + try (NotificationKeyMaterialHandle handle = keys.acquire(keyReference, keyVersion)) { + String value = + handle.readBytes( + material -> HexFormat.of().formatHex(hmac(material, canonical))); + return new Digest(handle.keyReference(), handle.keyVersion(), value); + } finally { + Arrays.fill(canonical, (byte) 0); + } + } + + public boolean verify(Digest expected, String purpose, List fields) { + Objects.requireNonNull(expected, "expected notification HMAC must be non-null"); + List versions = + List.copyOf( + Objects.requireNonNull( + keys.verificationVersions(expected.keyReference()), + "notification HMAC verification versions must be non-null")); + if (versions.isEmpty() + || versions.size() > MAXIMUM_VERIFICATION_VERSIONS + || new HashSet<>(versions).size() != versions.size()) { + throw new NotificationCryptoException( + "notification HMAC verification key set must be unique and bounded"); + } + if (!versions.contains(expected.keyVersion())) { + return false; + } + Digest actual = + digest(purpose, fields, expected.keyReference(), expected.keyVersion()); + return MessageDigest.isEqual( + HexFormat.of().parseHex(expected.value()), + HexFormat.of().parseHex(actual.value())); + } + + private static byte[] canonical(String purpose, List fields) { + if (purpose == null || !purpose.matches("[a-z][a-z0-9-]{0,62}")) { + throw new IllegalArgumentException( + "notification HMAC purpose must match [a-z][a-z0-9-]{0,62}"); + } + Objects.requireNonNull(fields, "notification HMAC fields must be non-null"); + if (fields.isEmpty() || fields.size() > 32) { + throw new IllegalArgumentException("notification HMAC fields must contain 1..32 entries"); + } + java.io.ByteArrayOutputStream output = new java.io.ByteArrayOutputStream(); + update(output, purpose); + output.writeBytes(ByteBuffer.allocate(Integer.BYTES).putInt(fields.size()).array()); + fields.forEach( + field -> { + if (field == null || field.length() > 4_096) { + throw new IllegalArgumentException( + "notification HMAC field must contain at most 4096 characters"); + } + update(output, field); + }); + return output.toByteArray(); + } + + private static byte[] hmac(byte[] material, byte[] canonical) { + if (material.length < 32) { + throw new NotificationCryptoException( + "notification HMAC key revision has an invalid length"); + } + byte[] keyCopy = material.clone(); + try { + Mac mac = Mac.getInstance(ALGORITHM); + mac.init(new SecretKeySpec(keyCopy, ALGORITHM)); + return mac.doFinal(canonical); + } catch (GeneralSecurityException failure) { + throw new NotificationCryptoException("notification HMAC operation failed", failure); + } finally { + Arrays.fill(keyCopy, (byte) 0); + } + } + + private static void update(java.io.ByteArrayOutputStream output, String value) { + byte[] bytes = value.getBytes(StandardCharsets.UTF_8); + output.writeBytes(ByteBuffer.allocate(Integer.BYTES).putInt(bytes.length).array()); + output.writeBytes(bytes); + } + + public record Digest(String keyReference, String keyVersion, String value) { + + public Digest { + keyReference = + NotificationKeyMaterialHandle.requireSlug( + "notification HMAC key reference", keyReference); + keyVersion = + NotificationKeyMaterialHandle.requireSlug( + "notification HMAC key version", keyVersion); + if (value == null || !value.matches("[0-9a-f]{64}")) { + throw new IllegalArgumentException( + "notification HMAC value must be lowercase SHA-256 hex"); + } + } + + @Override + public String toString() { + return "Digest[keyReference=" + + keyReference + + ", keyVersion=" + + keyVersion + + ", value=]"; + } + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/crypto/NotificationKeyMaterialHandle.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/crypto/NotificationKeyMaterialHandle.java new file mode 100644 index 00000000..0561a37b --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/crypto/NotificationKeyMaterialHandle.java @@ -0,0 +1,73 @@ +package dev.caskeleton.adapter.outbound.persistence.notification.crypto; + +import java.util.Arrays; +import java.util.Objects; +import java.util.function.Function; + +/** Operation-scoped mutable key copy that wipes on close and rejects use after close. */ +public final class NotificationKeyMaterialHandle implements AutoCloseable { + + private final String keyReference; + private final String keyVersion; + private final byte[] material; + private boolean closed; + + private NotificationKeyMaterialHandle( + String keyReference, String keyVersion, byte[] material) { + this.keyReference = requireSlug("notification key reference", keyReference); + this.keyVersion = requireSlug("notification key version", keyVersion); + this.material = material; + } + + public static NotificationKeyMaterialHandle fromBytes( + String keyReference, String keyVersion, byte[] material) { + Objects.requireNonNull(material, "notification key material must be non-null"); + if (material.length < 32 || material.length > 65_536) { + throw new IllegalArgumentException( + "notification key material must contain 32..65536 bytes"); + } + return new NotificationKeyMaterialHandle(keyReference, keyVersion, material.clone()); + } + + public String keyReference() { + return keyReference; + } + + public String keyVersion() { + return keyVersion; + } + + public synchronized T readBytes(Function reader) { + Objects.requireNonNull(reader, "notification key reader must be non-null"); + if (closed) { + throw new IllegalStateException("notification key material handle is closed"); + } + return reader.apply(material); + } + + @Override + public synchronized void close() { + if (!closed) { + Arrays.fill(material, (byte) 0); + closed = true; + } + } + + @Override + public synchronized String toString() { + return "NotificationKeyMaterialHandle[keyReference=" + + keyReference + + ", keyVersion=" + + keyVersion + + ", material=, closed=" + + closed + + "]"; + } + + static String requireSlug(String field, String value) { + if (value == null || !value.matches("[a-z][a-z0-9.-]{0,62}")) { + throw new IllegalArgumentException(field + " must match [a-z][a-z0-9.-]{0,62}"); + } + return value; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/crypto/NotificationKeyMaterialProvider.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/crypto/NotificationKeyMaterialProvider.java new file mode 100644 index 00000000..6a595068 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/notification/crypto/NotificationKeyMaterialProvider.java @@ -0,0 +1,11 @@ +package dev.caskeleton.adapter.outbound.persistence.notification.crypto; + +import java.util.List; + +/** Acquires versioned mutable key copies and declares bounded current-plus-retiring verification. */ +public interface NotificationKeyMaterialProvider { + + NotificationKeyMaterialHandle acquire(String keyReference, String keyVersion); + + List verificationVersions(String keyReference); +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringTransactionPort.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringTransactionPort.java index cea0b805..8fa8cdf6 100644 --- a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringTransactionPort.java +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringTransactionPort.java @@ -1,12 +1,14 @@ package dev.caskeleton.adapter.outbound.persistence.transaction; import dev.caskeleton.application.transaction.Isolation; +import dev.caskeleton.application.transaction.NestedRootTransactionRejectedException; import dev.caskeleton.application.transaction.TransactionMode; import dev.caskeleton.application.transaction.TransactionPort; import java.util.function.Supplier; import org.springframework.stereotype.Component; import org.springframework.transaction.PlatformTransactionManager; import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.support.TransactionSynchronizationManager; import org.springframework.transaction.support.TransactionTemplate; /** @@ -47,6 +49,14 @@ public class SpringTransactionPort implements TransactionPort { return writeTemplate.execute(status -> action.get()); } + @Override + public T inRootWrite(Supplier action) { + if (TransactionSynchronizationManager.isActualTransactionActive()) { + throw new NestedRootTransactionRejectedException(); + } + return writeTemplate.execute(status -> action.get()); + } + @Override public T inRead(Supplier action) { return readTemplate.execute(status -> action.get()); diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/notification/crypto/NotificationHmacDigesterTest.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/notification/crypto/NotificationHmacDigesterTest.java new file mode 100644 index 00000000..229c47a7 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/notification/crypto/NotificationHmacDigesterTest.java @@ -0,0 +1,77 @@ +package dev.caskeleton.adapter.outbound.persistence.notification.crypto; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; +import org.junit.jupiter.api.Test; + +class NotificationHmacDigesterTest { + + @Test + void purposeAndLengthPrefixesSeparateAmbiguousTuplesAndOrdering() { + NotificationHmacDigester digester = + new NotificationHmacDigester(NotificationPayloadCryptoTest.InMemoryKeys.standard()); + + NotificationHmacDigester.Digest split = + digester.digest("intent-dedupe-v1", List.of("a", "bc"), "alias-key", "hmac-r2"); + NotificationHmacDigester.Digest joined = + digester.digest("intent-dedupe-v1", List.of("ab", "c"), "alias-key", "hmac-r2"); + NotificationHmacDigester.Digest reordered = + digester.digest("intent-dedupe-v1", List.of("bc", "a"), "alias-key", "hmac-r2"); + NotificationHmacDigester.Digest otherPurpose = + digester.digest("recipient-alias-v1", List.of("a", "bc"), "alias-key", "hmac-r2"); + + assertThat(split.value()).matches("[0-9a-f]{64}"); + assertThat(split.value()) + .isNotEqualTo(joined.value()) + .isNotEqualTo(reordered.value()) + .isNotEqualTo(otherPurpose.value()); + assertThat(split.toString()).doesNotContain(split.value()); + } + + @Test + void currentAndBoundedRetiringVersionsVerifyWhileUnknownAndOversizedSetsFailClosed() { + NotificationHmacDigester digester = + new NotificationHmacDigester(NotificationPayloadCryptoTest.InMemoryKeys.standard()); + NotificationHmacDigester.Digest retiring = + digester.digest("intent-dedupe-v1", List.of("tenant-42", "scope-42"), "alias-key", "hmac-r1"); + + assertThat(digester.verify(retiring, "intent-dedupe-v1", List.of("tenant-42", "scope-42"))) + .isTrue(); + assertThat(digester.verify(retiring, "recipient-alias-v1", List.of("tenant-42", "scope-42"))) + .isFalse(); + assertThat( + digester.verify( + new NotificationHmacDigester.Digest( + "alias-key", "hmac-r9", retiring.value()), + "intent-dedupe-v1", + List.of("tenant-42", "scope-42"))) + .isFalse(); + + NotificationKeyMaterialProvider oversized = + new NotificationKeyMaterialProvider() { + @Override + public NotificationKeyMaterialHandle acquire(String reference, String version) { + return NotificationKeyMaterialHandle.fromBytes( + reference, + version, + NotificationPayloadCryptoTest.InMemoryKeys.HMAC_R2); + } + + @Override + public List verificationVersions(String reference) { + return List.of("hmac-r1", "hmac-r2", "hmac-r3", "hmac-r4", "hmac-r5"); + } + }; + assertThatThrownBy( + () -> + new NotificationHmacDigester(oversized) + .verify( + retiring, + "intent-dedupe-v1", + List.of("tenant-42", "scope-42"))) + .isInstanceOf(NotificationCryptoException.class) + .hasMessageContaining("bounded"); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/notification/crypto/NotificationPayloadCryptoTest.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/notification/crypto/NotificationPayloadCryptoTest.java new file mode 100644 index 00000000..837f9e25 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/notification/crypto/NotificationPayloadCryptoTest.java @@ -0,0 +1,168 @@ +package dev.caskeleton.adapter.outbound.persistence.notification.crypto; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; + +class NotificationPayloadCryptoTest { + + @Test + void aes256GcmUsesFreshNonceExactMetadataAndContextBoundLengthPrefixedAad() { + InMemoryKeys keys = InMemoryKeys.standard(); + DirectAeadNotificationPayloadCrypto crypto = + new DirectAeadNotificationPayloadCrypto(keys, new SecureRandom()); + NotificationCiphertext.AadContext context = context("record-42", "parameters"); + byte[] plaintext = "recipient-secret-value".getBytes(StandardCharsets.UTF_8); + + NotificationCiphertext first = + crypto.encrypt(plaintext, context, "payload-key", "payload-r2"); + NotificationCiphertext second = + crypto.encrypt(plaintext, context, "payload-key", "payload-r2"); + + assertThat(first.algorithm()).isEqualTo("AES-256-GCM"); + assertThat(first.cryptoProfileVersion()).isEqualTo("notification-direct-aead-v1"); + assertThat(first.aadRevision()).isEqualTo("notification-aad-v1"); + assertThat(first.nonce()).hasSize(12).isNotEqualTo(second.nonce()); + assertThat(crypto.decrypt(first, context)).isEqualTo(plaintext); + assertThat(first.toString()) + .doesNotContain("recipient-secret-value") + .doesNotContain(Arrays.toString(InMemoryKeys.PAYLOAD_R2)); + } + + @Test + void tupleFieldSwapsCiphertextSwapsAndWrongKeyRevisionFailAuthenticationAndStayRedacted() { + InMemoryKeys keys = InMemoryKeys.standard(); + DirectAeadNotificationPayloadCrypto crypto = + new DirectAeadNotificationPayloadCrypto(keys, new SecureRandom()); + NotificationCiphertext.AadContext parameters = context("record-42", "parameters"); + NotificationCiphertext.AadContext recipient = context("record-43", "recipient"); + NotificationCiphertext encrypted = + crypto.encrypt( + "secret-A".getBytes(StandardCharsets.UTF_8), + parameters, + "payload-key", + "payload-r2"); + NotificationCiphertext other = + crypto.encrypt( + "secret-B".getBytes(StandardCharsets.UTF_8), + recipient, + "payload-key", + "payload-r2"); + + assertThatThrownBy(() -> crypto.decrypt(encrypted, recipient)) + .isInstanceOf(NotificationCryptoException.class) + .hasMessageContaining("authentication") + .hasMessageNotContaining("secret-A") + .hasMessageNotContaining("secret-B"); + NotificationCiphertext swapped = + new NotificationCiphertext( + encrypted.algorithm(), + encrypted.keyReference(), + encrypted.keyVersion(), + encrypted.cryptoProfileVersion(), + encrypted.aadRevision(), + other.nonce(), + other.ciphertext()); + assertThatThrownBy(() -> crypto.decrypt(swapped, parameters)) + .isInstanceOf(NotificationCryptoException.class) + .hasMessageContaining("authentication"); + NotificationCiphertext wrongVersion = + new NotificationCiphertext( + encrypted.algorithm(), + encrypted.keyReference(), + "payload-r1", + encrypted.cryptoProfileVersion(), + encrypted.aadRevision(), + encrypted.nonce(), + encrypted.ciphertext()); + assertThatThrownBy(() -> crypto.decrypt(wrongVersion, parameters)) + .isInstanceOf(NotificationCryptoException.class) + .hasMessageNotContaining(Arrays.toString(InMemoryKeys.PAYLOAD_R1)); + } + + @Test + void acquiredMutableKeyHandleWipesOnCloseAndRejectsUseAfterClose() { + AtomicReference borrowed = new AtomicReference<>(); + NotificationKeyMaterialHandle handle = + NotificationKeyMaterialHandle.fromBytes( + "payload-key", "payload-r2", InMemoryKeys.PAYLOAD_R2); + handle.readBytes( + bytes -> { + borrowed.set(bytes); + return null; + }); + + handle.close(); + + assertThat(borrowed.get()).containsOnly((byte) 0); + assertThatThrownBy(() -> handle.readBytes(bytes -> bytes.length)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("closed"); + assertThat(handle.toString()).doesNotContain(Arrays.toString(InMemoryKeys.PAYLOAD_R2)); + } + + private static NotificationCiphertext.AadContext context(String recordId, String purpose) { + return new NotificationCiphertext.AadContext( + "notification_intent", + recordId, + "intent-42", + Optional.of("delivery-42"), + Optional.of("attempt-42"), + purpose, + "provider-binding-r3", + "notification-direct-aead-v1"); + } + + static final class InMemoryKeys implements NotificationKeyMaterialProvider { + + static final byte[] PAYLOAD_R1 = "1".repeat(32).getBytes(StandardCharsets.US_ASCII); + static final byte[] PAYLOAD_R2 = "2".repeat(32).getBytes(StandardCharsets.US_ASCII); + static final byte[] HMAC_R1 = "3".repeat(32).getBytes(StandardCharsets.US_ASCII); + static final byte[] HMAC_R2 = "4".repeat(32).getBytes(StandardCharsets.US_ASCII); + + private final Map keys; + private final Map> verificationVersions; + + private InMemoryKeys( + Map keys, Map> verificationVersions) { + this.keys = keys; + this.verificationVersions = verificationVersions; + } + + static InMemoryKeys standard() { + return new InMemoryKeys( + Map.of( + "payload-key:payload-r1", + PAYLOAD_R1, + "payload-key:payload-r2", + PAYLOAD_R2, + "alias-key:hmac-r1", + HMAC_R1, + "alias-key:hmac-r2", + HMAC_R2), + Map.of("alias-key", List.of("hmac-r2", "hmac-r1"))); + } + + @Override + public NotificationKeyMaterialHandle acquire(String keyReference, String keyVersion) { + byte[] key = keys.get(keyReference + ":" + keyVersion); + if (key == null) { + throw new NotificationCryptoException("notification key revision is unavailable"); + } + return NotificationKeyMaterialHandle.fromBytes(keyReference, keyVersion, key); + } + + @Override + public List verificationVersions(String keyReference) { + return verificationVersions.getOrDefault(keyReference, List.of()); + } + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringTransactionPortTest.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringTransactionPortTest.java index dd18f74f..f8626b2f 100644 --- a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringTransactionPortTest.java +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringTransactionPortTest.java @@ -1,18 +1,30 @@ package dev.caskeleton.adapter.outbound.persistence.transaction; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import dev.caskeleton.application.transaction.NestedRootTransactionRejectedException; import java.util.ArrayList; import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.springframework.transaction.PlatformTransactionManager; import org.springframework.transaction.TransactionDefinition; import org.springframework.transaction.TransactionException; import org.springframework.transaction.TransactionStatus; +import org.springframework.transaction.TransactionSystemException; import org.springframework.transaction.support.SimpleTransactionStatus; +import org.springframework.transaction.support.TransactionSynchronizationManager; class SpringTransactionPortTest { + @AfterEach + void clearTransactionState() { + TransactionSynchronizationManager.clear(); + } + @Test void inWriteUsesRequiredPropagationReadCommittedIsolationAndNotReadOnly() { RecordingTransactionManager tm = new RecordingTransactionManager(); @@ -48,6 +60,88 @@ class SpringTransactionPortTest { assertThat(definition.isReadOnly()).isTrue(); } + @Test + void inRootWriteUsesRequiredPropagationReadCommittedIsolationAndNotReadOnly() { + RecordingTransactionManager tm = new RecordingTransactionManager(); + SpringTransactionPort port = new SpringTransactionPort(tm); + + String result = port.inRootWrite(() -> "ok"); + + assertThat(result).isEqualTo("ok"); + assertThat(tm.definitions).hasSize(1); + TransactionDefinition definition = tm.definitions.get(0); + assertThat(definition.getPropagationBehavior()) + .isEqualTo(TransactionDefinition.PROPAGATION_REQUIRED); + assertThat(definition.getIsolationLevel()) + .isEqualTo(TransactionDefinition.ISOLATION_READ_COMMITTED); + assertThat(definition.isReadOnly()).isFalse(); + assertThat(tm.commits).isOne(); + assertThat(tm.rollbacks).isZero(); + } + + @Test + void inRootWriteRejectsAmbientActualTransactionBeforeActionOrTransactionManagerSideEffects() { + RecordingTransactionManager tm = new RecordingTransactionManager(); + SpringTransactionPort port = new SpringTransactionPort(tm); + AtomicBoolean actionCalled = new AtomicBoolean(); + TransactionSynchronizationManager.setActualTransactionActive(true); + + assertThatThrownBy( + () -> + port.inRootWrite( + () -> { + actionCalled.set(true); + return "not-visible"; + })) + .isInstanceOf(NestedRootTransactionRejectedException.class); + + assertThat(actionCalled).isFalse(); + assertThat(tm.definitions).isEmpty(); + assertThat(tm.commitAttempts).isZero(); + assertThat(tm.rollbacks).isZero(); + } + + @Test + void inRootWriteReturnsOnlyAfterPhysicalCommitCompletes() { + RecordingTransactionManager tm = new RecordingTransactionManager(); + SpringTransactionPort port = new SpringTransactionPort(tm); + + String result = + port.inRootWrite( + () -> { + tm.lifecycle.add("action"); + return "committed"; + }); + tm.lifecycle.add("returned"); + + assertThat(result).isEqualTo("committed"); + assertThat(tm.lifecycle).containsExactly("begin", "action", "commit", "returned"); + } + + @Test + void inRootWritePropagatesCommitFailureWithoutPublishingCallerVisibleResult() { + RecordingTransactionManager tm = new RecordingTransactionManager(); + tm.failCommit = true; + SpringTransactionPort port = new SpringTransactionPort(tm); + AtomicReference callerVisible = new AtomicReference<>(); + + assertThatThrownBy( + () -> + callerVisible.set( + port.inRootWrite( + () -> { + tm.lifecycle.add("action"); + return "must-not-be-visible"; + }))) + .isInstanceOf(TransactionSystemException.class) + .hasMessageContaining("commit failed"); + + assertThat(callerVisible).hasValue(null); + assertThat(tm.commitAttempts).isOne(); + assertThat(tm.commits).isZero(); + assertThat(tm.lifecycle).containsExactly("begin", "action", "commit-failed"); + } + @Test void inNewUsesRequiresNewPropagationReadCommittedIsolationAndNotReadOnly() { RecordingTransactionManager tm = new RecordingTransactionManager(); @@ -116,6 +210,9 @@ class SpringTransactionPortTest { private static final class RecordingTransactionManager implements PlatformTransactionManager { private final List definitions = new ArrayList<>(); + private final List lifecycle = new ArrayList<>(); + private boolean failCommit; + private int commitAttempts; private int commits; private int rollbacks; @@ -123,11 +220,18 @@ class SpringTransactionPortTest { public TransactionStatus getTransaction(TransactionDefinition definition) throws TransactionException { definitions.add(definition); + lifecycle.add("begin"); return new SimpleTransactionStatus(true); } @Override public void commit(TransactionStatus status) throws TransactionException { + commitAttempts++; + if (failCommit) { + lifecycle.add("commit-failed"); + throw new TransactionSystemException("commit failed"); + } + lifecycle.add("commit"); commits++; } diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/ArchitectureViolationFixtureTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/ArchitectureViolationFixtureTest.java index 514af5dc..46d1ca46 100644 --- a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/ArchitectureViolationFixtureTest.java +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/ArchitectureViolationFixtureTest.java @@ -6,6 +6,7 @@ import com.tngtech.archunit.core.domain.JavaClasses; import com.tngtech.archunit.core.importer.ClassFileImporter; import com.tngtech.archunit.lang.EvaluationResult; import dev.caskeleton.application.architecture.violations.ApplicationDiagnosticFrameworkViolation; +import dev.caskeleton.bootstrap.architecture.fixtures.application.RootWriteTransactionBoundaryUseCase; import dev.caskeleton.bootstrap.architecture.allowed.application.CleanProjectionQueryPort; import dev.caskeleton.bootstrap.architecture.violations.application.BulkWriteWithoutWriteAccessUseCase; import dev.caskeleton.bootstrap.architecture.violations.application.FixtureRepository; @@ -59,6 +60,8 @@ class ArchitectureViolationFixtureTest { new ClassFileImporter().importClasses(JakartaValidationApplicationFixture.class); private static final JavaClasses APPLICATION_DIAGNOSTIC_FRAMEWORK_FIXTURE_ONLY = new ClassFileImporter().importClasses(ApplicationDiagnosticFrameworkViolation.class); + private static final JavaClasses ROOT_WRITE_TRANSACTION_BOUNDARY_FIXTURE_ONLY = + new ClassFileImporter().importClasses(RootWriteTransactionBoundaryUseCase.class); // Each WebSocket fixture is imported in ISOLATION so the two package globs in // NO_WEBSOCKET_HANDLER ("org.springframework.web.socket.." vs "jakarta.websocket..") @@ -261,10 +264,23 @@ class ArchitectureViolationFixtureTest { .as( "USE_CASE_CAPABILITY_MATCHES_TRANSACTION_PORT_BOUNDARY must catch " + "MissingTransactionBoundaryUseCase declaring WRITE_REPOSITORY without " - + "TransactionPort.inWrite") + + "TransactionPort.inWrite or TransactionPort.inRootWrite") .isTrue(); } + @Test + void useCaseCapabilityMatchesTransactionPortBoundaryAllowsRootWriteBoundary() { + EvaluationResult result = + CleanArchitectureTest.USE_CASE_CAPABILITY_MATCHES_TRANSACTION_PORT_BOUNDARY.evaluate( + ROOT_WRITE_TRANSACTION_BOUNDARY_FIXTURE_ONLY); + + assertThat(result.hasViolation()) + .as( + "USE_CASE_CAPABILITY_MATCHES_TRANSACTION_PORT_BOUNDARY must allow a " + + "WRITE_REPOSITORY use case that directly calls TransactionPort.inRootWrite") + .isFalse(); + } + @Test void sharedContractScopeRuleCatchesDomainSpecificSharedPackage() { EvaluationResult result = diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/CleanArchitectureTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/CleanArchitectureTest.java index f259fa97..da0ee25c 100644 --- a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/CleanArchitectureTest.java +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/CleanArchitectureTest.java @@ -387,7 +387,7 @@ class CleanArchitectureTest { "feature-domain-feature-onboarding-contract D4: a use case that declares a " + "repository-backed transaction capability must call the matching " + "TransactionPort boundary directly: READ_REPOSITORY+READ_ONLY -> inRead, " - + "WRITE_REPOSITORY+WRITE -> inWrite, REQUIRES_NEW -> inNew. " + + "WRITE_REPOSITORY+WRITE -> inWrite or inRootWrite, REQUIRES_NEW -> inNew. " + "RepositoryAccess.NONE may intentionally skip a DB transaction. " + "UNSUPPORTED_IMPL_DECISION: static analysis reaches direct calls only; a " + "transaction hidden behind a helper remains a code-review concern.") @@ -583,24 +583,24 @@ class CleanArchitectureTest { String transactionMode = enumAnnotationValue(annotation, "transactionMode"); String repositoryAccess = enumAnnotationValue(annotation, "repositoryAccess"); - String requiredMethod = null; + Set requiredMethods = Set.of(); if ("REQUIRES_NEW".equals(transactionMode)) { - requiredMethod = "inNew"; + requiredMethods = Set.of("inNew"); } else if ("WRITE".equals(transactionMode) && "WRITE_REPOSITORY".equals(repositoryAccess)) { - requiredMethod = "inWrite"; + requiredMethods = Set.of("inWrite", "inRootWrite"); } else if ("READ_ONLY".equals(transactionMode) && "READ_REPOSITORY".equals(repositoryAccess)) { - requiredMethod = "inRead"; + requiredMethods = Set.of("inRead"); } - if (requiredMethod == null) { + if (requiredMethods.isEmpty()) { return; } for (JavaMethodCall call : item.getMethodCallsFromSelf()) { if ("dev.caskeleton.application.transaction.TransactionPort" .equals(call.getTargetOwner().getFullName()) - && requiredMethod.equals(call.getName())) { + && requiredMethods.contains(call.getName())) { return; } } @@ -614,8 +614,8 @@ class CleanArchitectureTest { + transactionMode + ", repositoryAccess = " + repositoryAccess - + ") but does not directly call TransactionPort." - + requiredMethod + + ") but does not directly call one of TransactionPort." + + requiredMethods + "(...)")); } }; @@ -1231,8 +1231,9 @@ class CleanArchitectureTest { static final ArchRule OUTBOUND_ADAPTER_METHOD_RETURNS_ONLY_DOMAIN_OR_PRIMITIVES = methods() .that() - .areDeclaredInClassesThat() - .resideInAPackage("..adapter.outbound..") + .areDeclaredInClassesThat( + JavaClass.Predicates.resideInAPackage("..adapter.outbound..") + .and(implementsApplicationLayerPort())) // @Configuration factory classes are excluded: a @Bean method legitimately // returns the adapter's own outbound port type for DI wiring (e.g. // MessagingConfig#messagePublisher -> MessagePublisher) — that is @@ -1263,7 +1264,7 @@ class CleanArchitectureTest { "..adapter.inbound.web..", "..adapter.outbound.persistence..")) .as( - "B7: outbound adapter public methods must return domain types (or " + "B7: outbound application-port adapter public methods must return domain types (or " + "primitives/wrappers/Optional) — raw external response types must not " + "escape the adapter package " + "(feature-boundary-validation-mapping-contract B7 ACL). @Configuration " @@ -1272,6 +1273,17 @@ class CleanArchitectureTest { + "response surfaces.") .allowEmptyShould(true); + private static DescribedPredicate implementsApplicationLayerPort() { + return DescribedPredicate.describe( + "implement an application-layer port", + type -> + type.getAllRawInterfaces().stream() + .anyMatch( + iface -> + iface.getPackageName().contains(".application.") + || iface.getPackageName().endsWith(".application"))); + } + @ArchTest static final ArchRule VALID_CASCADE_DEPTH_AT_MOST_THREE = classes() diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/fixtures/application/RootWriteTransactionBoundaryUseCase.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/fixtures/application/RootWriteTransactionBoundaryUseCase.java new file mode 100644 index 00000000..229909fa --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/fixtures/application/RootWriteTransactionBoundaryUseCase.java @@ -0,0 +1,33 @@ +package dev.caskeleton.bootstrap.architecture.fixtures.application; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.command.Command; +import dev.caskeleton.application.security.RequiresPermission; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.CommandUseCase; + +/** Positive fixture: a root-only write boundary satisfies the WRITE_REPOSITORY fitness rule. */ +@UseCaseCapability( + transactionMode = TransactionMode.WRITE, + idempotency = Idempotency.NOT_IDEMPOTENT, + repositoryAccess = RepositoryAccess.WRITE_REPOSITORY) +@RequiresPermission("fixture:root-write") +public final class RootWriteTransactionBoundaryUseCase + implements CommandUseCase { + + private final TransactionPort transactionPort; + + public RootWriteTransactionBoundaryUseCase(TransactionPort transactionPort) { + this.transactionPort = transactionPort; + } + + @Override + public String handle(CommandFixture command) { + return transactionPort.inRootWrite(command::value); + } + + public record CommandFixture(String value) implements Command {} +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/adapter/outbound/RawTypeLeakingAdapterFixture.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/adapter/outbound/RawTypeLeakingAdapterFixture.java index d7a3e0f4..d22619c8 100644 --- a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/adapter/outbound/RawTypeLeakingAdapterFixture.java +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/adapter/outbound/RawTypeLeakingAdapterFixture.java @@ -1,11 +1,14 @@ package dev.caskeleton.bootstrap.architecture.violations.adapter.outbound; +import dev.caskeleton.bootstrap.architecture.violations.application.RawLeakPortFixture; + /** * Negative fixture for {@code OUTBOUND_ADAPTER_METHOD_RETURNS_ONLY_DOMAIN_OR_PRIMITIVES}. Returns a * class still inside the outbound adapter package — the ACL bypass the contract forbids (B7). */ -public class RawTypeLeakingAdapterFixture { +public class RawTypeLeakingAdapterFixture implements RawLeakPortFixture { + @Override public RawExternalResponseFixture leakRaw() { return new RawExternalResponseFixture(); } diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/application/MissingTransactionBoundaryUseCase.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/application/MissingTransactionBoundaryUseCase.java index 1cf3dc55..c04610c4 100644 --- a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/application/MissingTransactionBoundaryUseCase.java +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/application/MissingTransactionBoundaryUseCase.java @@ -8,7 +8,10 @@ import dev.caskeleton.application.security.RequiresPermission; import dev.caskeleton.application.transaction.TransactionMode; import dev.caskeleton.application.usecase.CommandUseCase; -/** Intentional write-use-case violation: declares a write but skips TransactionPort.inWrite. */ +/** + * Intentional write-use-case violation: declares a write but skips both TransactionPort.inWrite and + * TransactionPort.inRootWrite. + */ @UseCaseCapability( transactionMode = TransactionMode.WRITE, idempotency = Idempotency.NOT_IDEMPOTENT, diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/application/RawLeakPortFixture.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/application/RawLeakPortFixture.java new file mode 100644 index 00000000..f063428c --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/application/RawLeakPortFixture.java @@ -0,0 +1,7 @@ +package dev.caskeleton.bootstrap.architecture.violations.application; + +/** Application-port side of the B7 raw adapter response negative fixture. */ +public interface RawLeakPortFixture { + + Object leakRaw(); +} diff --git a/src/application-core/CLAUDE.md b/src/application-core/CLAUDE.md index 93651035..e0b1aca7 100644 --- a/src/application-core/CLAUDE.md +++ b/src/application-core/CLAUDE.md @@ -56,13 +56,37 @@ Package root: `dev.caskeleton.application`. | `usecase.QueryUseCase` | Inbound port for read-only use cases. Implementations MUST declare `transactionMode = READ_ONLY` and `repositoryAccess = READ_REPOSITORY`. | | `command.Command` | Marker for write intents. Plain immutable types built from domain values. | | `query.Query` | Marker for read intents. Plain immutable types built from domain values. | -| `transaction.TransactionPort` | Outbound port for transactional boundaries. Implemented by `adapter-persistence`. | +| `transaction.TransactionPort` | Outbound port for join-capable write/read, physical root-only write, and independent write boundaries. Implemented by `adapter-persistence`. | +| `transaction.NestedRootTransactionRejectedException` | Fail-fast signal raised before action/provider side effects when `inRootWrite` detects an actual ambient transaction. | | `transaction.TransactionMode` | `WRITE` / `READ_ONLY` / `REQUIRES_NEW`. `NESTED` and `NEVER` are intentionally absent. | | `transaction.Isolation` | `READ_COMMITTED` (pinned default) / `REPEATABLE_READ` / `SERIALIZABLE`. `READ_UNCOMMITTED` is forbidden (not declared); the vendor default is never used (engine defaults differ — PostgreSQL READ COMMITTED vs MySQL InnoDB REPEATABLE READ). Routing the stricter levels through `TransactionPort` is a `planned` joint change with `feature-application-port-usecase-contract`; the shipped call path pins `READ_COMMITTED`. | | `capability.UseCaseCapability` | Mandatory annotation on every concrete use case: declares `transactionMode`, `idempotency`, `repositoryAccess`, `externalOutboundAllowed`. | | `capability.Idempotency` | `IDEMPOTENT` / `KEYED` / `NOT_IDEMPOTENT`. | | `capability.RepositoryAccess` | `NONE` / `READ_REPOSITORY` / `WRITE_REPOSITORY`. | +## Notification R1 application boundary + +- `dev.caskeleton.application.notification` owns only framework-free semantic values, code-owned + kind policy, narrow outbound ports, dispatch/receipt/admission/reconciliation orchestration and + writer-cutover command contracts. +- Feature/application code creates a typed `NotificationIntentDraft`; `NotificationPlanPort` + returns the application-owned immutable `NotificationFrozenPlan`, which is the only planning + handoff consumed by append or inline attempt ports. Provider SDK, transport DTO, persistence + entity, compiled adapter binding and raw recipient/template payload types are forbidden here. +- Provider calls run outside database transactions. Dispatch and reconciliation use bounded + claim/authorize/finalize transactions with opaque claim/version/execution tokens; an + `INDETERMINATE` submission is terminal and must not be blindly retried. +- Receipt reduction is order-independent and keeps delivery acceptance monotonic. Only hard bounce + and complaint facts may request technical suppression; consent/unsubscribe policy is outside this + capability. +- Writer-cutover operations that must prove a physical commit use `inRootWrite`. Route/profile + registries are application-owned exact inputs; signed inventory/quiescence verification is + delegated to narrow verifier ports and the persistence operation must enforce locked durable + state/journal invariants. +- This is the R1 application contract proven with fakes. It does not claim PostgreSQL schema/locking, + provider protocol, cryptographic verifier, or runtime wiring qualification; those belong to the + notification/persistence/bootstrap adapters. + ## Naming convention - Inbound port implementations end with `UseCase` (e.g. `RegisterUserUseCase`). Enforced by ArchUnit. @@ -103,10 +127,15 @@ application-core never self-registers with a DI framework. | Use case shape | `transactionMode` | TransactionPort call | When | |---|---|---|---| | Write command | `WRITE` | `tx.inWrite(...)` | Default for `CommandUseCase`. | +| Physical-root write command | `WRITE` | `tx.inRootWrite(...)` | Only when orchestration must prove there is no ambient transaction and expose a result after commit. | | Read-only query | `READ_ONLY` | `tx.inRead(...)` | Default for `QueryUseCase`. | | Outbox / audit / compensation | `REQUIRES_NEW` | `tx.inNew(...)` | Only when the use case MUST commit independently of the caller. | `NESTED` and `NEVER` propagation are forbidden. +`inRootWrite` MUST reject an actual ambient transaction before invoking its action or +`PlatformTransactionManager`; it MUST NOT emulate root-only behavior with `REQUIRES_NEW`. +Both `inWrite` and `inRootWrite` satisfy the direct boundary fitness rule for a +`WRITE_REPOSITORY + WRITE` use case. READ and REQUIRES_NEW mappings remain exclusive. ### Callback signature contract (D11) diff --git a/src/application-core/README.md b/src/application-core/README.md index 124f0c41..915d8db1 100644 --- a/src/application-core/README.md +++ b/src/application-core/README.md @@ -71,13 +71,21 @@ - **존재 이유**: application 유스케이스가 `org.springframework.transaction.annotation.Transactional` 을 import 하지 않고도 트랜잭션 의도를 선언하게 하기 위한 추상화다. 구현(보통 `SpringTransactionPort`)은 persistence adapter 가 Spring `PlatformTransactionManager` 로 제공한다. - application/domain 을 프레임워크-free 로 유지하는 핵심 장치. -- 세 가지 경계: +application/domain 을 프레임워크-free 로 유지하는 핵심 장치. +- 네 가지 경계: - `inWrite` — REQUIRED + read-write, `READ_COMMITTED`. command 유스케이스 기본. + - `inRootWrite` — 물리 root 전용 REQUIRED + read-write, `READ_COMMITTED`. 실제 ambient + transaction 이 하나라도 있으면 action 실행 전에 + `NestedRootTransactionRejectedException` 으로 거부한다. 성공 값은 commit 이 끝난 뒤에만 + 호출자에게 반환되며, commit 실패는 그대로 전파된다. - `inRead` — REQUIRED + read-only, `READ_COMMITTED`. query 유스케이스 기본. - `inNew` — REQUIRES_NEW + read-write. UseCaseCapability 에 `REQUIRES_NEW` 를 명시한 유스케이스(outbox/audit/compensation)에서만 허용. -- **콜백 시그니처(D11)**: 세 메서드 모두 `Supplier`/`Runnable` 을 받아 checked exception 을 던질 +- **root-only 사용 조건**: `inRootWrite` 는 join 가능한 일반 command 경계의 대체물이 아니다. + 외부 효과를 commit 이후에만 시작해야 하는 orchestration처럼 물리 root를 증명해야 하는 경우에만 + 쓴다. 기존 transaction 안에서 `REQUIRES_NEW` 로 몰래 분리하지 않고 fail-fast하므로, 호출자는 + transaction 없는 진입점에서 이 경계를 시작해야 한다. +- **콜백 시그니처(D11)**: 네 메서드 모두 `Supplier`/`Runnable` 을 받아 checked exception 을 던질 수 없다. Spring `TransactionCallback` 제약과 동일하다. 그래서 호출자는 도메인 checked exception 을 `RuntimeException` 하위로 감싸야 한다(`DomainException extends RuntimeException`). `IOException` → `UncheckedIOException`, `SQLException` 은 Spring `DataAccessException` 계층이 @@ -93,7 +101,33 @@ **금지**: 많은 레코드를 도는 루프 안에서 `inNew` 호출(예: per-row outbox dispatch). 풀 고갈 + 데드락 위험. 레코드를 한 번의 `inNew` 안에서 배치 처리하거나, 루프를 트랜잭션 경계 밖으로 빼라. - **금지 목록**: `NESTED`/`NEVER` propagation, `READ_UNCOMMITTED` isolation, application 패키지에서 - `@Transactional` 직접 사용, `inNew` 의 per-record 루프 호출. + `@Transactional` 직접 사용, `inRootWrite` 의 ambient transaction 진입, `inNew` 의 per-record + 루프 호출. + +--- + +## Notification R1 오케스트레이션 경계 + +`dev.caskeleton.application.notification`은 알림 vendor 구현이 아니라 알림 capability의 순수 +애플리케이션 계약이다. + +- 입력은 typed recipient/template value와 코드 소유 `NotificationKindPolicy`로 제한한다. feature가 + 만든 `NotificationIntentDraft`는 `NotificationPlanPort`에서 immutable + `NotificationFrozenPlan`으로 고정되고, append/inline 포트는 이 plan만 소비한다. +- dispatch는 claim → reserve/authorize → provider call → terminal-once finalize 순서다. 짧은 DB + transaction 사이에서 provider를 호출하며, opaque claim/version/execution token으로 stale 결과를 + 거부한다. submission certainty가 `INDETERMINATE`면 blind retry나 fallback을 하지 않는다. +- receipt reducer는 fact 순서와 무관한 monotonic projection을 만든다. hard bounce/complaint만 + technical suppression 후보이고, business consent/unsubscribe는 다른 capability가 소유한다. +- admission/reconciliation/maintenance는 bounded batch와 주입된 `Clock`을 사용한다. scheduler는 + 이 유스케이스만 호출하며 store/provider 포트를 직접 조율하지 않는다. +- legacy→canonical writer cutover는 exact route/generation/profile registry, root-only commit, + 서명된 inventory/quiescence evidence와 closed transition action으로 표현한다. 애플리케이션은 + verifier/operation 포트의 입력 계약을 강제하고, 실제 서명 검증·행 잠금·불변 journal·provider + egress 차단은 후속 adapter 구현이 증명해야 한다. + +현재 증거 등급은 **R1 application contract with fakes**다. PostgreSQL DDL/locking, provider +protocol, receipt ingress, runtime wiring을 포함한 R2/R3 완료 주장이 아니다. ### TransactionMode diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/ApplyNotificationReceiptCommand.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/ApplyNotificationReceiptCommand.java new file mode 100644 index 00000000..5b5e21ba --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/ApplyNotificationReceiptCommand.java @@ -0,0 +1,13 @@ +package dev.caskeleton.application.notification; + +import dev.caskeleton.application.command.Command; +import java.util.Objects; + +/** Applies one already authenticated and normalized receipt. */ +public record ApplyNotificationReceiptCommand(NormalizedNotificationReceiptCommand receipt) + implements Command { + + public ApplyNotificationReceiptCommand { + Objects.requireNonNull(receipt, "normalized notification receipt must be non-null"); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/ApplyNotificationReceiptResult.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/ApplyNotificationReceiptResult.java new file mode 100644 index 00000000..2dc4c007 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/ApplyNotificationReceiptResult.java @@ -0,0 +1,21 @@ +package dev.caskeleton.application.notification; + +import java.util.Objects; + +/** Non-sensitive result of reducing one receipt event. */ +public record ApplyNotificationReceiptResult( + Status status, NotificationReceiptProjection projection, boolean suppressionApplied) { + + public ApplyNotificationReceiptResult { + Objects.requireNonNull(status, "notification receipt apply status must be non-null"); + Objects.requireNonNull(projection, "notification receipt projection must be non-null"); + if (status == Status.DUPLICATE && suppressionApplied) { + throw new IllegalArgumentException("duplicate receipt cannot repeat technical suppression"); + } + } + + public enum Status { + APPLIED, + DUPLICATE + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/ApplyNotificationReceiptUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/ApplyNotificationReceiptUseCase.java new file mode 100644 index 00000000..74c12ea8 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/ApplyNotificationReceiptUseCase.java @@ -0,0 +1,80 @@ +package dev.caskeleton.application.notification; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.security.RequiresPermission; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.CommandUseCase; +import java.time.Clock; +import java.util.Objects; + +/** + * Appends one receipt fact and reduces its delivery projection in one physical root transaction. + */ +@RequiresPermission("notification:receipt") +@UseCaseCapability( + transactionMode = TransactionMode.WRITE, + idempotency = Idempotency.IDEMPOTENT, + repositoryAccess = RepositoryAccess.WRITE_REPOSITORY) +public final class ApplyNotificationReceiptUseCase + implements CommandUseCase { + + private final NotificationReceiptStorePort store; + private final NotificationTechnicalSuppressionPort suppression; + private final TransactionPort transactions; + private final Clock clock; + + public ApplyNotificationReceiptUseCase( + NotificationReceiptStorePort store, + NotificationTechnicalSuppressionPort suppression, + TransactionPort transactions, + Clock clock) { + this.store = Objects.requireNonNull(store, "notification receipt store must be non-null"); + this.suppression = + Objects.requireNonNull(suppression, "notification suppression port must be non-null"); + this.transactions = Objects.requireNonNull(transactions, "transaction port must be non-null"); + this.clock = Objects.requireNonNull(clock, "clock must be non-null"); + } + + @Override + public ApplyNotificationReceiptResult handle(ApplyNotificationReceiptCommand command) { + Objects.requireNonNull(command, "apply notification receipt command must be non-null"); + return transactions.inRootWrite(() -> applyInsideRoot(command.receipt())); + } + + private ApplyNotificationReceiptResult applyInsideRoot( + NormalizedNotificationReceiptCommand command) { + NotificationReceiptStorePort.AppendResult appendResult = store.appendIfAbsent(command); + if (appendResult instanceof NotificationReceiptStorePort.Duplicate duplicate) { + return new ApplyNotificationReceiptResult( + ApplyNotificationReceiptResult.Status.DUPLICATE, duplicate.projection(), false); + } + + NotificationReceiptStorePort.ReceiptAggregate aggregate = + ((NotificationReceiptStorePort.Appended) appendResult).aggregate(); + if (!aggregate.deliveryId().equals(command.deliveryId())) { + throw new IllegalStateException( + "receipt aggregate delivery does not match normalized command"); + } + NotificationReceiptProjection projection = + NotificationReceiptProjection.reduce(aggregate.facts()); + store.saveProjection(aggregate.deliveryId(), projection); + + boolean suppressionApplied = shouldSuppress(command.fact()); + if (suppressionApplied) { + suppression.suppress( + new NotificationTechnicalSuppressionPort.SuppressionMutation( + aggregate.recipient(), command.fact().reasonCode(), clock.instant())); + } + return new ApplyNotificationReceiptResult( + ApplyNotificationReceiptResult.Status.APPLIED, projection, suppressionApplied); + } + + private static boolean shouldSuppress(NotificationReceiptFact fact) { + return fact.type() == NotificationReceiptFact.Type.COMPLAINT + || (fact.type() == NotificationReceiptFact.Type.BOUNCE + && fact.bounceClass() == NotificationReceiptFact.BounceClass.HARD); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/ConsentCheckMode.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/ConsentCheckMode.java new file mode 100644 index 00000000..f24e7fc6 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/ConsentCheckMode.java @@ -0,0 +1,7 @@ +package dev.caskeleton.application.notification; + +/** Point at which recipient consent or preference must be established. */ +public enum ConsentCheckMode { + SNAPSHOT_AT_APPEND, + RECHECK_BEFORE_EACH_DELIVERY +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/EmailRecipientReference.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/EmailRecipientReference.java new file mode 100644 index 00000000..2db363f0 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/EmailRecipientReference.java @@ -0,0 +1,19 @@ +package dev.caskeleton.application.notification; + +/** Opaque reference resolved to an email recipient only inside a qualified adapter. */ +public record EmailRecipientReference(String reference) implements NotificationRecipientReference { + + public EmailRecipientReference { + reference = NotificationIntentId.requireOpaque("email recipient reference", reference); + } + + @Override + public NotificationChannel channel() { + return NotificationChannel.EMAIL; + } + + @Override + public String toString() { + return "EmailRecipientReference[reference=]"; + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/InitializeNotificationWriterFencesCommand.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/InitializeNotificationWriterFencesCommand.java new file mode 100644 index 00000000..34e689fb --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/InitializeNotificationWriterFencesCommand.java @@ -0,0 +1,32 @@ +package dev.caskeleton.application.notification; + +import dev.caskeleton.application.command.Command; +import java.util.Objects; + +/** Reviewed all-route initialization request; partial route sets are rejected by the use case. */ +public record InitializeNotificationWriterFencesCommand( + String operationToken, + NotificationCanonicalWriterRouteSet reviewedRoutes, + String reviewedRouteSetDigest, + String actorReference, + NotificationReasonCode reasonCode) + implements Command { + + public InitializeNotificationWriterFencesCommand { + operationToken = + NotificationIntentId.requireOpaque("writer initialization operation token", operationToken); + Objects.requireNonNull(reviewedRoutes, "reviewed writer routes must be non-null"); + reviewedRouteSetDigest = requireDigest(reviewedRouteSetDigest); + actorReference = + NotificationIntentId.requireOpaque("writer initialization actor", actorReference); + Objects.requireNonNull(reasonCode, "notification reason code must be non-null"); + } + + static String requireDigest(String digest) { + if (digest == null || !digest.matches("[0-9a-f]{64}")) { + throw new IllegalArgumentException( + "writer evidence digest must be 64 lowercase hex characters"); + } + return digest; + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/InitializeNotificationWriterFencesOperation.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/InitializeNotificationWriterFencesOperation.java new file mode 100644 index 00000000..809f55b5 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/InitializeNotificationWriterFencesOperation.java @@ -0,0 +1,13 @@ +package dev.caskeleton.application.notification; + +import java.time.Instant; + +/** Atomic persistence operation for absent-fence and empty-journal initialization. */ +@FunctionalInterface +public interface InitializeNotificationWriterFencesOperation { + + InitializeNotificationWriterFencesResult initialize( + InitializeNotificationWriterFencesCommand command, + NotificationWriterRouteSet trustedRoutes, + Instant requestedAt); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/InitializeNotificationWriterFencesResult.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/InitializeNotificationWriterFencesResult.java new file mode 100644 index 00000000..850b3fe0 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/InitializeNotificationWriterFencesResult.java @@ -0,0 +1,21 @@ +package dev.caskeleton.application.notification; + +import java.util.Objects; + +/** Committed all-route fence initialization result. */ +public record InitializeNotificationWriterFencesResult( + Status status, int initializedRouteCount, String routeSetDigest) { + + public InitializeNotificationWriterFencesResult { + Objects.requireNonNull(status, "writer initialization status must be non-null"); + if (initializedRouteCount < 1 || initializedRouteCount > 100) { + throw new IllegalArgumentException("initialized writer route count must be in 1..100"); + } + routeSetDigest = InitializeNotificationWriterFencesCommand.requireDigest(routeSetDigest); + } + + public enum Status { + INITIALIZED, + REPLAYED + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/InitializeNotificationWriterFencesUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/InitializeNotificationWriterFencesUseCase.java new file mode 100644 index 00000000..0d079842 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/InitializeNotificationWriterFencesUseCase.java @@ -0,0 +1,55 @@ +package dev.caskeleton.application.notification; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.security.RequiresPermission; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.CommandUseCase; +import java.time.Clock; +import java.util.Objects; + +/** Root-commits the complete trusted writer fence and proof registry initialization batch. */ +@RequiresPermission("notification:cutover") +@UseCaseCapability( + transactionMode = TransactionMode.WRITE, + idempotency = Idempotency.IDEMPOTENT, + repositoryAccess = RepositoryAccess.WRITE_REPOSITORY, + crossTenantAdmin = true) +public final class InitializeNotificationWriterFencesUseCase + implements CommandUseCase< + InitializeNotificationWriterFencesCommand, InitializeNotificationWriterFencesResult> { + + private final NotificationWriterRouteSet routes; + private final InitializeNotificationWriterFencesOperation operation; + private final TransactionPort transactions; + private final Clock clock; + + public InitializeNotificationWriterFencesUseCase( + NotificationWriterRouteSet routes, + InitializeNotificationWriterFencesOperation operation, + TransactionPort transactions, + Clock clock) { + this.routes = Objects.requireNonNull(routes, "notification writer route set must be non-null"); + this.operation = + Objects.requireNonNull(operation, "writer initialization operation must be non-null"); + this.transactions = Objects.requireNonNull(transactions, "transaction port must be non-null"); + this.clock = Objects.requireNonNull(clock, "clock must be non-null"); + } + + @Override + public InitializeNotificationWriterFencesResult handle( + InitializeNotificationWriterFencesCommand command) { + Objects.requireNonNull(command, "writer initialization command must be non-null"); + if (!command.reviewedRoutes().equals(routes.canonicalRoutes())) { + throw new IllegalArgumentException( + "reviewed writer routes must exactly equal the trusted all-route set"); + } + if (!command.reviewedRouteSetDigest().equals(routes.digest())) { + throw new IllegalArgumentException( + "reviewed writer route-set digest does not match trusted registry digest"); + } + return transactions.inRootWrite(() -> operation.initialize(command, routes, clock.instant())); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/InlineNotificationAttemptPort.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/InlineNotificationAttemptPort.java new file mode 100644 index 00000000..021d0ef3 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/InlineNotificationAttemptPort.java @@ -0,0 +1,11 @@ +package dev.caskeleton.application.notification; + +/** + * Executes one bounded, non-durable inline attempt over a frozen application plan. The caller must + * establish the physical root-write sequencing contract before invoking this port. + */ +@FunctionalInterface +public interface InlineNotificationAttemptPort { + + NotificationRequestResult.InlineCompleted attempt(NotificationFrozenPlan plan); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NormalizedNotificationReceiptCommand.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NormalizedNotificationReceiptCommand.java new file mode 100644 index 00000000..cb7b7169 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NormalizedNotificationReceiptCommand.java @@ -0,0 +1,16 @@ +package dev.caskeleton.application.notification; + +import java.util.Objects; + +/** Framework-free receipt normalized by an authenticated inbound adapter. */ +public record NormalizedNotificationReceiptCommand( + NotificationReceiptEventId receiptEventId, + NotificationDeliveryId deliveryId, + NotificationReceiptFact fact) { + + public NormalizedNotificationReceiptCommand { + Objects.requireNonNull(receiptEventId, "notification receipt event ID must be non-null"); + Objects.requireNonNull(deliveryId, "notification delivery ID must be non-null"); + Objects.requireNonNull(fact, "notification receipt fact must be non-null"); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationAdmissionClass.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationAdmissionClass.java new file mode 100644 index 00000000..d80a25c9 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationAdmissionClass.java @@ -0,0 +1,8 @@ +package dev.caskeleton.application.notification; + +/** Code-owned dispatch admission and fairness class. */ +public enum NotificationAdmissionClass { + SECURITY_CRITICAL, + TRANSACTIONAL, + BULK_LOW_VALUE +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationAdmissionGateCommand.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationAdmissionGateCommand.java new file mode 100644 index 00000000..70072b21 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationAdmissionGateCommand.java @@ -0,0 +1,53 @@ +package dev.caskeleton.application.notification; + +import dev.caskeleton.application.command.Command; +import java.util.Objects; + +/** Audited operator request to re-probe and resume one shared notification admission gate. */ +public record NotificationAdmissionGateCommand( + String operationToken, + NotificationRouteId routeId, + int policyRevision, + NotificationFaultScope faultScope, + String scopeReference, + long expectedGeneration, + int maximumParkedLegs, + String actorReference, + NotificationReasonCode reasonCode) + implements Command { + + public NotificationAdmissionGateCommand { + operationToken = + NotificationIntentId.requireOpaque("admission resume operation token", operationToken); + Objects.requireNonNull(routeId, "notification route ID must be non-null"); + if (policyRevision < 1 || expectedGeneration < 0) { + throw new IllegalArgumentException( + "policy revision must be positive and expected generation non-negative"); + } + if (maximumParkedLegs < 1 || maximumParkedLegs > 100) { + throw new IllegalArgumentException("maximum parked legs must be in 1..100"); + } + Objects.requireNonNull(faultScope, "notification fault scope must be non-null"); + if (faultScope == NotificationFaultScope.DELIVERY) { + throw new IllegalArgumentException("operator admission command cannot target DELIVERY scope"); + } + scopeReference = + NotificationIntentId.requireOpaque("admission scope reference", scopeReference); + actorReference = + NotificationIntentId.requireOpaque("admission resume actor reference", actorReference); + Objects.requireNonNull(reasonCode, "notification reason code must be non-null"); + } + + NotificationAdmissionReadinessPort.ResumeRequest toResumeRequest() { + return new NotificationAdmissionReadinessPort.ResumeRequest( + operationToken, + routeId, + policyRevision, + faultScope, + scopeReference, + expectedGeneration, + maximumParkedLegs, + actorReference, + reasonCode); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationAdmissionGateUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationAdmissionGateUseCase.java new file mode 100644 index 00000000..acd66794 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationAdmissionGateUseCase.java @@ -0,0 +1,89 @@ +package dev.caskeleton.application.notification; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.security.RequiresPermission; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.CommandUseCase; +import java.time.Clock; +import java.util.Objects; + +/** Probes readiness outside a transaction and generation-CAS resumes inside one short write. */ +@RequiresPermission("notification:operate") +@UseCaseCapability( + transactionMode = TransactionMode.WRITE, + idempotency = Idempotency.IDEMPOTENT, + repositoryAccess = RepositoryAccess.WRITE_REPOSITORY, + externalOutboundAllowed = true, + crossTenantAdmin = true) +public final class NotificationAdmissionGateUseCase + implements CommandUseCase< + NotificationAdmissionGateCommand, NotificationAdmissionGateUseCase.Result> { + + private final NotificationAdmissionReadinessPort admission; + private final TransactionPort transactions; + private final Clock clock; + + public NotificationAdmissionGateUseCase( + NotificationAdmissionReadinessPort admission, TransactionPort transactions, Clock clock) { + this.admission = + Objects.requireNonNull(admission, "notification admission port must be non-null"); + this.transactions = Objects.requireNonNull(transactions, "transaction port must be non-null"); + this.clock = Objects.requireNonNull(clock, "clock must be non-null"); + } + + @Override + public Result handle(NotificationAdmissionGateCommand command) { + Objects.requireNonNull(command, "notification admission command must be non-null"); + NotificationAdmissionReadinessPort.ResumeRequest request = command.toResumeRequest(); + NotificationAdmissionReadinessPort.ReadinessProbe probe = + Objects.requireNonNull(admission.probe(request), "readiness probe must be non-null"); + if (!probe.ready()) { + return new Result(Result.Status.NOT_READY, probe.reasonCode()); + } + NotificationAdmissionReadinessPort.ResumeResult resumeResult = + Objects.requireNonNull( + transactions.inWrite(() -> admission.resume(request, probe, clock.instant())), + "notification admission resume result must be non-null"); + validateResumeResult(request, resumeResult); + return switch (resumeResult.status()) { + case RESUMED -> new Result(Result.Status.RESUMED, probe.reasonCode()); + case ALREADY_ACTIVE -> new Result(Result.Status.ALREADY_ACTIVE, probe.reasonCode()); + case STALE_GENERATION -> + new Result( + Result.Status.STALE_GENERATION, + new NotificationReasonCode("STALE_ADMISSION_GENERATION")); + }; + } + + private static void validateResumeResult( + NotificationAdmissionReadinessPort.ResumeRequest request, + NotificationAdmissionReadinessPort.ResumeResult result) { + if (result.processedLegCount() > request.maximumParkedLegs()) { + throw new IllegalArgumentException( + "admission resume processed more parked legs than the requested bound"); + } + if (result.status() == NotificationAdmissionReadinessPort.ResumeStatus.RESUMED + && result.resultingGeneration() != request.expectedGeneration() + 1) { + throw new IllegalArgumentException( + "resumed admission gate must advance the exact expected generation"); + } + } + + public record Result(Status status, NotificationReasonCode reasonCode) { + + public Result { + Objects.requireNonNull(status, "notification admission result status must be non-null"); + Objects.requireNonNull(reasonCode, "notification reason code must be non-null"); + } + + public enum Status { + RESUMED, + ALREADY_ACTIVE, + NOT_READY, + STALE_GENERATION + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationAdmissionReadinessPort.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationAdmissionReadinessPort.java new file mode 100644 index 00000000..5e490d5d --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationAdmissionReadinessPort.java @@ -0,0 +1,154 @@ +package dev.caskeleton.application.notification; + +import java.time.Instant; +import java.util.Objects; + +/** Persists shared route/provider/account admission readiness with generation-guarded CAS. */ +@FunctionalInterface +public interface NotificationAdmissionReadinessPort { + + ParkResult park(ParkRequest request); + + default ReadinessProbe probe(ResumeRequest request) { + throw new UnsupportedOperationException("notification readiness probe is not implemented"); + } + + default ResumeResult resume(ResumeRequest request, ReadinessProbe probe, Instant resumedAt) { + throw new UnsupportedOperationException("notification admission resume is not implemented"); + } + + record ParkRequest( + NotificationRouteId routeId, + int policyRevision, + NotificationFaultScope faultScope, + String scopeReference, + long expectedGeneration, + NotificationReasonCode reasonCode, + Instant parkedAt) { + + public ParkRequest { + Objects.requireNonNull(routeId, "notification route ID must be non-null"); + if (policyRevision < 1 || expectedGeneration < 0) { + throw new IllegalArgumentException( + "policy revision must be positive and expected generation non-negative"); + } + Objects.requireNonNull(faultScope, "notification fault scope must be non-null"); + if (faultScope == NotificationFaultScope.DELIVERY) { + throw new IllegalArgumentException("shared admission gate cannot use DELIVERY fault scope"); + } + scopeReference = + NotificationIntentId.requireOpaque("admission scope reference", scopeReference); + Objects.requireNonNull(reasonCode, "notification reason code must be non-null"); + Objects.requireNonNull(parkedAt, "admission parked time must be non-null"); + } + } + + enum ParkResult { + NOT_REQUESTED, + PARKED, + ALREADY_PARKED, + STALE_GENERATION + } + + record ResumeRequest( + String operationToken, + NotificationRouteId routeId, + int policyRevision, + NotificationFaultScope faultScope, + String scopeReference, + long expectedGeneration, + int maximumParkedLegs, + String actorReference, + NotificationReasonCode reasonCode) { + + public ResumeRequest { + operationToken = + NotificationIntentId.requireOpaque("admission resume operation token", operationToken); + Objects.requireNonNull(routeId, "notification route ID must be non-null"); + if (policyRevision < 1 || expectedGeneration < 0) { + throw new IllegalArgumentException( + "policy revision must be positive and expected generation non-negative"); + } + if (maximumParkedLegs < 1 || maximumParkedLegs > 100) { + throw new IllegalArgumentException("maximum parked legs must be in 1..100"); + } + Objects.requireNonNull(faultScope, "notification fault scope must be non-null"); + if (faultScope == NotificationFaultScope.DELIVERY) { + throw new IllegalArgumentException("shared admission gate cannot use DELIVERY fault scope"); + } + scopeReference = + NotificationIntentId.requireOpaque("admission scope reference", scopeReference); + actorReference = + NotificationIntentId.requireOpaque("admission resume actor reference", actorReference); + Objects.requireNonNull(reasonCode, "notification reason code must be non-null"); + } + } + + record ReadinessProbe(boolean ready, NotificationReasonCode reasonCode) { + + public ReadinessProbe { + Objects.requireNonNull(reasonCode, "notification readiness reason must be non-null"); + } + } + + enum ResumeStatus { + RESUMED, + ALREADY_ACTIVE, + STALE_GENERATION + } + + /** + * Audited bounded result of rechecking every selected parked leg inside the gate-resume + * transaction. Initial R1 never activates fallback while resuming a binding park. + */ + record ResumeResult( + ResumeStatus status, + long resultingGeneration, + int queuedCount, + int expiredCount, + int cancelledCount, + int technicallySuppressedCount, + int policyRejectedCount, + int activatedFallbackCount) { + + public ResumeResult { + Objects.requireNonNull(status, "notification admission resume status must be non-null"); + if (resultingGeneration < 0 + || queuedCount < 0 + || expiredCount < 0 + || cancelledCount < 0 + || technicallySuppressedCount < 0 + || policyRejectedCount < 0 + || activatedFallbackCount < 0) { + throw new IllegalArgumentException( + "notification admission resume generation/counts must be non-negative"); + } + int processedLegCount = + queuedCount + + expiredCount + + cancelledCount + + technicallySuppressedCount + + policyRejectedCount; + if (processedLegCount > 100) { + throw new IllegalArgumentException( + "notification admission resume leg count must be bounded by 100"); + } + if (activatedFallbackCount != 0) { + throw new IllegalArgumentException( + "binding-park resume must not activate initial fallback legs"); + } + if (status != ResumeStatus.RESUMED && processedLegCount != 0) { + throw new IllegalArgumentException( + "non-mutating admission resume status cannot report processed legs"); + } + } + + public int processedLegCount() { + return queuedCount + + expiredCount + + cancelledCount + + technicallySuppressedCount + + policyRejectedCount; + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationAppendResult.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationAppendResult.java new file mode 100644 index 00000000..81c9e512 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationAppendResult.java @@ -0,0 +1,31 @@ +package dev.caskeleton.application.notification; + +import java.util.Objects; + +/** Durable append result; neither appended nor duplicate means provider delivery succeeded. */ +public sealed interface NotificationAppendResult + permits NotificationAppendResult.Appended, + NotificationAppendResult.DuplicateExisting, + NotificationAppendResult.Rejected { + + record Appended(NotificationIntentId intentId) implements NotificationAppendResult { + + public Appended { + Objects.requireNonNull(intentId, "notification intent ID must be non-null"); + } + } + + record DuplicateExisting(NotificationIntentId intentId) implements NotificationAppendResult { + + public DuplicateExisting { + Objects.requireNonNull(intentId, "notification intent ID must be non-null"); + } + } + + record Rejected(NotificationReasonCode reasonCode) implements NotificationAppendResult { + + public Rejected { + Objects.requireNonNull(reasonCode, "notification reason code must be non-null"); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationApplicationException.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationApplicationException.java new file mode 100644 index 00000000..ee5570e9 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationApplicationException.java @@ -0,0 +1,20 @@ +package dev.caskeleton.application.notification; + +import java.util.Objects; + +/** Framework- and provider-neutral application failure carrying only a stable reason code. */ +public final class NotificationApplicationException extends RuntimeException { + + private final NotificationReasonCode reasonCode; + + public NotificationApplicationException(NotificationReasonCode reasonCode, Throwable cause) { + super( + Objects.requireNonNull(reasonCode, "notification reason code must be non-null").value(), + cause); + this.reasonCode = reasonCode; + } + + public NotificationReasonCode reasonCode() { + return reasonCode; + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationAttemptId.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationAttemptId.java new file mode 100644 index 00000000..516be328 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationAttemptId.java @@ -0,0 +1,9 @@ +package dev.caskeleton.application.notification; + +/** Opaque identity of one authorized physical provider attempt. */ +public record NotificationAttemptId(String value) { + + public NotificationAttemptId { + value = NotificationIntentId.requireOpaque("attemptId", value); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationCanonicalWriterFenceGuard.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationCanonicalWriterFenceGuard.java new file mode 100644 index 00000000..0eafc3aa --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationCanonicalWriterFenceGuard.java @@ -0,0 +1,44 @@ +package dev.caskeleton.application.notification; + +import java.util.Objects; + +/** + * Internal application collaborator asserting canonical ownership in an existing write boundary. + */ +public final class NotificationCanonicalWriterFenceGuard { + + private final NotificationCanonicalWriterFencePort fence; + private final NotificationCanonicalWriterRouteSet routes; + + public NotificationCanonicalWriterFenceGuard( + NotificationCanonicalWriterFencePort fence, NotificationCanonicalWriterRouteSet routes) { + this.fence = Objects.requireNonNull(fence, "canonical writer fence port must be non-null"); + this.routes = Objects.requireNonNull(routes, "canonical writer route set must be non-null"); + } + + public void assertCanonical( + NotificationCanonicalWriterRouteSet.RouteRevision route, long expectedGeneration) { + if (!routes.contains(route)) { + throw new IllegalArgumentException( + "route is outside canonical notification writer route set"); + } + NotificationCanonicalWriterFencePort.FenceSnapshot snapshot = + Objects.requireNonNull( + fence.assertCanonicalInCallerTransaction( + new NotificationCanonicalWriterFencePort.FenceRequest(route, expectedGeneration)), + "canonical writer fence snapshot must be non-null"); + if (!snapshot.route().equals(route)) { + throw failure("CANONICAL_WRITER_ROUTE_MISMATCH"); + } + if (snapshot.owner() != NotificationWriterOwnership.CANONICAL) { + throw failure("CANONICAL_WRITER_NOT_OWNER"); + } + if (snapshot.generation() != expectedGeneration) { + throw failure("STALE_CANONICAL_WRITER_GENERATION"); + } + } + + private static NotificationApplicationException failure(String reason) { + return new NotificationApplicationException(new NotificationReasonCode(reason), null); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationCanonicalWriterFencePort.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationCanonicalWriterFencePort.java new file mode 100644 index 00000000..420ea9de --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationCanonicalWriterFencePort.java @@ -0,0 +1,38 @@ +package dev.caskeleton.application.notification; + +import java.util.Objects; + +/** + * Acquires a transaction-scoped shared fence assertion. The persistence implementation must hold + * the share lock until the caller's physical commit or rollback. + */ +@FunctionalInterface +public interface NotificationCanonicalWriterFencePort { + + FenceSnapshot assertCanonicalInCallerTransaction(FenceRequest request); + + record FenceRequest( + NotificationCanonicalWriterRouteSet.RouteRevision route, long expectedGeneration) { + + public FenceRequest { + Objects.requireNonNull(route, "notification writer route must be non-null"); + if (expectedGeneration < 0) { + throw new IllegalArgumentException("expected writer generation must be non-negative"); + } + } + } + + record FenceSnapshot( + NotificationCanonicalWriterRouteSet.RouteRevision route, + NotificationWriterOwnership owner, + long generation) { + + public FenceSnapshot { + Objects.requireNonNull(route, "notification writer route must be non-null"); + Objects.requireNonNull(owner, "notification writer owner must be non-null"); + if (generation < 0) { + throw new IllegalArgumentException("writer generation must be non-negative"); + } + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationCanonicalWriterRouteSet.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationCanonicalWriterRouteSet.java new file mode 100644 index 00000000..3a9316ec --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationCanonicalWriterRouteSet.java @@ -0,0 +1,79 @@ +package dev.caskeleton.application.notification; + +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; + +/** Bounded, ordered canonical route-revision set that production admission is allowed to use. */ +public record NotificationCanonicalWriterRouteSet(List routes) { + + public NotificationCanonicalWriterRouteSet { + Objects.requireNonNull(routes, "canonical notification writer routes must be non-null"); + routes = + routes.stream() + .map(route -> Objects.requireNonNull(route, "canonical route must be non-null")) + .sorted( + Comparator.comparing((RouteRevision route) -> route.routeId().value()) + .thenComparingInt(RouteRevision::routeRevision)) + .toList(); + if (routes.isEmpty() || routes.size() > 100) { + throw new IllegalArgumentException("canonical writer route set must contain 1..100 routes"); + } + if (new HashSet<>(routes).size() != routes.size()) { + throw new IllegalArgumentException("canonical writer route set contains a duplicate route"); + } + long distinctRouteKeys = routes.stream().map(RouteRevision::routeId).distinct().count(); + if (distinctRouteKeys != routes.size()) { + throw new IllegalArgumentException( + "canonical writer route set contains multiple revisions for one route key"); + } + } + + public boolean contains(RouteRevision route) { + return routes.contains(route); + } + + public String digest() { + MessageDigest digest = sha256(); + routes.forEach( + route -> { + update(digest, route.routeId().value()); + digest.update(ByteBuffer.allocate(Integer.BYTES).putInt(route.routeRevision()).array()); + digest.update( + ByteBuffer.allocate(Long.BYTES).putLong(route.predecessorGeneration()).array()); + }); + return java.util.HexFormat.of().formatHex(digest.digest()); + } + + private static MessageDigest sha256() { + try { + return MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException unavailable) { + throw new IllegalStateException( + "SHA-256 must be available on every Java runtime", unavailable); + } + } + + static void update(MessageDigest digest, String value) { + byte[] encoded = value.getBytes(StandardCharsets.UTF_8); + digest.update(ByteBuffer.allocate(Integer.BYTES).putInt(encoded.length).array()); + digest.update(encoded); + } + + public record RouteRevision( + NotificationRouteId routeId, int routeRevision, long predecessorGeneration) { + + public RouteRevision { + Objects.requireNonNull(routeId, "notification route ID must be non-null"); + if (routeRevision < 1 || routeRevision > 1_000_000 || predecessorGeneration < 0) { + throw new IllegalArgumentException( + "route revision must be in 1..1000000 and predecessor generation non-negative"); + } + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationCapabilityCompatibilityValidator.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationCapabilityCompatibilityValidator.java new file mode 100644 index 00000000..088c69f1 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationCapabilityCompatibilityValidator.java @@ -0,0 +1,96 @@ +package dev.caskeleton.application.notification; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +/** Pure validator over application-owned policy and provider/store/ingress capability facts. */ +public final class NotificationCapabilityCompatibilityValidator { + + public Compatibility validate( + NotificationKindPolicy policy, + NotificationProviderCapabilityDescriptor provider, + NotificationStoreCapabilityDescriptor store, + Optional receiptIngress, + boolean receiptRequired) { + Objects.requireNonNull(policy, "notification kind policy must be non-null"); + Objects.requireNonNull(provider, "notification provider descriptor must be non-null"); + Objects.requireNonNull(store, "notification store descriptor must be non-null"); + Objects.requireNonNull(receiptIngress, "receipt ingress container must be non-null"); + + List reasons = new ArrayList<>(); + addIf(reasons, provider.channel() != policy.channel(), "PROVIDER_CHANNEL_MISMATCH"); + addIf(reasons, !provider.supportedModes().contains(policy.mode()), "PROVIDER_MODE_UNSUPPORTED"); + addIf(reasons, !provider.hiddenRetriesControlled(), "PROVIDER_HIDDEN_RETRY_UNCONTROLLED"); + addIf( + reasons, + provider.maximumTargets() < policy.maxTargetsPerRecipient(), + "PROVIDER_TARGET_BOUND_INSUFFICIENT"); + addIf( + reasons, + policy.maxReconcileCalls() > 0 && !provider.reconciliationSupported(), + "PROVIDER_RECONCILIATION_UNSUPPORTED"); + if (policy.mode() == NotificationMode.DURABLE_ASYNC) { + addIf( + reasons, + !store.durableIntentStore() || !store.attemptJournal(), + "DURABLE_STORE_UNAVAILABLE"); + } + addIf( + reasons, + !store.availablePolicyRevisions().contains(policy.policyRevision()), + "POLICY_REVISION_UNAVAILABLE"); + addIf( + reasons, + !store.availableTemplateRevisions().contains(policy.templateRef()), + "TEMPLATE_REVISION_UNAVAILABLE"); + if (receiptRequired) { + addIf(reasons, !provider.receiptSupported(), "PROVIDER_RECEIPT_UNSUPPORTED"); + addIf(reasons, !store.receiptInbox(), "RECEIPT_STORE_UNAVAILABLE"); + boolean ingressUnavailable = + receiptIngress.isEmpty() + || !receiptIngress.orElseThrow().enabled() + || !receiptIngress.orElseThrow().authenticated() + || receiptIngress.orElseThrow().channel() != policy.channel() + || receiptIngress.orElseThrow().supportedFactTypes().isEmpty(); + addIf(reasons, ingressUnavailable, "RECEIPT_INGRESS_UNAVAILABLE"); + } + return new Compatibility(reasons.isEmpty(), reasons); + } + + public void requireCompatible( + NotificationKindPolicy policy, + NotificationProviderCapabilityDescriptor provider, + NotificationStoreCapabilityDescriptor store, + Optional receiptIngress, + boolean receiptRequired) { + Compatibility compatibility = + validate(policy, provider, store, receiptIngress, receiptRequired); + if (!compatibility.compatible()) { + throw new NotificationApplicationException( + new NotificationReasonCode("NOTIFICATION_CAPABILITY_INCOMPATIBLE"), null); + } + } + + private static void addIf( + List reasons, boolean condition, String reasonCode) { + if (condition) { + reasons.add(new NotificationReasonCode(reasonCode)); + } + } + + public record Compatibility(boolean compatible, List reasonCodes) { + + public Compatibility { + reasonCodes = + List.copyOf( + Objects.requireNonNull( + reasonCodes, "notification compatibility reasons must be non-null")); + if (compatible != reasonCodes.isEmpty()) { + throw new IllegalArgumentException( + "compatible flag must equal an empty incompatibility reason set"); + } + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationChannel.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationChannel.java new file mode 100644 index 00000000..1c736b5d --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationChannel.java @@ -0,0 +1,7 @@ +package dev.caskeleton.application.notification; + +/** Provider-neutral delivery medium. */ +public enum NotificationChannel { + EMAIL, + SLACK +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationDeliveryId.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationDeliveryId.java new file mode 100644 index 00000000..405e0095 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationDeliveryId.java @@ -0,0 +1,9 @@ +package dev.caskeleton.application.notification; + +/** Opaque identity of one provider leg for a logical recipient. */ +public record NotificationDeliveryId(String value) { + + public NotificationDeliveryId { + value = NotificationIntentId.requireOpaque("deliveryId", value); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationDeliveryStorePort.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationDeliveryStorePort.java new file mode 100644 index 00000000..339337bb --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationDeliveryStorePort.java @@ -0,0 +1,267 @@ +package dev.caskeleton.application.notification; + +import java.time.Instant; +import java.util.List; +import java.util.Objects; + +/** Durable delivery-leg state port; provider I/O is deliberately absent from this contract. */ +public interface NotificationDeliveryStorePort { + + List claimEligible(int maximumClaims, Instant now); + + AttemptAuthorization reserveAndAuthorize(ClaimedDelivery claimed, Instant now); + + FinalizationResult finalizeAttempt( + AuthorizedAttempt attempt, AttemptFinalization finalization, Instant now); + + List claimForReconciliation(int maximumClaims, Instant now); + + ReconciliationFinalizationResult finalizeReconciliation( + ReconciliationClaim claim, + NotificationReconciliationPort.ReconciliationOutcome outcome, + Instant now); + + int attachOrphanReceipts(int maximumAttachments, Instant now); + + record ClaimedDelivery( + NotificationDeliveryId deliveryId, + NotificationFrozenPlan plan, + int targetOrdinal, + String claimToken, + long rowVersion, + long admissionGeneration) { + + public ClaimedDelivery { + Objects.requireNonNull(deliveryId, "notification delivery ID must be non-null"); + Objects.requireNonNull(plan, "notification frozen plan must be non-null"); + if (targetOrdinal < 0 || targetOrdinal >= plan.policy().maxTargetsPerRecipient()) { + throw new IllegalArgumentException("target ordinal is outside the frozen plan bound"); + } + claimToken = NotificationIntentId.requireOpaque("claim token", claimToken); + if (rowVersion < 0 || admissionGeneration < 0) { + throw new IllegalArgumentException( + "row version and admission generation must be non-negative"); + } + } + } + + sealed interface AttemptAuthorization permits Authorized, StaleClaim, NotEligible {} + + record Authorized(AuthorizedAttempt attempt) implements AttemptAuthorization { + + public Authorized { + Objects.requireNonNull(attempt, "authorized notification attempt must be non-null"); + } + } + + record StaleClaim(NotificationDeliveryId deliveryId, NotificationReasonCode reasonCode) + implements AttemptAuthorization { + + public StaleClaim { + Objects.requireNonNull(deliveryId, "notification delivery ID must be non-null"); + Objects.requireNonNull(reasonCode, "notification reason code must be non-null"); + } + } + + record NotEligible(NotificationDeliveryId deliveryId, NotificationReasonCode reasonCode) + implements AttemptAuthorization { + + public NotEligible { + Objects.requireNonNull(deliveryId, "notification delivery ID must be non-null"); + Objects.requireNonNull(reasonCode, "notification reason code must be non-null"); + } + } + + record AuthorizedAttempt( + NotificationDeliveryId deliveryId, + NotificationAttemptId attemptId, + NotificationFrozenPlan plan, + int targetOrdinal, + String claimToken, + String executionToken, + long expectedRowVersion, + long admissionGeneration, + String admissionScopeReference, + Instant absoluteDeadline) { + + public AuthorizedAttempt { + Objects.requireNonNull(deliveryId, "notification delivery ID must be non-null"); + Objects.requireNonNull(attemptId, "notification attempt ID must be non-null"); + Objects.requireNonNull(plan, "notification frozen plan must be non-null"); + if (targetOrdinal < 0 || targetOrdinal >= plan.policy().maxTargetsPerRecipient()) { + throw new IllegalArgumentException("target ordinal is outside the frozen plan bound"); + } + claimToken = NotificationIntentId.requireOpaque("claim token", claimToken); + executionToken = + NotificationIntentId.requireOpaque("attempt execution token", executionToken); + if (claimToken.equals(executionToken)) { + throw new IllegalArgumentException( + "attempt execution token must be distinct from the claim token"); + } + if (expectedRowVersion < 0 || admissionGeneration < 0) { + throw new IllegalArgumentException( + "row version and admission generation must be non-negative"); + } + admissionScopeReference = + NotificationIntentId.requireOpaque("admission scope reference", admissionScopeReference); + Objects.requireNonNull(absoluteDeadline, "absolute attempt deadline must be non-null"); + } + + @Override + public String toString() { + return "AuthorizedAttempt[deliveryId=, attemptId=" + + attemptId + + ", plan=, targetOrdinal=" + + targetOrdinal + + ", claimToken=, executionToken=, expectedRowVersion=" + + expectedRowVersion + + ", admissionGeneration=" + + admissionGeneration + + ", admissionScopeReference=, absoluteDeadline=" + + absoluteDeadline + + "]"; + } + } + + record AttemptFinalization( + ProviderAttemptOutcome providerOutcome, + TerminalState terminalState, + boolean fallbackEligible, + NotificationAdmissionReadinessPort.ParkResult parkResult) { + + public AttemptFinalization { + Objects.requireNonNull(providerOutcome, "provider attempt outcome must be non-null"); + Objects.requireNonNull(terminalState, "notification terminal state must be non-null"); + Objects.requireNonNull(parkResult, "admission park result must be non-null"); + if (fallbackEligible + && providerOutcome.submissionCertainty() != SubmissionCertainty.DEFINITELY_NOT_APPLIED) { + throw new IllegalArgumentException("fallback is eligible only for DEFINITELY_NOT_APPLIED"); + } + if (terminalState == TerminalState.TERMINAL_INDETERMINATE + && providerOutcome.submissionCertainty() != SubmissionCertainty.INDETERMINATE) { + throw new IllegalArgumentException( + "TERMINAL_INDETERMINATE requires an indeterminate provider outcome"); + } + if (terminalState == TerminalState.PARKED_BINDING + && providerOutcome.retryDisposition() != RetryDisposition.PARK_BINDING) { + throw new IllegalArgumentException("PARKED_BINDING requires PARK_BINDING disposition"); + } + if (providerOutcome.retryDisposition() == RetryDisposition.PARK_BINDING) { + if (parkResult == NotificationAdmissionReadinessPort.ParkResult.NOT_REQUESTED) { + throw new IllegalArgumentException("PARK_BINDING requires an admission park result"); + } + boolean parked = + parkResult == NotificationAdmissionReadinessPort.ParkResult.PARKED + || parkResult == NotificationAdmissionReadinessPort.ParkResult.ALREADY_PARKED; + TerminalState expected = + parked ? TerminalState.PARKED_BINDING : TerminalState.RETRY_SCHEDULED; + if (terminalState != expected) { + throw new IllegalArgumentException( + "terminal state must reflect the generation-guarded admission park result"); + } + } + } + } + + enum TerminalState { + ACCEPTED, + RETRY_SCHEDULED, + PARKED_BINDING, + TERMINAL_FAILURE, + TERMINAL_INDETERMINATE + } + + enum FinalizationResult { + APPLIED, + LATE_EXACT_APPLIED, + STALE_EXECUTION_TOKEN, + ALREADY_TERMINAL + } + + record ReconciliationClaim( + NotificationDeliveryId deliveryId, + String executionToken, + long expectedRowVersion, + NotificationRouteId routeId, + int routeRevision, + String bindingDigest, + int targetOrdinal, + String targetReference, + String providerCapabilityReference, + String providerBindingRevision, + String credentialGeneration, + String lookupReference, + ReconciliationLookupKind lookupKind, + Instant absoluteDeadline) { + + public ReconciliationClaim { + Objects.requireNonNull(deliveryId, "notification delivery ID must be non-null"); + executionToken = + NotificationIntentId.requireOpaque("reconciliation execution token", executionToken); + if (expectedRowVersion < 0 || routeRevision < 1 || targetOrdinal < 0 || targetOrdinal > 15) { + throw new IllegalArgumentException( + "reconciliation row version, route revision and target ordinal are invalid"); + } + Objects.requireNonNull(routeId, "reconciliation route ID must be non-null"); + if (bindingDigest == null || !bindingDigest.matches("[0-9a-f]{64}")) { + throw new IllegalArgumentException( + "reconciliation binding digest must be a lowercase SHA-256 digest"); + } + targetReference = + NotificationIntentId.requireOpaque("reconciliation target reference", targetReference); + providerCapabilityReference = + NotificationIntentId.requireOpaque( + "reconciliation provider capability reference", providerCapabilityReference); + providerBindingRevision = + NotificationIntentId.requireOpaque( + "reconciliation provider binding revision", providerBindingRevision); + credentialGeneration = + NotificationIntentId.requireSlug( + "reconciliation credential generation", credentialGeneration); + lookupReference = + NotificationIntentId.requireOpaque( + "provider reconciliation lookup reference", lookupReference); + Objects.requireNonNull(lookupKind, "provider reconciliation lookup kind must be non-null"); + Objects.requireNonNull(absoluteDeadline, "reconciliation deadline must be non-null"); + } + + @Override + public String toString() { + return "ReconciliationClaim[deliveryId=, executionToken=, " + + "expectedRowVersion=" + + expectedRowVersion + + ", routeId=" + + routeId + + ", routeRevision=" + + routeRevision + + ", bindingDigest=" + + bindingDigest + + ", targetOrdinal=" + + targetOrdinal + + ", targetReference=, providerCapabilityReference=" + + providerCapabilityReference + + ", providerBindingRevision=" + + providerBindingRevision + + ", credentialGeneration=" + + credentialGeneration + + ", lookupReference=, lookupKind=" + + lookupKind + + ", absoluteDeadline=" + + absoluteDeadline + + "]"; + } + } + + enum ReconciliationLookupKind { + PRE_SEND_CORRELATION, + CLIENT_OPERATION_KEY, + MESSAGE_REFERENCE + } + + enum ReconciliationFinalizationResult { + APPLIED, + LATE_EXACT_APPLIED, + STALE_EXECUTION_TOKEN, + ALREADY_TERMINAL + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationDispatchCommand.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationDispatchCommand.java new file mode 100644 index 00000000..3689b395 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationDispatchCommand.java @@ -0,0 +1,13 @@ +package dev.caskeleton.application.notification; + +import dev.caskeleton.application.command.Command; + +/** Requests one bounded cross-tenant dispatch cycle. */ +public record NotificationDispatchCommand(int maximumClaims) implements Command { + + public NotificationDispatchCommand { + if (maximumClaims < 1 || maximumClaims > 100) { + throw new IllegalArgumentException("maximum notification claims must be in 1..100"); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationDispatchResult.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationDispatchResult.java new file mode 100644 index 00000000..43502a07 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationDispatchResult.java @@ -0,0 +1,37 @@ +package dev.caskeleton.application.notification; + +/** Bounded non-sensitive aggregate outcome of one dispatch cycle. */ +public record NotificationDispatchResult( + int claimedCount, + int authorizedCount, + int providerCallCount, + int finalizedCount, + int staleClaimCount, + int indeterminateCount, + int parkedCount) { + + public NotificationDispatchResult { + int[] counts = { + claimedCount, + authorizedCount, + providerCallCount, + finalizedCount, + staleClaimCount, + indeterminateCount, + parkedCount + }; + for (int count : counts) { + if (count < 0 || count > 100) { + throw new IllegalArgumentException("notification dispatch counts must be in 0..100"); + } + } + if (authorizedCount > claimedCount + || providerCallCount > authorizedCount + || finalizedCount > providerCallCount + || staleClaimCount > claimedCount + || indeterminateCount > providerCallCount + || parkedCount > providerCallCount) { + throw new IllegalArgumentException("notification dispatch counts are inconsistent"); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationDispatchUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationDispatchUseCase.java new file mode 100644 index 00000000..b9d225f1 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationDispatchUseCase.java @@ -0,0 +1,199 @@ +package dev.caskeleton.application.notification; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.security.RequiresPermission; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.CommandUseCase; +import java.time.Clock; +import java.util.List; +import java.util.Objects; + +/** Coordinates short store transactions around provider I/O for a bounded delivery batch. */ +@RequiresPermission("notification:dispatch") +@UseCaseCapability( + transactionMode = TransactionMode.WRITE, + idempotency = Idempotency.IDEMPOTENT, + repositoryAccess = RepositoryAccess.WRITE_REPOSITORY, + externalOutboundAllowed = true, + sensitiveRead = true, + crossTenantAdmin = true) +public final class NotificationDispatchUseCase + implements CommandUseCase { + + private final NotificationDeliveryStorePort store; + private final NotificationProviderAttemptPort provider; + private final NotificationAdmissionReadinessPort admission; + private final TransactionPort transactions; + private final Clock clock; + + public NotificationDispatchUseCase( + NotificationDeliveryStorePort store, + NotificationProviderAttemptPort provider, + NotificationAdmissionReadinessPort admission, + TransactionPort transactions, + Clock clock) { + this.store = Objects.requireNonNull(store, "notification delivery store must be non-null"); + this.provider = Objects.requireNonNull(provider, "notification provider port must be non-null"); + this.admission = + Objects.requireNonNull(admission, "notification admission port must be non-null"); + this.transactions = Objects.requireNonNull(transactions, "transaction port must be non-null"); + this.clock = Objects.requireNonNull(clock, "clock must be non-null"); + } + + @Override + public NotificationDispatchResult handle(NotificationDispatchCommand command) { + Objects.requireNonNull(command, "notification dispatch command must be non-null"); + List claimed = + List.copyOf( + transactions.inWrite( + () -> store.claimEligible(command.maximumClaims(), clock.instant()))); + if (claimed.size() > command.maximumClaims()) { + throw new IllegalStateException("notification store returned more claims than requested"); + } + + MutableCounts counts = new MutableCounts(claimed.size()); + for (NotificationDeliveryStorePort.ClaimedDelivery delivery : claimed) { + dispatchOne(delivery, counts); + } + return counts.toResult(); + } + + private void dispatchOne( + NotificationDeliveryStorePort.ClaimedDelivery delivery, MutableCounts counts) { + NotificationDeliveryStorePort.AttemptAuthorization authorization = + transactions.inWrite(() -> store.reserveAndAuthorize(delivery, clock.instant())); + if (authorization instanceof NotificationDeliveryStorePort.StaleClaim) { + counts.staleClaims++; + return; + } + if (authorization instanceof NotificationDeliveryStorePort.NotEligible) { + return; + } + + NotificationDeliveryStorePort.AuthorizedAttempt attempt = + ((NotificationDeliveryStorePort.Authorized) authorization).attempt(); + counts.authorized++; + ProviderAttemptOutcome outcome = invokeProvider(attempt); + counts.providerCalls++; + if (outcome.submissionCertainty() == SubmissionCertainty.INDETERMINATE) { + counts.indeterminate++; + } + + FinalizationExecution execution = + transactions.inWrite(() -> finalizeInsideTransaction(attempt, outcome)); + boolean applied = + execution.result() == NotificationDeliveryStorePort.FinalizationResult.APPLIED + || execution.result() + == NotificationDeliveryStorePort.FinalizationResult.LATE_EXACT_APPLIED; + if (applied) { + counts.finalized++; + } + if (applied + && execution.finalization().terminalState() + == NotificationDeliveryStorePort.TerminalState.PARKED_BINDING) { + counts.parked++; + } + } + + private ProviderAttemptOutcome invokeProvider( + NotificationDeliveryStorePort.AuthorizedAttempt attempt) { + try { + return Objects.requireNonNull( + provider.attempt(attempt), "provider attempt outcome must be non-null"); + } catch (RuntimeException providerFailure) { + return new ProviderAttemptOutcome( + SubmissionCertainty.INDETERMINATE, + RetryDisposition.NOT_APPLICABLE, + NotificationFaultScope.DELIVERY, + new NotificationReasonCode("UNCLASSIFIED_PROVIDER_FAILURE"), + java.util.Optional.empty(), + attempt.executionToken(), + java.util.Optional.empty()); + } + } + + private FinalizationExecution finalizeInsideTransaction( + NotificationDeliveryStorePort.AuthorizedAttempt attempt, ProviderAttemptOutcome outcome) { + NotificationAdmissionReadinessPort.ParkResult parkResult = + NotificationAdmissionReadinessPort.ParkResult.NOT_REQUESTED; + if (outcome.retryDisposition() == RetryDisposition.PARK_BINDING) { + parkResult = + admission.park( + new NotificationAdmissionReadinessPort.ParkRequest( + attempt.plan().routeId(), + attempt.plan().policy().policyRevision(), + outcome.faultScope(), + attempt.admissionScopeReference(), + attempt.admissionGeneration(), + outcome.reasonCode(), + clock.instant())); + } + + NotificationDeliveryStorePort.AttemptFinalization finalization = + new NotificationDeliveryStorePort.AttemptFinalization( + outcome, terminalState(outcome, parkResult), fallbackEligible(outcome), parkResult); + NotificationDeliveryStorePort.FinalizationResult result = + Objects.requireNonNull( + store.finalizeAttempt(attempt, finalization, clock.instant()), + "notification attempt finalization result must be non-null"); + return new FinalizationExecution(finalization, result); + } + + private static NotificationDeliveryStorePort.TerminalState terminalState( + ProviderAttemptOutcome outcome, NotificationAdmissionReadinessPort.ParkResult parkResult) { + if (outcome.submissionCertainty() == SubmissionCertainty.PROVIDER_ACCEPTED) { + return NotificationDeliveryStorePort.TerminalState.ACCEPTED; + } + if (outcome.submissionCertainty() == SubmissionCertainty.INDETERMINATE) { + return NotificationDeliveryStorePort.TerminalState.TERMINAL_INDETERMINATE; + } + return switch (outcome.retryDisposition()) { + case RETRY_AT -> NotificationDeliveryStorePort.TerminalState.RETRY_SCHEDULED; + case PARK_BINDING -> + switch (parkResult) { + case PARKED, ALREADY_PARKED -> + NotificationDeliveryStorePort.TerminalState.PARKED_BINDING; + case STALE_GENERATION -> NotificationDeliveryStorePort.TerminalState.RETRY_SCHEDULED; + case NOT_REQUESTED -> + throw new IllegalStateException( + "PARK_BINDING outcome requires an admission park result"); + }; + case TERMINAL -> NotificationDeliveryStorePort.TerminalState.TERMINAL_FAILURE; + case NOT_APPLICABLE -> + throw new IllegalArgumentException( + "definitely-not-applied outcome requires an explicit disposition"); + }; + } + + private static boolean fallbackEligible(ProviderAttemptOutcome outcome) { + return outcome.submissionCertainty() == SubmissionCertainty.DEFINITELY_NOT_APPLIED + && outcome.retryDisposition() == RetryDisposition.TERMINAL; + } + + private record FinalizationExecution( + NotificationDeliveryStorePort.AttemptFinalization finalization, + NotificationDeliveryStorePort.FinalizationResult result) {} + + private static final class MutableCounts { + + private final int claimed; + private int authorized; + private int providerCalls; + private int finalized; + private int staleClaims; + private int indeterminate; + private int parked; + + private MutableCounts(int claimed) { + this.claimed = claimed; + } + + private NotificationDispatchResult toResult() { + return new NotificationDispatchResult( + claimed, authorized, providerCalls, finalized, staleClaims, indeterminate, parked); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationEvidenceTrustSnapshot.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationEvidenceTrustSnapshot.java new file mode 100644 index 00000000..a94083af --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationEvidenceTrustSnapshot.java @@ -0,0 +1,24 @@ +package dev.caskeleton.application.notification; + +import java.util.Objects; + +/** Historical issuer-key decision retained with accepted signed evidence. */ +public record NotificationEvidenceTrustSnapshot( + String catalogRevision, HistoricalKeyStatus historicalKeyStatus, String issuerKeyDigest) { + + public NotificationEvidenceTrustSnapshot { + catalogRevision = + NotificationIntentId.requireSlug("evidence trust catalog revision", catalogRevision); + Objects.requireNonNull(historicalKeyStatus, "historical evidence key status must be non-null"); + issuerKeyDigest = InitializeNotificationWriterFencesCommand.requireDigest(issuerKeyDigest); + if (historicalKeyStatus == HistoricalKeyStatus.REVOKED) { + throw new IllegalArgumentException("revoked evidence issuer key cannot be accepted"); + } + } + + public enum HistoricalKeyStatus { + ALLOWED, + RETIRING, + REVOKED + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationFaultScope.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationFaultScope.java new file mode 100644 index 00000000..9a3dad4d --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationFaultScope.java @@ -0,0 +1,9 @@ +package dev.caskeleton.application.notification; + +/** Smallest durable scope affected by a classified attempt failure. */ +public enum NotificationFaultScope { + DELIVERY, + ROUTE_REVISION, + PROVIDER_BINDING, + ACCOUNT +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationFrozenPlan.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationFrozenPlan.java new file mode 100644 index 00000000..ac263192 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationFrozenPlan.java @@ -0,0 +1,216 @@ +package dev.caskeleton.application.notification; + +import java.time.Duration; +import java.time.Instant; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.Optional; + +/** Immutable provider-neutral plan snapshot returned by the application planning boundary. */ +public record NotificationFrozenPlan( + NotificationIntentId intentId, + NotificationKindPolicy policy, + Locale selectedLocale, + BindingSnapshot binding, + NotificationRecipientReference recipient, + NotificationTemplateParameters parameters, + String idempotencyScope, + String sourceOperationId, + Optional tenantReference, + String correlationReference, + Optional causationReference, + Instant notBefore, + Instant expiresAt) { + + public NotificationFrozenPlan { + Objects.requireNonNull(intentId, "notification intent ID must be non-null"); + Objects.requireNonNull(policy, "notification kind policy must be non-null"); + selectedLocale = NotificationIntentDraft.requireLocale("selected locale", selectedLocale); + Objects.requireNonNull(binding, "notification binding snapshot must be non-null"); + Objects.requireNonNull(recipient, "notification recipient must be non-null"); + Objects.requireNonNull(parameters, "notification template parameters must be non-null"); + idempotencyScope = + NotificationIntentId.requireOpaque("notification idempotency scope", idempotencyScope); + sourceOperationId = + NotificationIntentId.requireOpaque("notification source operation ID", sourceOperationId); + tenantReference = requireOptionalOpaque("tenant reference", tenantReference); + correlationReference = + NotificationIntentId.requireOpaque( + "notification correlation reference", correlationReference); + causationReference = requireOptionalOpaque("causation reference", causationReference); + Objects.requireNonNull(notBefore, "notification not-before time must be non-null"); + Objects.requireNonNull(expiresAt, "notification expiry time must be non-null"); + if (recipient.channel() != policy.channel()) { + throw new IllegalArgumentException("recipient channel must match notification kind channel"); + } + if (binding.targets().size() != policy.maxTargetsPerRecipient()) { + throw new IllegalArgumentException( + "frozen binding target count must match the code-owned policy target bound"); + } + Duration lifetime = Duration.between(notBefore, expiresAt); + if (lifetime.isZero() + || lifetime.isNegative() + || lifetime.compareTo(policy.maxElapsedRetryHorizon()) > 0) { + throw new IllegalArgumentException( + "notification expiry must be after not-before and within the policy retry horizon"); + } + } + + public static NotificationFrozenPlan from( + NotificationIntentDraft draft, Locale selectedLocale, BindingSnapshot binding) { + Objects.requireNonNull(draft, "notification intent draft must be non-null"); + return new NotificationFrozenPlan( + draft.intentId(), + draft.policy(), + selectedLocale, + binding, + draft.recipient(), + draft.parameters(), + draft.idempotencyScope(), + draft.sourceOperationId(), + draft.tenantReference(), + draft.correlationReference(), + draft.causationReference(), + draft.notBefore(), + draft.expiresAt()); + } + + public NotificationMode mode() { + return policy.mode(); + } + + public NotificationRouteId routeId() { + return policy.routeId(); + } + + private static Optional requireOptionalOpaque(String field, Optional reference) { + Objects.requireNonNull(reference, field + " container must be non-null"); + return reference.map(value -> NotificationIntentId.requireOpaque(field, value)); + } + + @Override + public String toString() { + return "NotificationFrozenPlan[intentId=" + + intentId + + ", kindId=" + + policy.kindId() + + ", policyRevision=" + + policy.policyRevision() + + ", selectedLocale=" + + selectedLocale.toLanguageTag() + + ", routeRevision=" + + binding.routeRevision() + + ", bindingDigest=" + + binding.bindingDigest() + + ", rendererRevision=" + + binding.rendererRevision() + + ", targets=" + + ", recipient=, parameters=, context=, notBefore=" + + notBefore + + ", expiresAt=" + + expiresAt + + "]"; + } + + /** Provider-neutral immutable execution graph persisted with the logical intent. */ + public record BindingSnapshot( + int routeRevision, + String bindingDigest, + String templateChecksum, + String rendererRevision, + List targets, + boolean receiptRequired, + Duration perAttemptDeadline) { + + private static final Duration MAXIMUM_ATTEMPT_DEADLINE = Duration.ofMinutes(5); + + public BindingSnapshot { + if (routeRevision < 1 || routeRevision > 1_000_000) { + throw new IllegalArgumentException("frozen route revision must be in 1..1000000"); + } + bindingDigest = requireDigest("notification binding digest", bindingDigest); + templateChecksum = requireDigest("notification template checksum", templateChecksum); + rendererRevision = + NotificationIntentId.requireSlug("notification renderer revision", rendererRevision); + Objects.requireNonNull(targets, "frozen notification targets must be non-null"); + targets = + targets.stream() + .map(target -> Objects.requireNonNull(target, "frozen target must be non-null")) + .sorted(Comparator.comparingInt(FrozenTarget::ordinal)) + .toList(); + if (targets.isEmpty() || targets.size() > 16) { + throw new IllegalArgumentException( + "frozen notification targets must contain 1..16 entries"); + } + if (new HashSet<>(targets.stream().map(FrozenTarget::targetReference).toList()).size() + != targets.size()) { + throw new IllegalArgumentException( + "frozen notification targets contain duplicate references"); + } + for (int index = 0; index < targets.size(); index++) { + if (targets.get(index).ordinal() != index) { + throw new IllegalArgumentException( + "frozen notification target ordinals must be contiguous from zero"); + } + } + Objects.requireNonNull( + perAttemptDeadline, "notification per-attempt deadline must be non-null"); + if (perAttemptDeadline.isZero() + || perAttemptDeadline.isNegative() + || perAttemptDeadline.compareTo(MAXIMUM_ATTEMPT_DEADLINE) > 0) { + throw new IllegalArgumentException( + "notification per-attempt deadline must be positive and at most five minutes"); + } + } + + private static String requireDigest(String field, String digest) { + if (digest == null || !digest.matches("[0-9a-f]{64}")) { + throw new IllegalArgumentException(field + " must be a lowercase SHA-256 digest"); + } + return digest; + } + } + + /** Opaque provider-leg identity; credentials, endpoints and SDK types are deliberately absent. */ + public record FrozenTarget( + int ordinal, + String targetReference, + String providerCapabilityReference, + String providerBindingRevision, + String credentialGeneration) { + + public FrozenTarget { + if (ordinal < 0 || ordinal > 15) { + throw new IllegalArgumentException("frozen notification target ordinal must be in 0..15"); + } + targetReference = + NotificationIntentId.requireOpaque( + "frozen notification target reference", targetReference); + providerCapabilityReference = + NotificationIntentId.requireOpaque( + "frozen provider capability reference", providerCapabilityReference); + providerBindingRevision = + NotificationIntentId.requireOpaque( + "frozen provider binding revision", providerBindingRevision); + credentialGeneration = + NotificationIntentId.requireSlug( + "frozen provider credential generation", credentialGeneration); + } + + @Override + public String toString() { + return "FrozenTarget[ordinal=" + + ordinal + + ", targetReference=, providerCapabilityReference=" + + providerCapabilityReference + + ", providerBindingRevision=" + + providerBindingRevision + + ", credentialGeneration=" + + credentialGeneration + + "]"; + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationIntentAppendPort.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationIntentAppendPort.java new file mode 100644 index 00000000..4346c5f8 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationIntentAppendPort.java @@ -0,0 +1,11 @@ +package dev.caskeleton.application.notification; + +/** + * Appends a frozen intent to durable storage in the caller's current transaction. Implementations + * must not open an independent transaction. + */ +@FunctionalInterface +public interface NotificationIntentAppendPort { + + NotificationAppendResult append(NotificationFrozenPlan plan); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationIntentDraft.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationIntentDraft.java new file mode 100644 index 00000000..59356cda --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationIntentDraft.java @@ -0,0 +1,84 @@ +package dev.caskeleton.application.notification; + +import java.time.Duration; +import java.time.Instant; +import java.util.Locale; +import java.util.Objects; +import java.util.Optional; + +/** Feature-policy output containing one logical recipient and no provider or transport types. */ +public record NotificationIntentDraft( + NotificationIntentId intentId, + NotificationKindPolicy policy, + Locale requestedLocale, + NotificationRecipientReference recipient, + NotificationTemplateParameters parameters, + String idempotencyScope, + String sourceOperationId, + Optional tenantReference, + String correlationReference, + Optional causationReference, + Instant notBefore, + Instant expiresAt) { + + public NotificationIntentDraft { + Objects.requireNonNull(intentId, "notification intent ID must be non-null"); + Objects.requireNonNull(policy, "notification kind policy must be non-null"); + requestedLocale = requireLocale("requested locale", requestedLocale); + Objects.requireNonNull(recipient, "notification recipient must be non-null"); + Objects.requireNonNull(parameters, "notification template parameters must be non-null"); + if (recipient.channel() != policy.channel()) { + throw new IllegalArgumentException("recipient channel must match notification kind channel"); + } + idempotencyScope = + NotificationIntentId.requireOpaque("notification idempotency scope", idempotencyScope); + sourceOperationId = + NotificationIntentId.requireOpaque("notification source operation ID", sourceOperationId); + tenantReference = requireOptionalOpaque("tenant reference", tenantReference); + correlationReference = + NotificationIntentId.requireOpaque( + "notification correlation reference", correlationReference); + causationReference = requireOptionalOpaque("causation reference", causationReference); + Objects.requireNonNull(notBefore, "notification not-before time must be non-null"); + Objects.requireNonNull(expiresAt, "notification expiry time must be non-null"); + Duration lifetime = Duration.between(notBefore, expiresAt); + if (lifetime.isZero() + || lifetime.isNegative() + || lifetime.compareTo(policy.maxElapsedRetryHorizon()) > 0) { + throw new IllegalArgumentException( + "notification expiry must be after not-before and within the policy retry horizon"); + } + } + + static Locale requireLocale(String field, Locale locale) { + Objects.requireNonNull(locale, field + " must be non-null"); + String languageTag = locale.toLanguageTag(); + if (locale.equals(Locale.ROOT) + || languageTag.equals("und") + || languageTag.isBlank() + || languageTag.length() > 35) { + throw new IllegalArgumentException(field + " must be an explicit bounded locale"); + } + return Locale.forLanguageTag(languageTag); + } + + private static Optional requireOptionalOpaque(String field, Optional reference) { + Objects.requireNonNull(reference, field + " container must be non-null"); + return reference.map(value -> NotificationIntentId.requireOpaque(field, value)); + } + + @Override + public String toString() { + return "NotificationIntentDraft[intentId=" + + intentId + + ", kindId=" + + policy.kindId() + + ", requestedLocale=" + + requestedLocale.toLanguageTag() + + ", recipient=, parameters=, context=, notBefore=" + + notBefore + + ", expiresAt=" + + expiresAt + + "]"; + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationIntentId.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationIntentId.java new file mode 100644 index 00000000..6c1c3c49 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationIntentId.java @@ -0,0 +1,24 @@ +package dev.caskeleton.application.notification; + +/** Opaque identity of one logical business notification. */ +public record NotificationIntentId(String value) { + + public NotificationIntentId { + value = requireOpaque("intentId", value); + } + + static String requireOpaque(String field, String value) { + if (value == null || !value.matches("[A-Za-z0-9][A-Za-z0-9._:-]{0,127}")) { + throw new IllegalArgumentException( + field + " must contain 1..128 opaque identifier characters"); + } + return value; + } + + static String requireSlug(String field, String value) { + if (value == null || !value.matches("[a-z][a-z0-9.-]{0,62}")) { + throw new IllegalArgumentException(field + " must match [a-z][a-z0-9.-]{0,62}"); + } + return value; + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationKindId.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationKindId.java new file mode 100644 index 00000000..3aed1f86 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationKindId.java @@ -0,0 +1,9 @@ +package dev.caskeleton.application.notification; + +/** Closed-catalog notification business kind. */ +public record NotificationKindId(String value) { + + public NotificationKindId { + value = NotificationIntentId.requireSlug("kindId", value); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationKindPolicy.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationKindPolicy.java new file mode 100644 index 00000000..05429fae --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationKindPolicy.java @@ -0,0 +1,124 @@ +package dev.caskeleton.application.notification; + +import java.time.Duration; +import java.util.Objects; + +/** + * Code-owned notification kind catalog row. Runtime configuration may assert these values and lower + * resource concurrency, but cannot replace semantic fields in this record. + */ +public record NotificationKindPolicy( + NotificationKindId kindId, + NotificationChannel channel, + NotificationRouteId routeId, + NotificationTemplateRef templateRef, + NotificationMode mode, + NotificationAdmissionClass admissionClass, + NotificationRouteStrategy routeStrategy, + ConsentCheckMode consentCheckMode, + int policyRevision, + int maxTargetsPerRecipient, + int maxPhysicalAttemptsPerDelivery, + int maxFallbackActivations, + int maxReconcileCalls, + int maxTotalProviderCallsPerIntent, + Duration maxElapsedRetryHorizon) { + + private static final int MAXIMUM_TARGETS = 16; + private static final int MAXIMUM_ATTEMPTS_PER_DELIVERY = 10; + private static final int MAXIMUM_PROVIDER_CALLS = 64; + private static final Duration MAXIMUM_RETRY_HORIZON = Duration.ofDays(30); + + public NotificationKindPolicy { + Objects.requireNonNull(kindId, "notification kind must be non-null"); + Objects.requireNonNull(channel, "notification channel must be non-null"); + Objects.requireNonNull(routeId, "notification route must be non-null"); + Objects.requireNonNull(templateRef, "notification template must be non-null"); + Objects.requireNonNull(mode, "notification mode must be non-null"); + Objects.requireNonNull(admissionClass, "notification admission class must be non-null"); + Objects.requireNonNull(routeStrategy, "notification route strategy must be non-null"); + Objects.requireNonNull(consentCheckMode, "consent check mode must be non-null"); + Objects.requireNonNull(maxElapsedRetryHorizon, "maximum retry horizon must be non-null"); + if (policyRevision < 1 || policyRevision > 1_000_000) { + throw new IllegalArgumentException("policy revision must be in 1..1000000"); + } + if (maxTargetsPerRecipient < 1 || maxTargetsPerRecipient > MAXIMUM_TARGETS) { + throw new IllegalArgumentException("maximum targets per recipient must be in 1..16"); + } + if (maxPhysicalAttemptsPerDelivery < 1 + || maxPhysicalAttemptsPerDelivery > MAXIMUM_ATTEMPTS_PER_DELIVERY) { + throw new IllegalArgumentException("maximum physical attempts per delivery must be in 1..10"); + } + if (maxFallbackActivations < 0 || maxFallbackActivations >= MAXIMUM_TARGETS) { + throw new IllegalArgumentException("maximum fallback activations must be in 0..15"); + } + if (maxReconcileCalls < 0 || maxReconcileCalls > 10) { + throw new IllegalArgumentException("maximum reconcile calls must be in 0..10"); + } + if (maxTotalProviderCallsPerIntent < 1 + || maxTotalProviderCallsPerIntent > MAXIMUM_PROVIDER_CALLS) { + throw new IllegalArgumentException("maximum total provider calls must be in 1..64"); + } + if (maxElapsedRetryHorizon.isZero() + || maxElapsedRetryHorizon.isNegative() + || maxElapsedRetryHorizon.compareTo(MAXIMUM_RETRY_HORIZON) > 0) { + throw new IllegalArgumentException( + "maximum retry horizon must be positive and at most 30 days"); + } + if (admissionClass == NotificationAdmissionClass.SECURITY_CRITICAL + && mode == NotificationMode.BEST_EFFORT_INLINE) { + throw new IllegalArgumentException( + "SECURITY_CRITICAL notification kind cannot use BEST_EFFORT_INLINE"); + } + validateStrategy(routeStrategy, maxTargetsPerRecipient, maxFallbackActivations); + if (mode == NotificationMode.BEST_EFFORT_INLINE + && (maxPhysicalAttemptsPerDelivery != 1 || maxReconcileCalls != 0)) { + throw new IllegalArgumentException( + "BEST_EFFORT_INLINE permits one attempt per target and no reconciliation"); + } + long worstCaseProviderCalls = + Math.addExact( + Math.multiplyExact( + (long) maxTargetsPerRecipient, (long) maxPhysicalAttemptsPerDelivery), + maxReconcileCalls); + if (worstCaseProviderCalls > maxTotalProviderCallsPerIntent) { + throw new IllegalArgumentException( + "worst-case provider calls exceed maximum total provider calls per intent"); + } + } + + public NotificationKindPolicy assertRuntimeExpectation( + NotificationMode expectedMode, NotificationAdmissionClass expectedAdmissionClass) { + Objects.requireNonNull(expectedMode, "expected notification mode must be non-null"); + Objects.requireNonNull( + expectedAdmissionClass, "expected notification admission class must be non-null"); + if (mode != expectedMode) { + throw new IllegalStateException( + "runtime expected mode " + expectedMode + " does not match code-owned mode " + mode); + } + if (admissionClass != expectedAdmissionClass) { + throw new IllegalStateException( + "runtime expected admission " + + expectedAdmissionClass + + " does not match code-owned admission " + + admissionClass); + } + return this; + } + + private static void validateStrategy( + NotificationRouteStrategy strategy, int maximumTargets, int maximumFallbacks) { + if (strategy == NotificationRouteStrategy.SINGLE + && (maximumTargets != 1 || maximumFallbacks != 0)) { + throw new IllegalArgumentException("SINGLE requires one target and zero fallbacks"); + } + if (strategy == NotificationRouteStrategy.FAN_OUT_ALL && maximumFallbacks != 0) { + throw new IllegalArgumentException("FAN_OUT_ALL cannot activate fallback targets"); + } + if (strategy == NotificationRouteStrategy.ORDERED_FALLBACK + && (maximumTargets < 2 || maximumFallbacks < 1 || maximumFallbacks > maximumTargets - 1)) { + throw new IllegalArgumentException( + "ORDERED_FALLBACK requires 2..16 targets and 1..targetCount-1 fallbacks"); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationLegacyWriterPermitCommand.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationLegacyWriterPermitCommand.java new file mode 100644 index 00000000..6bbb029d --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationLegacyWriterPermitCommand.java @@ -0,0 +1,103 @@ +package dev.caskeleton.application.notification; + +import dev.caskeleton.application.command.Command; +import java.time.Duration; +import java.util.Objects; +import java.util.Optional; + +/** Root-committed acquire or release of one bounded legacy writer permit. */ +public record NotificationLegacyWriterPermitCommand( + Action action, + NotificationCanonicalWriterRouteSet.RouteRevision route, + long expectedGeneration, + String transportProfileId, + String permitToken, + String holderReference, + String operationToken, + String actorReference, + NotificationReasonCode reasonCode, + Optional wireBudget) + implements Command { + + private static final Duration MAXIMUM_WIRE_BUDGET = Duration.ofSeconds(30); + + public NotificationLegacyWriterPermitCommand { + Objects.requireNonNull(action, "legacy writer permit action must be non-null"); + Objects.requireNonNull(route, "notification writer route must be non-null"); + if (expectedGeneration < 0) { + throw new IllegalArgumentException("expected writer generation must be non-negative"); + } + transportProfileId = + NotificationIntentId.requireSlug("legacy transport profile ID", transportProfileId); + permitToken = NotificationIntentId.requireOpaque("legacy writer permit token", permitToken); + holderReference = + NotificationIntentId.requireOpaque("legacy writer permit holder", holderReference); + operationToken = + NotificationIntentId.requireOpaque("legacy writer permit operation token", operationToken); + actorReference = + NotificationIntentId.requireOpaque("legacy writer permit actor", actorReference); + Objects.requireNonNull(reasonCode, "notification reason code must be non-null"); + Objects.requireNonNull(wireBudget, "wire budget container must be non-null"); + if (action == Action.ACQUIRE) { + Duration budget = + wireBudget.orElseThrow( + () -> new IllegalArgumentException("legacy permit acquire requires a wire budget")); + if (budget.isZero() || budget.isNegative() || budget.compareTo(MAXIMUM_WIRE_BUDGET) > 0) { + throw new IllegalArgumentException( + "legacy writer wire budget must be positive and at most 30 seconds"); + } + } else if (wireBudget.isPresent()) { + throw new IllegalArgumentException("legacy permit release must not carry a wire budget"); + } + } + + public static NotificationLegacyWriterPermitCommand acquire( + NotificationCanonicalWriterRouteSet.RouteRevision route, + long expectedGeneration, + String transportProfileId, + String permitToken, + String holderReference, + String operationToken, + String actorReference, + NotificationReasonCode reasonCode, + Duration wireBudget) { + return new NotificationLegacyWriterPermitCommand( + Action.ACQUIRE, + route, + expectedGeneration, + transportProfileId, + permitToken, + holderReference, + operationToken, + actorReference, + reasonCode, + Optional.of(wireBudget)); + } + + public static NotificationLegacyWriterPermitCommand release( + NotificationCanonicalWriterRouteSet.RouteRevision route, + long expectedGeneration, + String transportProfileId, + String permitToken, + String holderReference, + String operationToken, + String actorReference, + NotificationReasonCode reasonCode) { + return new NotificationLegacyWriterPermitCommand( + Action.RELEASE, + route, + expectedGeneration, + transportProfileId, + permitToken, + holderReference, + operationToken, + actorReference, + reasonCode, + Optional.empty()); + } + + public enum Action { + ACQUIRE, + RELEASE + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationLegacyWriterPermitResult.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationLegacyWriterPermitResult.java new file mode 100644 index 00000000..7547674a --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationLegacyWriterPermitResult.java @@ -0,0 +1,45 @@ +package dev.caskeleton.application.notification; + +import java.time.Instant; +import java.util.Objects; +import java.util.Optional; + +/** Physically committed permit facts used by the legacy wrapper's monotonic wire deadline guard. */ +public record NotificationLegacyWriterPermitResult( + Status status, + String permitToken, + Optional acquiredAt, + Optional wireDeadline, + Optional expiresAt) { + + public NotificationLegacyWriterPermitResult { + Objects.requireNonNull(status, "legacy writer permit status must be non-null"); + permitToken = NotificationIntentId.requireOpaque("legacy writer permit token", permitToken); + Objects.requireNonNull(acquiredAt, "permit acquired-at container must be non-null"); + Objects.requireNonNull(wireDeadline, "permit wire-deadline container must be non-null"); + Objects.requireNonNull(expiresAt, "permit expiry container must be non-null"); + if (status == Status.ACQUIRED) { + Instant acquired = + acquiredAt.orElseThrow( + () -> new IllegalArgumentException("acquired permit requires DB acquired-at")); + Instant deadline = + wireDeadline.orElseThrow( + () -> new IllegalArgumentException("acquired permit requires wire deadline")); + Instant expiry = + expiresAt.orElseThrow( + () -> new IllegalArgumentException("acquired permit requires expiry")); + if (deadline.isBefore(acquired) || expiry.isBefore(deadline)) { + throw new IllegalArgumentException( + "permit timestamps must satisfy acquiredAt <= wireDeadline <= expiresAt"); + } + } else if (acquiredAt.isPresent() || wireDeadline.isPresent() || expiresAt.isPresent()) { + throw new IllegalArgumentException("non-acquired permit result must not expose wire times"); + } + } + + public enum Status { + ACQUIRED, + RELEASED, + REPLAYED + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationLegacyWriterPermitUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationLegacyWriterPermitUseCase.java new file mode 100644 index 00000000..d59eaba4 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationLegacyWriterPermitUseCase.java @@ -0,0 +1,59 @@ +package dev.caskeleton.application.notification; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.security.RequiresPermission; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.CommandUseCase; +import java.time.Clock; +import java.util.Objects; + +/** Root-commits bounded legacy permit acquire/release before any caller provider I/O. */ +@RequiresPermission("notification:cutover-admit") +@UseCaseCapability( + transactionMode = TransactionMode.WRITE, + idempotency = Idempotency.IDEMPOTENT, + repositoryAccess = RepositoryAccess.WRITE_REPOSITORY, + crossTenantAdmin = true) +public final class NotificationLegacyWriterPermitUseCase + implements CommandUseCase< + NotificationLegacyWriterPermitCommand, NotificationLegacyWriterPermitResult> { + + private final NotificationWriterRouteSet routes; + private final NotificationWriterCutoverPort cutover; + private final TransactionPort transactions; + private final Clock clock; + + public NotificationLegacyWriterPermitUseCase( + NotificationWriterRouteSet routes, + NotificationWriterCutoverPort cutover, + TransactionPort transactions, + Clock clock) { + this.routes = Objects.requireNonNull(routes, "notification writer route set must be non-null"); + this.cutover = + Objects.requireNonNull(cutover, "notification writer cutover port must be non-null"); + this.transactions = Objects.requireNonNull(transactions, "transaction port must be non-null"); + this.clock = Objects.requireNonNull(clock, "clock must be non-null"); + } + + @Override + public NotificationLegacyWriterPermitResult handle( + NotificationLegacyWriterPermitCommand command) { + Objects.requireNonNull(command, "legacy writer permit command must be non-null"); + NotificationWriterRouteSet.RouteProfile route = routes.requireRoute(command.route()); + NotificationWriterRouteSet.TransportProfile profile = + route.requireProfile(command.transportProfileId()); + if (command.action() == NotificationLegacyWriterPermitCommand.Action.ACQUIRE + && !profile.activeAdmissionProfile()) { + throw new IllegalArgumentException( + "new legacy permit acquire requires the active admission transport profile"); + } + return transactions.inRootWrite( + () -> + command.action() == NotificationLegacyWriterPermitCommand.Action.ACQUIRE + ? cutover.acquireLegacyPermit(command, route, clock.instant()) + : cutover.releaseLegacyPermit(command, route, clock.instant())); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationMaintenanceCommand.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationMaintenanceCommand.java new file mode 100644 index 00000000..cc979963 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationMaintenanceCommand.java @@ -0,0 +1,21 @@ +package dev.caskeleton.application.notification; + +import dev.caskeleton.application.command.Command; + +/** Bounded retention and redaction mutation request. */ +public record NotificationMaintenanceCommand( + int maximumExpiredIntents, int maximumPayloadRedactions, int maximumExpiredReceipts) + implements Command { + + public NotificationMaintenanceCommand { + long total = (long) maximumExpiredIntents + maximumPayloadRedactions + maximumExpiredReceipts; + if (maximumExpiredIntents < 0 + || maximumPayloadRedactions < 0 + || maximumExpiredReceipts < 0 + || total < 1 + || total > 100) { + throw new IllegalArgumentException( + "notification maintenance total mutation bound must be in 1..100"); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationMaintenanceResult.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationMaintenanceResult.java new file mode 100644 index 00000000..13edab51 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationMaintenanceResult.java @@ -0,0 +1,16 @@ +package dev.caskeleton.application.notification; + +/** Bounded non-sensitive maintenance outcome. */ +public record NotificationMaintenanceResult( + int expiredIntentCount, int redactedPayloadCount, int expiredReceiptCount) { + + public NotificationMaintenanceResult { + long total = (long) expiredIntentCount + redactedPayloadCount + expiredReceiptCount; + if (expiredIntentCount < 0 + || redactedPayloadCount < 0 + || expiredReceiptCount < 0 + || total > 100) { + throw new IllegalArgumentException("notification maintenance result must total 0..100"); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationMaintenanceStorePort.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationMaintenanceStorePort.java new file mode 100644 index 00000000..5379667b --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationMaintenanceStorePort.java @@ -0,0 +1,23 @@ +package dev.caskeleton.application.notification; + +import java.time.Instant; + +/** Performs one bounded local retention/redaction mutation batch. */ +@FunctionalInterface +public interface NotificationMaintenanceStorePort { + + MutationResult maintain(NotificationMaintenanceCommand command, Instant now); + + record MutationResult(int expiredIntentCount, int redactedPayloadCount, int expiredReceiptCount) { + + public MutationResult { + long total = (long) expiredIntentCount + redactedPayloadCount + expiredReceiptCount; + if (expiredIntentCount < 0 + || redactedPayloadCount < 0 + || expiredReceiptCount < 0 + || total > 100) { + throw new IllegalArgumentException("notification maintenance mutations must total 0..100"); + } + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationMaintenanceUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationMaintenanceUseCase.java new file mode 100644 index 00000000..e87e79bf --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationMaintenanceUseCase.java @@ -0,0 +1,44 @@ +package dev.caskeleton.application.notification; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.security.RequiresPermission; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.CommandUseCase; +import java.time.Clock; +import java.util.Objects; + +/** Runs one bounded notification retention/redaction mutation in a short write transaction. */ +@RequiresPermission("notification:maintain") +@UseCaseCapability( + transactionMode = TransactionMode.WRITE, + idempotency = Idempotency.IDEMPOTENT, + repositoryAccess = RepositoryAccess.WRITE_REPOSITORY, + crossTenantAdmin = true) +public final class NotificationMaintenanceUseCase + implements CommandUseCase { + + private final NotificationMaintenanceStorePort store; + private final TransactionPort transactions; + private final Clock clock; + + public NotificationMaintenanceUseCase( + NotificationMaintenanceStorePort store, TransactionPort transactions, Clock clock) { + this.store = Objects.requireNonNull(store, "notification maintenance store must be non-null"); + this.transactions = Objects.requireNonNull(transactions, "transaction port must be non-null"); + this.clock = Objects.requireNonNull(clock, "clock must be non-null"); + } + + @Override + public NotificationMaintenanceResult handle(NotificationMaintenanceCommand command) { + Objects.requireNonNull(command, "notification maintenance command must be non-null"); + NotificationMaintenanceStorePort.MutationResult mutation = + transactions.inWrite(() -> store.maintain(command, clock.instant())); + return new NotificationMaintenanceResult( + mutation.expiredIntentCount(), + mutation.redactedPayloadCount(), + mutation.expiredReceiptCount()); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationMode.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationMode.java new file mode 100644 index 00000000..d9bb2884 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationMode.java @@ -0,0 +1,7 @@ +package dev.caskeleton.application.notification; + +/** Code-owned delivery durability contract. */ +public enum NotificationMode { + BEST_EFFORT_INLINE, + DURABLE_ASYNC +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationOperationsSnapshot.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationOperationsSnapshot.java new file mode 100644 index 00000000..df256e39 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationOperationsSnapshot.java @@ -0,0 +1,57 @@ +package dev.caskeleton.application.notification; + +import java.time.Instant; +import java.util.List; +import java.util.Objects; + +/** Bounded non-sensitive operational aggregate returned through the application query boundary. */ +public record NotificationOperationsSnapshot( + Instant observedAt, + long pendingIntentCount, + long parkedDeliveryCount, + long orphanReceiptCount, + long activeLegacyPermitCount, + List writerRoutes) { + + private static final long MAXIMUM_COUNT = 1_000_000_000L; + + public NotificationOperationsSnapshot { + Objects.requireNonNull(observedAt, "notification snapshot time must be non-null"); + validateCount(pendingIntentCount); + validateCount(parkedDeliveryCount); + validateCount(orphanReceiptCount); + validateCount(activeLegacyPermitCount); + writerRoutes = + List.copyOf( + Objects.requireNonNull(writerRoutes, "writer route snapshots must be non-null")); + if (writerRoutes.size() > 100) { + throw new IllegalArgumentException("writer route snapshot exceeds 100 entries"); + } + } + + private static void validateCount(long count) { + if (count < 0 || count > MAXIMUM_COUNT) { + throw new IllegalArgumentException("notification operation count is outside 0..1000000000"); + } + } + + public record RouteWriterStatus( + NotificationRouteId routeId, + int routeRevision, + NotificationWriterOwnership owner, + long generation, + boolean draining) { + + public RouteWriterStatus { + Objects.requireNonNull(routeId, "notification route ID must be non-null"); + Objects.requireNonNull(owner, "notification writer owner must be non-null"); + if (routeRevision < 1 || generation < 0) { + throw new IllegalArgumentException( + "writer route revision must be positive and generation non-negative"); + } + if (draining && owner != NotificationWriterOwnership.LEGACY) { + throw new IllegalArgumentException("only LEGACY writer ownership may be draining"); + } + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationOperationsSnapshotPort.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationOperationsSnapshotPort.java new file mode 100644 index 00000000..8c74e79d --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationOperationsSnapshotPort.java @@ -0,0 +1,8 @@ +package dev.caskeleton.application.notification; + +/** Loads bounded non-sensitive operational aggregates from the durable store. */ +@FunctionalInterface +public interface NotificationOperationsSnapshotPort { + + NotificationOperationsSnapshot load(NotificationOperationsSnapshotQuery query); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationOperationsSnapshotQuery.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationOperationsSnapshotQuery.java new file mode 100644 index 00000000..ac11509b --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationOperationsSnapshotQuery.java @@ -0,0 +1,13 @@ +package dev.caskeleton.application.notification; + +import dev.caskeleton.application.query.Query; + +/** Requests at most a bounded number of route writer status rows. */ +public record NotificationOperationsSnapshotQuery(int maximumRoutes) implements Query { + + public NotificationOperationsSnapshotQuery { + if (maximumRoutes < 1 || maximumRoutes > 100) { + throw new IllegalArgumentException("maximum notification routes must be in 1..100"); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationOperationsSnapshotUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationOperationsSnapshotUseCase.java new file mode 100644 index 00000000..3869fb81 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationOperationsSnapshotUseCase.java @@ -0,0 +1,37 @@ +package dev.caskeleton.application.notification; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.security.RequiresPermission; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.QueryUseCase; +import java.util.Objects; + +/** Application query boundary for bounded notification operations visibility. */ +@RequiresPermission("notification:observe") +@UseCaseCapability( + transactionMode = TransactionMode.READ_ONLY, + idempotency = Idempotency.IDEMPOTENT, + repositoryAccess = RepositoryAccess.READ_REPOSITORY, + crossTenantAdmin = true) +public final class NotificationOperationsSnapshotUseCase + implements QueryUseCase { + + private final NotificationOperationsSnapshotPort snapshots; + private final TransactionPort transactions; + + public NotificationOperationsSnapshotUseCase( + NotificationOperationsSnapshotPort snapshots, TransactionPort transactions) { + this.snapshots = + Objects.requireNonNull(snapshots, "notification operations snapshot port must be non-null"); + this.transactions = Objects.requireNonNull(transactions, "transaction port must be non-null"); + } + + @Override + public NotificationOperationsSnapshot handle(NotificationOperationsSnapshotQuery query) { + Objects.requireNonNull(query, "notification operations snapshot query must be non-null"); + return transactions.inRead(() -> snapshots.load(query)); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationPlanPort.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationPlanPort.java new file mode 100644 index 00000000..95a6d47c --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationPlanPort.java @@ -0,0 +1,8 @@ +package dev.caskeleton.application.notification; + +/** Resolves a feature-owned draft into a provider-neutral immutable application plan. */ +@FunctionalInterface +public interface NotificationPlanPort { + + NotificationPlanningResult plan(NotificationIntentDraft draft); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationPlanningResult.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationPlanningResult.java new file mode 100644 index 00000000..28ee6914 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationPlanningResult.java @@ -0,0 +1,32 @@ +package dev.caskeleton.application.notification; + +import java.util.Objects; + +/** Closed result of compiling a feature draft without exposing adapter binding types. */ +public sealed interface NotificationPlanningResult + permits NotificationPlanningResult.Planned, + NotificationPlanningResult.Rejected, + NotificationPlanningResult.CapabilityUnavailable { + + record Planned(NotificationFrozenPlan plan) implements NotificationPlanningResult { + + public Planned { + Objects.requireNonNull(plan, "notification frozen plan must be non-null"); + } + } + + record Rejected(NotificationReasonCode reasonCode) implements NotificationPlanningResult { + + public Rejected { + Objects.requireNonNull(reasonCode, "notification reason code must be non-null"); + } + } + + record CapabilityUnavailable(NotificationReasonCode reasonCode) + implements NotificationPlanningResult { + + public CapabilityUnavailable { + Objects.requireNonNull(reasonCode, "notification reason code must be non-null"); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationProviderAttemptPort.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationProviderAttemptPort.java new file mode 100644 index 00000000..93094979 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationProviderAttemptPort.java @@ -0,0 +1,8 @@ +package dev.caskeleton.application.notification; + +/** Performs one previously authorized provider call and returns only a classified outcome. */ +@FunctionalInterface +public interface NotificationProviderAttemptPort { + + ProviderAttemptOutcome attempt(NotificationDeliveryStorePort.AuthorizedAttempt attempt); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationProviderCapabilityDescriptor.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationProviderCapabilityDescriptor.java new file mode 100644 index 00000000..eedf4d8c --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationProviderCapabilityDescriptor.java @@ -0,0 +1,41 @@ +package dev.caskeleton.application.notification; + +import java.util.Collections; +import java.util.EnumSet; +import java.util.Objects; +import java.util.Set; + +/** Provider-neutral capability facts consumed by the pure compatibility validator. */ +public record NotificationProviderCapabilityDescriptor( + String capabilityReference, + NotificationChannel channel, + Set supportedModes, + boolean receiptSupported, + boolean reconciliationSupported, + boolean hiddenRetriesControlled, + int maximumTargets, + int maximumPayloadBytes) { + + public NotificationProviderCapabilityDescriptor { + capabilityReference = + NotificationIntentId.requireOpaque( + "notification provider capability reference", capabilityReference); + Objects.requireNonNull(channel, "notification provider channel must be non-null"); + Objects.requireNonNull(supportedModes, "notification provider modes must be non-null"); + EnumSet modes = + supportedModes.isEmpty() + ? EnumSet.noneOf(NotificationMode.class) + : EnumSet.copyOf(supportedModes); + if (modes.isEmpty()) { + throw new IllegalArgumentException("notification provider must support at least one mode"); + } + supportedModes = Collections.unmodifiableSet(modes); + if (maximumTargets < 1 || maximumTargets > 16) { + throw new IllegalArgumentException("notification provider maximum targets must be in 1..16"); + } + if (maximumPayloadBytes < 1 || maximumPayloadBytes > 10_000_000) { + throw new IllegalArgumentException( + "notification provider maximum payload must be in 1..10000000 bytes"); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationReasonCode.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationReasonCode.java new file mode 100644 index 00000000..11322bf8 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationReasonCode.java @@ -0,0 +1,12 @@ +package dev.caskeleton.application.notification; + +/** Bounded stable operational reason code; never a provider error body or SDK exception message. */ +public record NotificationReasonCode(String value) { + + public NotificationReasonCode { + if (value == null || !value.matches("[A-Z][A-Z0-9_]{0,63}")) { + throw new IllegalArgumentException( + "notification reason code must match [A-Z][A-Z0-9_]{0,63}"); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationReceiptEventId.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationReceiptEventId.java new file mode 100644 index 00000000..dd875177 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationReceiptEventId.java @@ -0,0 +1,9 @@ +package dev.caskeleton.application.notification; + +/** Opaque identity used to deduplicate one normalized provider receipt event. */ +public record NotificationReceiptEventId(String value) { + + public NotificationReceiptEventId { + value = NotificationIntentId.requireOpaque("receiptEventId", value); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationReceiptFact.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationReceiptFact.java new file mode 100644 index 00000000..7faa18da --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationReceiptFact.java @@ -0,0 +1,34 @@ +package dev.caskeleton.application.notification; + +import java.time.Instant; +import java.util.Objects; + +/** One normalized, immutable provider feedback fact. */ +public record NotificationReceiptFact( + Type type, BounceClass bounceClass, NotificationReasonCode reasonCode, Instant occurredAt) { + + public NotificationReceiptFact { + Objects.requireNonNull(type, "notification receipt type must be non-null"); + Objects.requireNonNull(bounceClass, "notification bounce class must be non-null"); + Objects.requireNonNull(reasonCode, "notification reason code must be non-null"); + Objects.requireNonNull(occurredAt, "notification receipt occurrence time must be non-null"); + if ((type == Type.BOUNCE) != (bounceClass != BounceClass.NONE)) { + throw new IllegalArgumentException( + "BOUNCE requires HARD or SOFT classification and other facts require NONE"); + } + } + + public enum Type { + SEND, + DELIVERY, + BOUNCE, + COMPLAINT, + DELIVERY_DELAY + } + + public enum BounceClass { + NONE, + SOFT, + HARD + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationReceiptIngressCapabilityDescriptor.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationReceiptIngressCapabilityDescriptor.java new file mode 100644 index 00000000..bae4f331 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationReceiptIngressCapabilityDescriptor.java @@ -0,0 +1,25 @@ +package dev.caskeleton.application.notification; + +import java.util.Collections; +import java.util.EnumSet; +import java.util.Objects; +import java.util.Set; + +/** Authenticated normalized receipt-ingress capabilities for one channel. */ +public record NotificationReceiptIngressCapabilityDescriptor( + NotificationChannel channel, + boolean enabled, + boolean authenticated, + Set supportedFactTypes) { + + public NotificationReceiptIngressCapabilityDescriptor { + Objects.requireNonNull(channel, "notification receipt ingress channel must be non-null"); + Objects.requireNonNull( + supportedFactTypes, "notification receipt ingress fact types must be non-null"); + EnumSet factTypes = + supportedFactTypes.isEmpty() + ? EnumSet.noneOf(NotificationReceiptFact.Type.class) + : EnumSet.copyOf(supportedFactTypes); + supportedFactTypes = Collections.unmodifiableSet(factTypes); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationReceiptProjection.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationReceiptProjection.java new file mode 100644 index 00000000..079d337a --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationReceiptProjection.java @@ -0,0 +1,70 @@ +package dev.caskeleton.application.notification; + +import java.time.Instant; +import java.util.List; +import java.util.Objects; + +/** Order-independent orthogonal projection derived only from immutable receipt facts. */ +public record NotificationReceiptProjection( + boolean submissionAccepted, + boolean delivered, + boolean softBounced, + boolean hardBounced, + boolean complained, + boolean deliveryDelayed, + Instant latestFactAt, + int factCount) { + + public NotificationReceiptProjection { + Objects.requireNonNull(latestFactAt, "latest receipt fact time must be non-null"); + if (factCount < 1 || factCount > 100) { + throw new IllegalArgumentException("receipt projection fact count must be in 1..100"); + } + if ((delivered || softBounced || hardBounced || complained || deliveryDelayed) + && !submissionAccepted) { + throw new IllegalArgumentException( + "delivery feedback cannot erase or contradict provider acceptance"); + } + } + + public static NotificationReceiptProjection reduce(List facts) { + Objects.requireNonNull(facts, "notification receipt facts must be non-null"); + List immutableFacts = List.copyOf(facts); + if (immutableFacts.isEmpty() || immutableFacts.size() > 100) { + throw new IllegalArgumentException("notification receipt facts must contain 1..100 entries"); + } + + boolean accepted = false; + boolean delivered = false; + boolean softBounced = false; + boolean hardBounced = false; + boolean complained = false; + boolean delayed = false; + Instant latest = Instant.MIN; + for (NotificationReceiptFact fact : immutableFacts) { + Objects.requireNonNull(fact, "notification receipt fact must be non-null"); + accepted = true; + delivered |= fact.type() == NotificationReceiptFact.Type.DELIVERY; + softBounced |= + fact.type() == NotificationReceiptFact.Type.BOUNCE + && fact.bounceClass() == NotificationReceiptFact.BounceClass.SOFT; + hardBounced |= + fact.type() == NotificationReceiptFact.Type.BOUNCE + && fact.bounceClass() == NotificationReceiptFact.BounceClass.HARD; + complained |= fact.type() == NotificationReceiptFact.Type.COMPLAINT; + delayed |= fact.type() == NotificationReceiptFact.Type.DELIVERY_DELAY; + if (fact.occurredAt().isAfter(latest)) { + latest = fact.occurredAt(); + } + } + return new NotificationReceiptProjection( + accepted, + delivered, + softBounced, + hardBounced, + complained, + delayed, + latest, + immutableFacts.size()); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationReceiptStorePort.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationReceiptStorePort.java new file mode 100644 index 00000000..18269be6 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationReceiptStorePort.java @@ -0,0 +1,52 @@ +package dev.caskeleton.application.notification; + +import java.util.List; +import java.util.Objects; + +/** Durable receipt inbox and delivery projection mutation boundary. */ +public interface NotificationReceiptStorePort { + + AppendResult appendIfAbsent(NormalizedNotificationReceiptCommand command); + + void saveProjection(NotificationDeliveryId deliveryId, NotificationReceiptProjection projection); + + sealed interface AppendResult permits Appended, Duplicate {} + + record Appended(ReceiptAggregate aggregate) implements AppendResult { + + public Appended { + Objects.requireNonNull(aggregate, "notification receipt aggregate must be non-null"); + } + } + + record Duplicate(NotificationReceiptProjection projection) implements AppendResult { + + public Duplicate { + Objects.requireNonNull(projection, "notification receipt projection must be non-null"); + } + } + + record ReceiptAggregate( + NotificationDeliveryId deliveryId, + NotificationRecipientReference recipient, + List facts) { + + public ReceiptAggregate { + Objects.requireNonNull(deliveryId, "notification delivery ID must be non-null"); + Objects.requireNonNull(recipient, "notification recipient reference must be non-null"); + facts = + List.copyOf(Objects.requireNonNull(facts, "notification receipt facts must be non-null")); + if (facts.isEmpty() || facts.size() > 100) { + throw new IllegalArgumentException( + "notification receipt aggregate must contain 1..100 facts"); + } + } + + @Override + public String toString() { + return "ReceiptAggregate[deliveryId=, recipient=, factCount=" + + facts.size() + + "]"; + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationRecipientReference.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationRecipientReference.java new file mode 100644 index 00000000..3b18c233 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationRecipientReference.java @@ -0,0 +1,8 @@ +package dev.caskeleton.application.notification; + +/** Channel-typed opaque recipient reference; raw addresses are forbidden at this boundary. */ +public sealed interface NotificationRecipientReference + permits EmailRecipientReference, SlackAudienceReference { + + NotificationChannel channel(); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationReconciliationPort.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationReconciliationPort.java new file mode 100644 index 00000000..ebd32079 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationReconciliationPort.java @@ -0,0 +1,19 @@ +package dev.caskeleton.application.notification; + +import java.util.Objects; + +/** Performs one bounded provider reconciliation outside any database transaction. */ +@FunctionalInterface +public interface NotificationReconciliationPort { + + ReconciliationOutcome reconcile(NotificationDeliveryStorePort.ReconciliationClaim claim); + + record ReconciliationOutcome( + SubmissionCertainty submissionCertainty, NotificationReasonCode reasonCode) { + + public ReconciliationOutcome { + Objects.requireNonNull(submissionCertainty, "submission certainty must be non-null"); + Objects.requireNonNull(reasonCode, "notification reason code must be non-null"); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationRequestResult.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationRequestResult.java new file mode 100644 index 00000000..eff18bd5 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationRequestResult.java @@ -0,0 +1,71 @@ +package dev.caskeleton.application.notification; + +import java.util.List; +import java.util.Objects; + +/** Closed result union for a notification request; append is not delivery success. */ +public sealed interface NotificationRequestResult + permits NotificationRequestResult.InlineCompleted, + NotificationRequestResult.AppendedDurably, + NotificationRequestResult.DuplicateExistingIntent, + NotificationRequestResult.RejectedByBusinessPolicy, + NotificationRequestResult.RejectedInvalidRequest, + NotificationRequestResult.CapabilityUnavailable { + + record InlineCompleted(NotificationIntentId intentId, List outcomes) + implements NotificationRequestResult { + + public InlineCompleted { + Objects.requireNonNull(intentId, "notification intent ID must be non-null"); + Objects.requireNonNull(outcomes, "inline outcomes must be non-null"); + outcomes = List.copyOf(outcomes); + if (outcomes.isEmpty() || outcomes.size() > 16) { + throw new IllegalArgumentException("inline outcomes must contain 1..16 targets"); + } + long distinctOrdinals = + outcomes.stream().map(TargetAttemptOutcome::targetOrdinal).distinct().count(); + if (distinctOrdinals != outcomes.size()) { + throw new IllegalArgumentException("inline target ordinals must be unique"); + } + } + } + + record AppendedDurably(NotificationIntentId intentId) implements NotificationRequestResult { + + public AppendedDurably { + Objects.requireNonNull(intentId, "notification intent ID must be non-null"); + } + } + + record DuplicateExistingIntent(NotificationIntentId intentId) + implements NotificationRequestResult { + + public DuplicateExistingIntent { + Objects.requireNonNull(intentId, "notification intent ID must be non-null"); + } + } + + record RejectedByBusinessPolicy(NotificationReasonCode reasonCode) + implements NotificationRequestResult { + + public RejectedByBusinessPolicy { + Objects.requireNonNull(reasonCode, "notification reason code must be non-null"); + } + } + + record RejectedInvalidRequest(NotificationReasonCode reasonCode) + implements NotificationRequestResult { + + public RejectedInvalidRequest { + Objects.requireNonNull(reasonCode, "notification reason code must be non-null"); + } + } + + record CapabilityUnavailable(NotificationReasonCode reasonCode) + implements NotificationRequestResult { + + public CapabilityUnavailable { + Objects.requireNonNull(reasonCode, "notification reason code must be non-null"); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationRouteId.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationRouteId.java new file mode 100644 index 00000000..bd153861 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationRouteId.java @@ -0,0 +1,9 @@ +package dev.caskeleton.application.notification; + +/** Closed-catalog logical technical route; never a provider or endpoint identifier. */ +public record NotificationRouteId(String value) { + + public NotificationRouteId { + value = NotificationIntentId.requireSlug("routeId", value); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationRouteStrategy.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationRouteStrategy.java new file mode 100644 index 00000000..c7be8612 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationRouteStrategy.java @@ -0,0 +1,8 @@ +package dev.caskeleton.application.notification; + +/** Closed provider-leg expansion strategy for one logical recipient. */ +public enum NotificationRouteStrategy { + SINGLE, + FAN_OUT_ALL, + ORDERED_FALLBACK +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationSignedEvidenceHeader.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationSignedEvidenceHeader.java new file mode 100644 index 00000000..969287dd --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationSignedEvidenceHeader.java @@ -0,0 +1,281 @@ +package dev.caskeleton.application.notification; + +import java.time.Duration; +import java.time.Instant; +import java.util.Arrays; +import java.util.Objects; + +/** + * Bounded immutable signed-evidence header; cryptographic verification belongs to verifier ports. + */ +public final class NotificationSignedEvidenceHeader { + + private final String canonicalProfile; + private final byte[] canonicalPayload; + private final byte[] signature; + private final String signatureAlgorithm; + private final String issuerKeyId; + private final byte[] issuerPublicKeySpki; + private final String issuerPublicKeyDigest; + private final NotificationEvidenceTrustSnapshot trustSnapshot; + private final Instant issuedAt; + private final Instant expiresAt; + private final Duration allowedClockSkew; + private final Duration acceptanceMargin; + private final String environmentId; + private final String databaseId; + private final String artifactId; + private final String consumerInventoryId; + private final String providerCallLedgerId; + private final String providerCallLedgerSnapshot; + private final int childCount; + private final String childSetDigest; + + public NotificationSignedEvidenceHeader( + String canonicalProfile, + byte[] canonicalPayload, + byte[] signature, + String signatureAlgorithm, + String issuerKeyId, + byte[] issuerPublicKeySpki, + String issuerPublicKeyDigest, + NotificationEvidenceTrustSnapshot trustSnapshot, + Instant issuedAt, + Instant expiresAt, + Duration allowedClockSkew, + Duration acceptanceMargin, + String environmentId, + String databaseId, + String artifactId, + String consumerInventoryId, + String providerCallLedgerId, + String providerCallLedgerSnapshot, + int childCount, + String childSetDigest) { + this.canonicalProfile = + NotificationIntentId.requireSlug("signed evidence canonical profile", canonicalProfile); + this.canonicalPayload = copyBounded("canonical evidence payload", canonicalPayload, 1, 65_536); + this.signature = copyBounded("evidence signature", signature, 64, 128); + if (!"Ed25519".equals(signatureAlgorithm)) { + throw new IllegalArgumentException("signed evidence algorithm must be Ed25519"); + } + this.signatureAlgorithm = signatureAlgorithm; + this.issuerKeyId = NotificationIntentId.requireOpaque("evidence issuer key ID", issuerKeyId); + this.issuerPublicKeySpki = + copyBounded("evidence issuer public-key SPKI", issuerPublicKeySpki, 32, 1_024); + this.issuerPublicKeyDigest = + InitializeNotificationWriterFencesCommand.requireDigest(issuerPublicKeyDigest); + this.trustSnapshot = + Objects.requireNonNull(trustSnapshot, "evidence trust snapshot must be non-null"); + if (!this.issuerPublicKeyDigest.equals(trustSnapshot.issuerKeyDigest())) { + throw new IllegalArgumentException("evidence issuer key digest must match trust snapshot"); + } + this.issuedAt = Objects.requireNonNull(issuedAt, "evidence issued-at must be non-null"); + this.expiresAt = Objects.requireNonNull(expiresAt, "evidence expires-at must be non-null"); + this.allowedClockSkew = + Objects.requireNonNull(allowedClockSkew, "evidence allowed clock skew must be non-null"); + this.acceptanceMargin = + Objects.requireNonNull(acceptanceMargin, "evidence acceptance margin must be non-null"); + if (!expiresAt.isAfter(issuedAt) + || allowedClockSkew.isNegative() + || allowedClockSkew.compareTo(Duration.ofMinutes(5)) > 0 + || acceptanceMargin.isNegative() + || acceptanceMargin.compareTo(Duration.ofMinutes(5)) > 0 + || !expiresAt.minus(acceptanceMargin).isAfter(issuedAt.minus(allowedClockSkew))) { + throw new IllegalArgumentException("signed evidence acceptance window is invalid"); + } + this.environmentId = + NotificationIntentId.requireOpaque("evidence environment identity", environmentId); + this.databaseId = NotificationIntentId.requireOpaque("evidence database identity", databaseId); + this.artifactId = NotificationIntentId.requireOpaque("evidence artifact identity", artifactId); + this.consumerInventoryId = + NotificationIntentId.requireOpaque( + "evidence consumer inventory identity", consumerInventoryId); + this.providerCallLedgerId = + NotificationIntentId.requireOpaque( + "evidence provider-call ledger identity", providerCallLedgerId); + this.providerCallLedgerSnapshot = + NotificationIntentId.requireOpaque( + "evidence provider-call ledger snapshot", providerCallLedgerSnapshot); + if (childCount < 0 || childCount > 1_000) { + throw new IllegalArgumentException("signed evidence child count must be in 0..1000"); + } + this.childCount = childCount; + this.childSetDigest = InitializeNotificationWriterFencesCommand.requireDigest(childSetDigest); + } + + public String canonicalProfile() { + return canonicalProfile; + } + + public byte[] canonicalPayload() { + return Arrays.copyOf(canonicalPayload, canonicalPayload.length); + } + + public byte[] signature() { + return Arrays.copyOf(signature, signature.length); + } + + public String signatureAlgorithm() { + return signatureAlgorithm; + } + + public String issuerKeyId() { + return issuerKeyId; + } + + public byte[] issuerPublicKeySpki() { + return Arrays.copyOf(issuerPublicKeySpki, issuerPublicKeySpki.length); + } + + public String issuerPublicKeyDigest() { + return issuerPublicKeyDigest; + } + + public NotificationEvidenceTrustSnapshot trustSnapshot() { + return trustSnapshot; + } + + public Instant issuedAt() { + return issuedAt; + } + + public Instant expiresAt() { + return expiresAt; + } + + public Duration allowedClockSkew() { + return allowedClockSkew; + } + + public Duration acceptanceMargin() { + return acceptanceMargin; + } + + public String environmentId() { + return environmentId; + } + + public String databaseId() { + return databaseId; + } + + public String artifactId() { + return artifactId; + } + + public String consumerInventoryId() { + return consumerInventoryId; + } + + public String providerCallLedgerId() { + return providerCallLedgerId; + } + + public String providerCallLedgerSnapshot() { + return providerCallLedgerSnapshot; + } + + public int childCount() { + return childCount; + } + + public String childSetDigest() { + return childSetDigest; + } + + @Override + public boolean equals(Object candidate) { + if (this == candidate) { + return true; + } + if (!(candidate instanceof NotificationSignedEvidenceHeader other)) { + return false; + } + return childCount == other.childCount + && canonicalProfile.equals(other.canonicalProfile) + && Arrays.equals(canonicalPayload, other.canonicalPayload) + && Arrays.equals(signature, other.signature) + && signatureAlgorithm.equals(other.signatureAlgorithm) + && issuerKeyId.equals(other.issuerKeyId) + && Arrays.equals(issuerPublicKeySpki, other.issuerPublicKeySpki) + && issuerPublicKeyDigest.equals(other.issuerPublicKeyDigest) + && trustSnapshot.equals(other.trustSnapshot) + && issuedAt.equals(other.issuedAt) + && expiresAt.equals(other.expiresAt) + && allowedClockSkew.equals(other.allowedClockSkew) + && acceptanceMargin.equals(other.acceptanceMargin) + && environmentId.equals(other.environmentId) + && databaseId.equals(other.databaseId) + && artifactId.equals(other.artifactId) + && consumerInventoryId.equals(other.consumerInventoryId) + && providerCallLedgerId.equals(other.providerCallLedgerId) + && providerCallLedgerSnapshot.equals(other.providerCallLedgerSnapshot) + && childSetDigest.equals(other.childSetDigest); + } + + @Override + public int hashCode() { + int result = + Objects.hash( + canonicalProfile, + signatureAlgorithm, + issuerKeyId, + issuerPublicKeyDigest, + trustSnapshot, + issuedAt, + expiresAt, + allowedClockSkew, + acceptanceMargin, + environmentId, + databaseId, + artifactId, + consumerInventoryId, + providerCallLedgerId, + providerCallLedgerSnapshot, + childCount, + childSetDigest); + result = 31 * result + Arrays.hashCode(canonicalPayload); + result = 31 * result + Arrays.hashCode(signature); + result = 31 * result + Arrays.hashCode(issuerPublicKeySpki); + return result; + } + + @Override + public String toString() { + return "NotificationSignedEvidenceHeader[canonicalProfile=" + + canonicalProfile + + ", canonicalPayload=, signature=, signatureAlgorithm=" + + signatureAlgorithm + + ", issuerKeyId=" + + issuerKeyId + + ", issuerPublicKeySpki=, issuerPublicKeyDigest=" + + issuerPublicKeyDigest + + ", trustSnapshot=" + + trustSnapshot + + ", issuedAt=" + + issuedAt + + ", expiresAt=" + + expiresAt + + ", consumerInventoryId=" + + consumerInventoryId + + ", providerCallLedgerId=" + + providerCallLedgerId + + ", providerCallLedgerSnapshot=" + + providerCallLedgerSnapshot + + ", childCount=" + + childCount + + ", childSetDigest=" + + childSetDigest + + "]"; + } + + private static byte[] copyBounded( + String field, byte[] value, int minimumLength, int maximumLength) { + if (value == null || value.length < minimumLength || value.length > maximumLength) { + throw new IllegalArgumentException( + field + " length must be in " + minimumLength + ".." + maximumLength); + } + return Arrays.copyOf(value, value.length); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationStoreCapabilityDescriptor.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationStoreCapabilityDescriptor.java new file mode 100644 index 00000000..8f32c71d --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationStoreCapabilityDescriptor.java @@ -0,0 +1,41 @@ +package dev.caskeleton.application.notification; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Objects; +import java.util.Set; +import java.util.TreeSet; + +/** Durable store capabilities and retained frozen revisions visible to application validation. */ +public record NotificationStoreCapabilityDescriptor( + boolean durableIntentStore, + boolean attemptJournal, + boolean receiptInbox, + int maximumBatch, + Set availablePolicyRevisions, + Set availableTemplateRevisions) { + + public NotificationStoreCapabilityDescriptor { + if (maximumBatch < 1 || maximumBatch > 100) { + throw new IllegalArgumentException("notification store maximum batch must be in 1..100"); + } + Objects.requireNonNull(availablePolicyRevisions, "available policy revisions must be non-null"); + TreeSet policies = new TreeSet<>(availablePolicyRevisions); + if (policies.isEmpty() + || policies.size() > 100 + || policies.stream().anyMatch(revision -> revision == null || revision < 1)) { + throw new IllegalArgumentException( + "available policy revisions must contain 1..100 positive revisions"); + } + availablePolicyRevisions = Collections.unmodifiableSet(policies); + Objects.requireNonNull( + availableTemplateRevisions, "available template revisions must be non-null"); + HashSet templates = new HashSet<>(availableTemplateRevisions); + if (templates.isEmpty() + || templates.size() > 100 + || templates.stream().anyMatch(Objects::isNull)) { + throw new IllegalArgumentException("available template revisions must contain 1..100 values"); + } + availableTemplateRevisions = Collections.unmodifiableSet(templates); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationTechnicalSuppressionPort.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationTechnicalSuppressionPort.java new file mode 100644 index 00000000..10d7b26f --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationTechnicalSuppressionPort.java @@ -0,0 +1,32 @@ +package dev.caskeleton.application.notification; + +import java.time.Instant; +import java.util.Objects; + +/** Persists technical suppression caused by hard bounce or complaint, not business consent. */ +@FunctionalInterface +public interface NotificationTechnicalSuppressionPort { + + void suppress(SuppressionMutation mutation); + + record SuppressionMutation( + NotificationRecipientReference recipient, + NotificationReasonCode reasonCode, + Instant suppressedAt) { + + public SuppressionMutation { + Objects.requireNonNull(recipient, "notification recipient reference must be non-null"); + Objects.requireNonNull(reasonCode, "notification reason code must be non-null"); + Objects.requireNonNull(suppressedAt, "notification suppression time must be non-null"); + } + + @Override + public String toString() { + return "SuppressionMutation[recipient=, reasonCode=" + + reasonCode + + ", suppressedAt=" + + suppressedAt + + "]"; + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationTemplateParameters.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationTemplateParameters.java new file mode 100644 index 00000000..eeeee958 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationTemplateParameters.java @@ -0,0 +1,36 @@ +package dev.caskeleton.application.notification; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; + +/** Bounded, immutable and redacted template parameter bag over a closed scalar value set. */ +public record NotificationTemplateParameters(Map values) { + + private static final int MAXIMUM_PARAMETERS = 32; + + public NotificationTemplateParameters { + Objects.requireNonNull(values, "template parameters must be non-null"); + if (values.size() > MAXIMUM_PARAMETERS) { + throw new IllegalArgumentException( + "template parameters exceed " + MAXIMUM_PARAMETERS + " entries"); + } + LinkedHashMap copy = new LinkedHashMap<>(); + values.forEach( + (name, value) -> { + if (name == null || !name.matches("[a-z][A-Za-z0-9]{0,63}")) { + throw new IllegalArgumentException( + "template parameter name must match [a-z][A-Za-z0-9]{0,63}"); + } + copy.put( + name, Objects.requireNonNull(value, "template parameter value must be non-null")); + }); + values = Collections.unmodifiableMap(copy); + } + + @Override + public String toString() { + return "NotificationTemplateParameters[names=" + values.keySet() + ", values=]"; + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationTemplateRef.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationTemplateRef.java new file mode 100644 index 00000000..775a2c62 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationTemplateRef.java @@ -0,0 +1,12 @@ +package dev.caskeleton.application.notification; + +/** Immutable checked-in template identity and version. */ +public record NotificationTemplateRef(String templateId, int version) { + + public NotificationTemplateRef { + templateId = NotificationIntentId.requireSlug("templateId", templateId); + if (version < 1 || version > 1_000_000) { + throw new IllegalArgumentException("template version must be in 1..1000000"); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationTemplateValue.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationTemplateValue.java new file mode 100644 index 00000000..d9da83de --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationTemplateValue.java @@ -0,0 +1,103 @@ +package dev.caskeleton.application.notification; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Currency; +import java.util.Objects; + +/** + * Closed scalar set accepted by the generic template boundary. Provider objects, raw HTML, JSON, + * collections and arbitrary objects cannot implement this sealed contract. + */ +public sealed interface NotificationTemplateValue + permits NotificationTemplateValue.SafeText, + NotificationTemplateValue.TrustedAbsoluteLinkReference, + NotificationTemplateValue.LocalDateValue, + NotificationTemplateValue.LocalDateTimeValue, + NotificationTemplateValue.IntegerValue, + NotificationTemplateValue.MoneyValue { + + record SafeText(String value) implements NotificationTemplateValue { + + public SafeText { + if (value == null || value.isBlank() || value.length() > 4_096) { + throw new IllegalArgumentException("safe text must contain 1..4096 characters"); + } + if (value.chars().anyMatch(character -> character == 0)) { + throw new IllegalArgumentException("safe text must not contain NUL"); + } + } + + @Override + public String toString() { + return "SafeText[value=]"; + } + } + + record TrustedAbsoluteLinkReference(String value) implements NotificationTemplateValue { + + public TrustedAbsoluteLinkReference { + value = NotificationIntentId.requireOpaque("trusted link reference", value); + } + + @Override + public String toString() { + return "TrustedAbsoluteLinkReference[value=]"; + } + } + + record LocalDateValue(LocalDate value) implements NotificationTemplateValue { + + public LocalDateValue { + Objects.requireNonNull(value, "local date value must be non-null"); + } + + @Override + public String toString() { + return "LocalDateValue[value=]"; + } + } + + record LocalDateTimeValue(LocalDateTime value, ZoneId zone) implements NotificationTemplateValue { + + public LocalDateTimeValue { + Objects.requireNonNull(value, "local date-time value must be non-null"); + Objects.requireNonNull(zone, "business time zone must be non-null"); + if (zone.getId().length() > 64) { + throw new IllegalArgumentException("business time zone exceeds 64 characters"); + } + } + + @Override + public String toString() { + return "LocalDateTimeValue[value=, zone=]"; + } + } + + record IntegerValue(long value) implements NotificationTemplateValue { + + @Override + public String toString() { + return "IntegerValue[value=]"; + } + } + + record MoneyValue(BigDecimal amount, Currency currency) implements NotificationTemplateValue { + + public MoneyValue { + Objects.requireNonNull(amount, "money amount must be non-null"); + Objects.requireNonNull(currency, "money currency must be non-null"); + if (amount.scale() < 0 || amount.scale() > 4 || amount.precision() > 19) { + throw new IllegalArgumentException( + "money amount must have precision at most 19 and scale in 0..4"); + } + } + + @Override + public String toString() { + return "MoneyValue[amount=, currency=]"; + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationWriterCutoverPort.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationWriterCutoverPort.java new file mode 100644 index 00000000..9c8ff08f --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationWriterCutoverPort.java @@ -0,0 +1,17 @@ +package dev.caskeleton.application.notification; + +import java.time.Instant; + +/** Durable legacy permit mutation boundary; ownership operations use dedicated operation ports. */ +public interface NotificationWriterCutoverPort { + + NotificationLegacyWriterPermitResult acquireLegacyPermit( + NotificationLegacyWriterPermitCommand command, + NotificationWriterRouteSet.RouteProfile routeProfile, + Instant requestedAt); + + NotificationLegacyWriterPermitResult releaseLegacyPermit( + NotificationLegacyWriterPermitCommand command, + NotificationWriterRouteSet.RouteProfile routeProfile, + Instant requestedAt); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationWriterInventoryEvidence.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationWriterInventoryEvidence.java new file mode 100644 index 00000000..f5551f0e --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationWriterInventoryEvidence.java @@ -0,0 +1,36 @@ +package dev.caskeleton.application.notification; + +import java.time.Instant; +import java.util.Collections; +import java.util.Objects; +import java.util.Set; +import java.util.TreeSet; + +/** + * Verified complete BEGIN inventory evidence; never construct from caller-authored digests alone. + */ +public record NotificationWriterInventoryEvidence( + NotificationCanonicalWriterRouteSet.RouteRevision route, + long generation, + Set nodeIds, + String nodeSetDigest, + Instant verifiedAt) { + + public NotificationWriterInventoryEvidence { + Objects.requireNonNull(route, "verified writer inventory route must be non-null"); + if (generation < 0) { + throw new IllegalArgumentException( + "verified writer inventory generation must be non-negative"); + } + Objects.requireNonNull(nodeIds, "verified writer inventory nodes must be non-null"); + TreeSet nodes = new TreeSet<>(); + nodeIds.forEach( + node -> nodes.add(NotificationIntentId.requireOpaque("writer inventory node ID", node))); + if (nodes.size() > 100) { + throw new IllegalArgumentException("verified writer inventory exceeds 100 nodes"); + } + nodeIds = Collections.unmodifiableSet(nodes); + nodeSetDigest = InitializeNotificationWriterFencesCommand.requireDigest(nodeSetDigest); + Objects.requireNonNull(verifiedAt, "writer inventory verification time must be non-null"); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationWriterInventoryEvidenceVerifierPort.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationWriterInventoryEvidenceVerifierPort.java new file mode 100644 index 00000000..f94dfb39 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationWriterInventoryEvidenceVerifierPort.java @@ -0,0 +1,14 @@ +package dev.caskeleton.application.notification; + +import java.time.Instant; + +/** Verifies signed BEGIN inventory against the closed issuer trust catalog and exact context. */ +@FunctionalInterface +public interface NotificationWriterInventoryEvidenceVerifierPort { + + NotificationWriterInventoryEvidence verify( + SignedNotificationWriterInventoryManifest manifest, + NotificationCanonicalWriterRouteSet.RouteRevision expectedRoute, + long expectedGeneration, + Instant verifiedAt); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationWriterOwnership.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationWriterOwnership.java new file mode 100644 index 00000000..39d58ab9 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationWriterOwnership.java @@ -0,0 +1,7 @@ +package dev.caskeleton.application.notification; + +/** Exclusive writer owner stored in the route-specific database fence. */ +public enum NotificationWriterOwnership { + LEGACY, + CANONICAL +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationWriterQuiescenceAttestationPort.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationWriterQuiescenceAttestationPort.java new file mode 100644 index 00000000..2edc22a0 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationWriterQuiescenceAttestationPort.java @@ -0,0 +1,34 @@ +package dev.caskeleton.application.notification; + +import java.time.Instant; +import java.util.Objects; + +/** Verifies signed quiescence payload, issuer trust and exact route/registry context. */ +@FunctionalInterface +public interface NotificationWriterQuiescenceAttestationPort { + + VerifiedQuiescenceEvidence verify( + SignedNotificationWriterQuiescenceManifest manifest, + NotificationCanonicalWriterRouteSet.RouteRevision expectedRoute, + long expectedGeneration, + NotificationWriterRouteSet.RouteProfile trustedRoute, + Instant verifiedAt); + + record VerifiedQuiescenceEvidence( + NotificationCanonicalWriterRouteSet.RouteRevision route, + long generation, + String childSetDigest, + int childCount, + Instant verifiedAt) { + + public VerifiedQuiescenceEvidence { + Objects.requireNonNull(route, "verified quiescence route must be non-null"); + if (generation < 0 || childCount < 0 || childCount > 1_000) { + throw new IllegalArgumentException( + "verified quiescence generation/count is outside bounds"); + } + childSetDigest = InitializeNotificationWriterFencesCommand.requireDigest(childSetDigest); + Objects.requireNonNull(verifiedAt, "quiescence verification time must be non-null"); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationWriterRouteSet.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationWriterRouteSet.java new file mode 100644 index 00000000..e2d13bf1 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationWriterRouteSet.java @@ -0,0 +1,157 @@ +package dev.caskeleton.application.notification; + +import java.nio.ByteBuffer; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +/** + * PRE-only route set decorating the canonical route keys with legacy aliases and the complete + * current-plus-retiring transport proof registry. + */ +public record NotificationWriterRouteSet( + NotificationCanonicalWriterRouteSet canonicalRoutes, List routeProfiles) { + + public NotificationWriterRouteSet { + Objects.requireNonNull(canonicalRoutes, "canonical writer route set must be non-null"); + Objects.requireNonNull(routeProfiles, "writer route profiles must be non-null"); + routeProfiles = + routeProfiles.stream() + .map( + profile -> Objects.requireNonNull(profile, "writer route profile must be non-null")) + .sorted( + Comparator.comparing((RouteProfile profile) -> profile.route().routeId().value()) + .thenComparingInt(profile -> profile.route().routeRevision())) + .toList(); + if (routeProfiles.size() != canonicalRoutes.routes().size()) { + throw new IllegalArgumentException( + "writer route profile keys must exactly equal canonical route keys"); + } + if (new HashSet<>(routeProfiles.stream().map(RouteProfile::route).toList()).size() + != routeProfiles.size()) { + throw new IllegalArgumentException("writer route profiles contain duplicate routes"); + } + if (!routeProfiles.stream() + .map(RouteProfile::route) + .toList() + .equals(canonicalRoutes.routes())) { + throw new IllegalArgumentException( + "writer route profile keys must exactly equal canonical route keys"); + } + } + + public RouteProfile requireRoute(NotificationCanonicalWriterRouteSet.RouteRevision route) { + return routeProfiles.stream() + .filter(candidate -> candidate.route().equals(route)) + .findFirst() + .orElseThrow( + () -> new IllegalArgumentException("route is outside trusted writer route set")); + } + + public String digest() { + MessageDigest digest = sha256(); + NotificationCanonicalWriterRouteSet.update(digest, canonicalRoutes.digest()); + routeProfiles.forEach( + route -> { + NotificationCanonicalWriterRouteSet.update(digest, route.route().routeId().value()); + digest.update( + ByteBuffer.allocate(Integer.BYTES).putInt(route.route().routeRevision()).array()); + NotificationCanonicalWriterRouteSet.update(digest, route.legacyAlias().orElse("")); + route + .transportProfiles() + .forEach( + profile -> { + NotificationCanonicalWriterRouteSet.update(digest, profile.profileId()); + NotificationCanonicalWriterRouteSet.update(digest, profile.proofClass().name()); + NotificationCanonicalWriterRouteSet.update(digest, profile.evidenceRevision()); + digest.update((byte) (profile.activeAdmissionProfile() ? 1 : 0)); + }); + }); + return java.util.HexFormat.of().formatHex(digest.digest()); + } + + private static MessageDigest sha256() { + try { + return MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException unavailable) { + throw new IllegalStateException( + "SHA-256 must be available on every Java runtime", unavailable); + } + } + + public record RouteProfile( + NotificationCanonicalWriterRouteSet.RouteRevision route, + Optional legacyAlias, + List transportProfiles) { + + public RouteProfile { + Objects.requireNonNull(route, "notification writer route must be non-null"); + Objects.requireNonNull(legacyAlias, "legacy alias container must be non-null"); + legacyAlias = + legacyAlias.map(alias -> NotificationIntentId.requireSlug("legacy route alias", alias)); + Objects.requireNonNull( + transportProfiles, "legacy transport profile registry must be non-null"); + transportProfiles = + transportProfiles.stream() + .map( + profile -> + Objects.requireNonNull(profile, "legacy transport profile must be non-null")) + .sorted(Comparator.comparing(TransportProfile::profileId)) + .toList(); + if (transportProfiles.isEmpty() || transportProfiles.size() > 8) { + throw new IllegalArgumentException( + "legacy transport profile registry must contain 1..8 profiles"); + } + if (new HashSet<>(transportProfiles.stream().map(TransportProfile::profileId).toList()).size() + != transportProfiles.size()) { + throw new IllegalArgumentException("legacy transport profile registry contains duplicates"); + } + long activeCount = + transportProfiles.stream().filter(TransportProfile::activeAdmissionProfile).count(); + if (activeCount != 1) { + throw new IllegalArgumentException( + "legacy transport registry requires exactly one active admission profile"); + } + } + + public ProofClass proofRequirement() { + return transportProfiles.stream() + .allMatch(profile -> profile.proofClass() == ProofClass.HARD_BOUND_PROVEN) + ? ProofClass.HARD_BOUND_PROVEN + : ProofClass.QUIESCENCE_REQUIRED; + } + + public TransportProfile requireProfile(String profileId) { + return transportProfiles.stream() + .filter(profile -> profile.profileId().equals(profileId)) + .findFirst() + .orElseThrow( + () -> + new IllegalArgumentException( + "transport profile is outside trusted writer registry")); + } + } + + public record TransportProfile( + String profileId, + ProofClass proofClass, + String evidenceRevision, + boolean activeAdmissionProfile) { + + public TransportProfile { + profileId = NotificationIntentId.requireSlug("legacy transport profile ID", profileId); + Objects.requireNonNull(proofClass, "legacy transport proof class must be non-null"); + evidenceRevision = + NotificationIntentId.requireSlug("legacy transport evidence revision", evidenceRevision); + } + } + + public enum ProofClass { + HARD_BOUND_PROVEN, + QUIESCENCE_REQUIRED + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/ProviderAttemptOutcome.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/ProviderAttemptOutcome.java new file mode 100644 index 00000000..7dac31fe --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/ProviderAttemptOutcome.java @@ -0,0 +1,92 @@ +package dev.caskeleton.application.notification; + +import java.time.Instant; +import java.util.Objects; +import java.util.Optional; + +/** + * Provider-neutral attempt fact with submission, coordinator action and failure scope kept as + * separate axes. + */ +public record ProviderAttemptOutcome( + SubmissionCertainty submissionCertainty, + RetryDisposition retryDisposition, + NotificationFaultScope faultScope, + NotificationReasonCode reasonCode, + Optional retryNotBefore, + String attemptCorrelationReference, + Optional providerMessageReference) { + + public ProviderAttemptOutcome { + Objects.requireNonNull(submissionCertainty, "submission certainty must be non-null"); + Objects.requireNonNull(retryDisposition, "retry disposition must be non-null"); + Objects.requireNonNull(faultScope, "fault scope must be non-null"); + Objects.requireNonNull(reasonCode, "notification reason code must be non-null"); + Objects.requireNonNull(retryNotBefore, "retry-not-before container must be non-null"); + attemptCorrelationReference = + NotificationIntentId.requireOpaque( + "attempt correlation reference", attemptCorrelationReference); + Objects.requireNonNull( + providerMessageReference, "provider message reference container must be non-null"); + providerMessageReference = + providerMessageReference.map( + value -> NotificationIntentId.requireOpaque("provider message reference", value)); + validateAxes( + submissionCertainty, + retryDisposition, + faultScope, + retryNotBefore, + providerMessageReference); + } + + private static void validateAxes( + SubmissionCertainty certainty, + RetryDisposition disposition, + NotificationFaultScope scope, + Optional retryAt, + Optional providerReference) { + if ((disposition == RetryDisposition.RETRY_AT) != retryAt.isPresent()) { + throw new IllegalArgumentException( + "retryNotBefore must be present exactly when retry disposition is RETRY_AT"); + } + if (certainty == SubmissionCertainty.PROVIDER_ACCEPTED + && (disposition != RetryDisposition.NOT_APPLICABLE + || scope != NotificationFaultScope.DELIVERY)) { + throw new IllegalArgumentException("PROVIDER_ACCEPTED requires NOT_APPLICABLE and DELIVERY"); + } + if (certainty == SubmissionCertainty.INDETERMINATE + && (disposition != RetryDisposition.NOT_APPLICABLE + || scope != NotificationFaultScope.DELIVERY + || providerReference.isPresent())) { + throw new IllegalArgumentException( + "INDETERMINATE requires NOT_APPLICABLE, DELIVERY and no provider message reference"); + } + if (certainty == SubmissionCertainty.DEFINITELY_NOT_APPLIED + && disposition == RetryDisposition.NOT_APPLICABLE) { + throw new IllegalArgumentException( + "DEFINITELY_NOT_APPLIED requires an explicit retry, park or terminal disposition"); + } + if (certainty != SubmissionCertainty.PROVIDER_ACCEPTED && providerReference.isPresent()) { + throw new IllegalArgumentException( + "provider message reference is only valid for a provider-accepted outcome"); + } + if (disposition == RetryDisposition.PARK_BINDING && scope == NotificationFaultScope.DELIVERY) { + throw new IllegalArgumentException("PARK_BINDING requires a shared non-delivery fault scope"); + } + } + + @Override + public String toString() { + return "ProviderAttemptOutcome[submissionCertainty=" + + submissionCertainty + + ", retryDisposition=" + + retryDisposition + + ", faultScope=" + + faultScope + + ", reasonCode=" + + reasonCode + + ", retryNotBefore=" + + retryNotBefore + + ", attemptCorrelationReference=, providerMessageReference=]"; + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/ReconcileNotificationDeliveriesCommand.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/ReconcileNotificationDeliveriesCommand.java new file mode 100644 index 00000000..d1a64b4a --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/ReconcileNotificationDeliveriesCommand.java @@ -0,0 +1,19 @@ +package dev.caskeleton.application.notification; + +import dev.caskeleton.application.command.Command; + +/** Requests bounded provider reconciliation and/or local orphan-receipt attachment. */ +public record ReconcileNotificationDeliveriesCommand( + int maximumClaims, int maximumOrphanAttachments) implements Command { + + public ReconcileNotificationDeliveriesCommand { + if (maximumClaims < 0 + || maximumClaims > 100 + || maximumOrphanAttachments < 0 + || maximumOrphanAttachments > 100 + || maximumClaims + maximumOrphanAttachments == 0) { + throw new IllegalArgumentException( + "reconciliation bounds must each be in 0..100 and at least one must be positive"); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/ReconcileNotificationDeliveriesResult.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/ReconcileNotificationDeliveriesResult.java new file mode 100644 index 00000000..70601ec0 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/ReconcileNotificationDeliveriesResult.java @@ -0,0 +1,18 @@ +package dev.caskeleton.application.notification; + +/** Bounded non-sensitive aggregate outcome of one reconciliation cycle. */ +public record ReconcileNotificationDeliveriesResult( + int claimedCount, int providerCallCount, int finalizedCount, int orphanAttachedCount) { + + public ReconcileNotificationDeliveriesResult { + int[] counts = {claimedCount, providerCallCount, finalizedCount, orphanAttachedCount}; + for (int count : counts) { + if (count < 0 || count > 100) { + throw new IllegalArgumentException("reconciliation counts must be in 0..100"); + } + } + if (providerCallCount > claimedCount || finalizedCount > providerCallCount) { + throw new IllegalArgumentException("reconciliation counts are inconsistent"); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/ReconcileNotificationDeliveriesUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/ReconcileNotificationDeliveriesUseCase.java new file mode 100644 index 00000000..554aa927 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/ReconcileNotificationDeliveriesUseCase.java @@ -0,0 +1,84 @@ +package dev.caskeleton.application.notification; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.security.RequiresPermission; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.CommandUseCase; +import java.time.Clock; +import java.util.List; +import java.util.Objects; + +/** + * Coordinates local orphan attach and provider reconciliation with short transaction boundaries. + */ +@RequiresPermission("notification:reconcile") +@UseCaseCapability( + transactionMode = TransactionMode.WRITE, + idempotency = Idempotency.IDEMPOTENT, + repositoryAccess = RepositoryAccess.WRITE_REPOSITORY, + externalOutboundAllowed = true, + sensitiveRead = true, + crossTenantAdmin = true) +public final class ReconcileNotificationDeliveriesUseCase + implements CommandUseCase< + ReconcileNotificationDeliveriesCommand, ReconcileNotificationDeliveriesResult> { + + private final NotificationDeliveryStorePort store; + private final NotificationReconciliationPort provider; + private final TransactionPort transactions; + private final Clock clock; + + public ReconcileNotificationDeliveriesUseCase( + NotificationDeliveryStorePort store, + NotificationReconciliationPort provider, + TransactionPort transactions, + Clock clock) { + this.store = Objects.requireNonNull(store, "notification delivery store must be non-null"); + this.provider = + Objects.requireNonNull(provider, "notification reconciliation port must be non-null"); + this.transactions = Objects.requireNonNull(transactions, "transaction port must be non-null"); + this.clock = Objects.requireNonNull(clock, "clock must be non-null"); + } + + @Override + public ReconcileNotificationDeliveriesResult handle( + ReconcileNotificationDeliveriesCommand command) { + Objects.requireNonNull(command, "reconciliation command must be non-null"); + int orphanAttached = + command.maximumOrphanAttachments() == 0 + ? 0 + : transactions.inWrite( + () -> + store.attachOrphanReceipts( + command.maximumOrphanAttachments(), clock.instant())); + List claims = + command.maximumClaims() == 0 + ? List.of() + : List.copyOf( + transactions.inWrite( + () -> store.claimForReconciliation(command.maximumClaims(), clock.instant()))); + if (claims.size() > command.maximumClaims()) { + throw new IllegalStateException("notification store returned too many reconciliation claims"); + } + + int finalized = 0; + for (NotificationDeliveryStorePort.ReconciliationClaim claim : claims) { + NotificationReconciliationPort.ReconciliationOutcome outcome = + Objects.requireNonNull( + provider.reconcile(claim), "reconciliation outcome must be non-null"); + NotificationDeliveryStorePort.ReconciliationFinalizationResult finalization = + transactions.inWrite(() -> store.finalizeReconciliation(claim, outcome, clock.instant())); + if (finalization == NotificationDeliveryStorePort.ReconciliationFinalizationResult.APPLIED + || finalization + == NotificationDeliveryStorePort.ReconciliationFinalizationResult + .LATE_EXACT_APPLIED) { + finalized++; + } + } + return new ReconcileNotificationDeliveriesResult( + claims.size(), claims.size(), finalized, orphanAttached); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/RecordNotificationWriterQuiescenceAttestationCommand.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/RecordNotificationWriterQuiescenceAttestationCommand.java new file mode 100644 index 00000000..6163ac47 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/RecordNotificationWriterQuiescenceAttestationCommand.java @@ -0,0 +1,23 @@ +package dev.caskeleton.application.notification; + +import dev.caskeleton.application.command.Command; +import java.util.Objects; + +/** Authenticated PRE request to verify and retain one immutable signed quiescence manifest. */ +public record RecordNotificationWriterQuiescenceAttestationCommand( + String operationToken, + SignedNotificationWriterQuiescenceManifest manifest, + String actorReference, + NotificationReasonCode reasonCode) + implements Command { + + public RecordNotificationWriterQuiescenceAttestationCommand { + operationToken = + NotificationIntentId.requireOpaque( + "quiescence attestation operation token", operationToken); + Objects.requireNonNull(manifest, "signed quiescence manifest must be non-null"); + actorReference = + NotificationIntentId.requireOpaque("quiescence attestation actor", actorReference); + Objects.requireNonNull(reasonCode, "notification reason code must be non-null"); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/RecordNotificationWriterQuiescenceAttestationOperation.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/RecordNotificationWriterQuiescenceAttestationOperation.java new file mode 100644 index 00000000..1655b68d --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/RecordNotificationWriterQuiescenceAttestationOperation.java @@ -0,0 +1,14 @@ +package dev.caskeleton.application.notification; + +import java.time.Instant; + +/** Atomically re-derives current blocking sets and retains verified signed quiescence evidence. */ +@FunctionalInterface +public interface RecordNotificationWriterQuiescenceAttestationOperation { + + RecordNotificationWriterQuiescenceAttestationResult record( + RecordNotificationWriterQuiescenceAttestationCommand command, + NotificationWriterQuiescenceAttestationPort.VerifiedQuiescenceEvidence verifiedEvidence, + NotificationWriterRouteSet.RouteProfile trustedRoute, + Instant requestedAt); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/RecordNotificationWriterQuiescenceAttestationResult.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/RecordNotificationWriterQuiescenceAttestationResult.java new file mode 100644 index 00000000..d1284fd6 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/RecordNotificationWriterQuiescenceAttestationResult.java @@ -0,0 +1,29 @@ +package dev.caskeleton.application.notification; + +import java.util.Objects; + +/** Committed retained quiescence evidence identity. */ +public record RecordNotificationWriterQuiescenceAttestationResult( + Status status, + String operationToken, + NotificationCanonicalWriterRouteSet.RouteRevision route, + long generation, + String childSetDigest) { + + public RecordNotificationWriterQuiescenceAttestationResult { + Objects.requireNonNull(status, "quiescence attestation status must be non-null"); + operationToken = + NotificationIntentId.requireOpaque( + "quiescence attestation operation token", operationToken); + Objects.requireNonNull(route, "quiescence attestation route must be non-null"); + if (generation < 0) { + throw new IllegalArgumentException("quiescence generation must be non-negative"); + } + childSetDigest = InitializeNotificationWriterFencesCommand.requireDigest(childSetDigest); + } + + public enum Status { + RECORDED, + REPLAYED + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/RecordNotificationWriterQuiescenceAttestationUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/RecordNotificationWriterQuiescenceAttestationUseCase.java new file mode 100644 index 00000000..4beed314 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/RecordNotificationWriterQuiescenceAttestationUseCase.java @@ -0,0 +1,80 @@ +package dev.caskeleton.application.notification; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.security.RequiresPermission; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.CommandUseCase; +import java.time.Clock; +import java.util.Objects; +import java.util.Set; + +/** + * Verifies signed quiescence outside a transaction, then root-commits its exact retained evidence. + */ +@RequiresPermission("notification:cutover-attest") +@UseCaseCapability( + transactionMode = TransactionMode.WRITE, + idempotency = Idempotency.IDEMPOTENT, + repositoryAccess = RepositoryAccess.WRITE_REPOSITORY, + crossTenantAdmin = true) +public final class RecordNotificationWriterQuiescenceAttestationUseCase + implements CommandUseCase< + RecordNotificationWriterQuiescenceAttestationCommand, + RecordNotificationWriterQuiescenceAttestationResult> { + + private final NotificationWriterRouteSet routes; + private final NotificationWriterQuiescenceAttestationPort verifier; + private final RecordNotificationWriterQuiescenceAttestationOperation operation; + private final TransactionPort transactions; + private final Clock clock; + + public RecordNotificationWriterQuiescenceAttestationUseCase( + NotificationWriterRouteSet routes, + NotificationWriterQuiescenceAttestationPort verifier, + RecordNotificationWriterQuiescenceAttestationOperation operation, + TransactionPort transactions, + Clock clock) { + this.routes = Objects.requireNonNull(routes, "notification writer route set must be non-null"); + this.verifier = + Objects.requireNonNull(verifier, "quiescence evidence verifier must be non-null"); + this.operation = + Objects.requireNonNull(operation, "quiescence attestation operation must be non-null"); + this.transactions = Objects.requireNonNull(transactions, "transaction port must be non-null"); + this.clock = Objects.requireNonNull(clock, "clock must be non-null"); + } + + @Override + public RecordNotificationWriterQuiescenceAttestationResult handle( + RecordNotificationWriterQuiescenceAttestationCommand command) { + Objects.requireNonNull(command, "quiescence attestation command must be non-null"); + SignedNotificationWriterQuiescenceManifest manifest = command.manifest(); + NotificationWriterRouteSet.RouteProfile route = routes.requireRoute(manifest.route()); + if (route.proofRequirement() != NotificationWriterRouteSet.ProofClass.QUIESCENCE_REQUIRED) { + throw new IllegalArgumentException( + "all-hard-bound writer route forbids quiescence attestation evidence"); + } + Set trustedProfileIds = + route.transportProfiles().stream() + .map(NotificationWriterRouteSet.TransportProfile::profileId) + .collect(java.util.stream.Collectors.toUnmodifiableSet()); + if (!manifest.transportProfileIds().equals(trustedProfileIds)) { + throw new IllegalArgumentException( + "signed quiescence transport profiles must exactly match trusted writer registry"); + } + NotificationWriterQuiescenceAttestationPort.VerifiedQuiescenceEvidence evidence = + Objects.requireNonNull( + verifier.verify( + manifest, manifest.route(), manifest.drainingGeneration(), route, clock.instant()), + "verified quiescence evidence must be non-null"); + if (!evidence.route().equals(manifest.route()) + || evidence.generation() != manifest.drainingGeneration()) { + throw new IllegalArgumentException( + "verified quiescence evidence does not match command route/generation"); + } + return transactions.inRootWrite( + () -> operation.record(command, evidence, route, clock.instant())); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/RetryDisposition.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/RetryDisposition.java new file mode 100644 index 00000000..baf8106f --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/RetryDisposition.java @@ -0,0 +1,9 @@ +package dev.caskeleton.application.notification; + +/** Coordinator action after one provider attempt. */ +public enum RetryDisposition { + RETRY_AT, + PARK_BINDING, + TERMINAL, + NOT_APPLICABLE +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/SignedNotificationWriterInventoryManifest.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/SignedNotificationWriterInventoryManifest.java new file mode 100644 index 00000000..5d6c4b2f --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/SignedNotificationWriterInventoryManifest.java @@ -0,0 +1,46 @@ +package dev.caskeleton.application.notification; + +import java.util.List; +import java.util.Objects; +import java.util.Set; + +/** Signed complete old-writer node inventory presented to BEGIN_DRAIN. */ +public record SignedNotificationWriterInventoryManifest( + NotificationSignedEvidenceHeader header, + NotificationCanonicalWriterRouteSet.RouteRevision route, + long generation, + List nodes) { + + public SignedNotificationWriterInventoryManifest { + Objects.requireNonNull(header, "signed inventory header must be non-null"); + Objects.requireNonNull(route, "signed inventory route must be non-null"); + if (generation < 0) { + throw new IllegalArgumentException("signed inventory generation must be non-negative"); + } + nodes = List.copyOf(Objects.requireNonNull(nodes, "signed inventory nodes must be non-null")); + if (nodes.size() > 100 || nodes.size() > header.childCount()) { + throw new IllegalArgumentException( + "signed inventory node count exceeds bounded signed child count"); + } + if (nodes.stream() + .map(NodeInventory::nodeId) + .collect(java.util.stream.Collectors.toSet()) + .size() + != nodes.size()) { + throw new IllegalArgumentException("signed inventory contains duplicate node IDs"); + } + } + + public Set nodeIds() { + return java.util.Collections.unmodifiableSet( + new java.util.TreeSet<>(nodes.stream().map(NodeInventory::nodeId).toList())); + } + + public record NodeInventory(String nodeId, String artifactId) { + + public NodeInventory { + nodeId = NotificationIntentId.requireOpaque("writer inventory node ID", nodeId); + artifactId = NotificationIntentId.requireOpaque("writer inventory artifact ID", artifactId); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/SignedNotificationWriterQuiescenceManifest.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/SignedNotificationWriterQuiescenceManifest.java new file mode 100644 index 00000000..e9764b81 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/SignedNotificationWriterQuiescenceManifest.java @@ -0,0 +1,74 @@ +package dev.caskeleton.application.notification; + +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.TreeSet; + +/** Signed exact irreversible quiescence evidence for a QUIESCENCE_REQUIRED route generation. */ +public record SignedNotificationWriterQuiescenceManifest( + NotificationSignedEvidenceHeader header, + NotificationCanonicalWriterRouteSet.RouteRevision route, + long drainingGeneration, + Set transportProfileIds, + List nodes, + int blockingPermitCount, + String blockingPermitSetDigest, + Set permitHolderIds, + int productionConsumerCount, + int providerCallOpenCount) { + + public SignedNotificationWriterQuiescenceManifest { + Objects.requireNonNull(header, "signed quiescence header must be non-null"); + Objects.requireNonNull(route, "signed quiescence route must be non-null"); + if (drainingGeneration < 0) { + throw new IllegalArgumentException("draining generation must be non-negative"); + } + Objects.requireNonNull( + transportProfileIds, "signed quiescence transport profiles must be non-null"); + TreeSet profiles = new TreeSet<>(); + transportProfileIds.forEach( + profile -> + profiles.add(NotificationIntentId.requireSlug("legacy transport profile ID", profile))); + if (profiles.isEmpty() || profiles.size() > 8) { + throw new IllegalArgumentException( + "signed quiescence transport profile set must contain 1..8 profiles"); + } + transportProfileIds = java.util.Collections.unmodifiableSet(profiles); + nodes = List.copyOf(Objects.requireNonNull(nodes, "signed quiescence nodes must be non-null")); + if (nodes.size() > 100 || nodes.size() > header.childCount()) { + throw new IllegalArgumentException( + "signed quiescence node count exceeds bounded signed child count"); + } + if (blockingPermitCount < 0 || blockingPermitCount > 100) { + throw new IllegalArgumentException("blocking permit count must be in 0..100"); + } + blockingPermitSetDigest = + InitializeNotificationWriterFencesCommand.requireDigest(blockingPermitSetDigest); + Objects.requireNonNull(permitHolderIds, "permit holder set must be non-null"); + TreeSet holders = new TreeSet<>(); + permitHolderIds.forEach( + holder -> holders.add(NotificationIntentId.requireOpaque("legacy permit holder", holder))); + if (holders.size() > 100) { + throw new IllegalArgumentException("permit holder set exceeds 100 entries"); + } + permitHolderIds = java.util.Collections.unmodifiableSet(holders); + if (productionConsumerCount != 0 || providerCallOpenCount != 0) { + throw new IllegalArgumentException( + "quiescence evidence requires production consumer and provider open counts of zero"); + } + } + + public record NodeQuiescence( + String nodeId, + boolean retired, + boolean quiesced, + boolean deploymentTombstoned, + boolean credentialRevoked, + boolean egressRevoked) { + + public NodeQuiescence { + nodeId = NotificationIntentId.requireOpaque("quiescence node ID", nodeId); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/SlackAudienceReference.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/SlackAudienceReference.java new file mode 100644 index 00000000..05fd186e --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/SlackAudienceReference.java @@ -0,0 +1,25 @@ +package dev.caskeleton.application.notification; + +/** Opaque Slack workspace binding and audience references; neither value is a webhook URL. */ +public record SlackAudienceReference(String workspaceBindingReference, String audienceReference) + implements NotificationRecipientReference { + + public SlackAudienceReference { + workspaceBindingReference = + NotificationIntentId.requireOpaque( + "Slack workspace binding reference", workspaceBindingReference); + audienceReference = + NotificationIntentId.requireOpaque("Slack audience reference", audienceReference); + } + + @Override + public NotificationChannel channel() { + return NotificationChannel.SLACK; + } + + @Override + public String toString() { + return "SlackAudienceReference[workspaceBindingReference=, " + + "audienceReference=]"; + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/SubmissionCertainty.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/SubmissionCertainty.java new file mode 100644 index 00000000..f00c4289 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/SubmissionCertainty.java @@ -0,0 +1,8 @@ +package dev.caskeleton.application.notification; + +/** Whether one provider call could have produced an external side effect. */ +public enum SubmissionCertainty { + DEFINITELY_NOT_APPLIED, + PROVIDER_ACCEPTED, + INDETERMINATE +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/SwitchNotificationWriterOwnershipCommand.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/SwitchNotificationWriterOwnershipCommand.java new file mode 100644 index 00000000..bd85e296 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/SwitchNotificationWriterOwnershipCommand.java @@ -0,0 +1,130 @@ +package dev.caskeleton.application.notification; + +import dev.caskeleton.application.command.Command; +import java.util.Objects; +import java.util.Optional; + +/** + * Audited closed writer transition request. No target owner field exists; action determines the + * only legal result owner. + */ +public record SwitchNotificationWriterOwnershipCommand( + Action action, + NotificationCanonicalWriterRouteSet.RouteRevision route, + long expectedGeneration, + long reviewedTargetGeneration, + String operationToken, + String actorReference, + NotificationReasonCode reasonCode, + Optional inventoryManifest, + Optional quiescenceAttestationToken) + implements Command { + + public SwitchNotificationWriterOwnershipCommand { + Objects.requireNonNull(action, "writer ownership action must be non-null"); + Objects.requireNonNull(route, "notification writer route must be non-null"); + if (expectedGeneration < 0 || reviewedTargetGeneration < 0) { + throw new IllegalArgumentException("writer generations must be non-negative"); + } + operationToken = + NotificationIntentId.requireOpaque("writer switch operation token", operationToken); + actorReference = NotificationIntentId.requireOpaque("writer switch actor", actorReference); + Objects.requireNonNull(reasonCode, "notification reason code must be non-null"); + Objects.requireNonNull(inventoryManifest, "inventory manifest container must be non-null"); + Objects.requireNonNull( + quiescenceAttestationToken, "quiescence attestation token container must be non-null"); + quiescenceAttestationToken = + quiescenceAttestationToken.map( + token -> NotificationIntentId.requireOpaque("quiescence attestation token", token)); + switch (action) { + case BEGIN_DRAIN -> { + if (reviewedTargetGeneration != expectedGeneration + || inventoryManifest.isEmpty() + || quiescenceAttestationToken.isPresent()) { + throw new IllegalArgumentException( + "BEGIN_DRAIN keeps generation, requires inventory and forbids attestation token"); + } + } + case COMPLETE_SWITCH -> { + if (reviewedTargetGeneration != Math.addExact(expectedGeneration, 1) + || inventoryManifest.isPresent()) { + throw new IllegalArgumentException( + "COMPLETE_SWITCH requires exactly generation+1 and no caller inventory"); + } + } + case ABORT_DRAIN -> { + if (reviewedTargetGeneration != Math.addExact(expectedGeneration, 1) + || inventoryManifest.isPresent() + || quiescenceAttestationToken.isPresent()) { + throw new IllegalArgumentException( + "ABORT_DRAIN requires exactly generation+1 and no evidence inputs"); + } + } + default -> throw new IllegalArgumentException("unsupported writer ownership action"); + } + } + + public static SwitchNotificationWriterOwnershipCommand beginDrain( + NotificationCanonicalWriterRouteSet.RouteRevision route, + long expectedGeneration, + String operationToken, + String actorReference, + NotificationReasonCode reasonCode, + SignedNotificationWriterInventoryManifest inventoryManifest) { + return new SwitchNotificationWriterOwnershipCommand( + Action.BEGIN_DRAIN, + route, + expectedGeneration, + expectedGeneration, + operationToken, + actorReference, + reasonCode, + Optional.of(inventoryManifest), + Optional.empty()); + } + + public static SwitchNotificationWriterOwnershipCommand completeSwitch( + NotificationCanonicalWriterRouteSet.RouteRevision route, + long expectedGeneration, + long reviewedTargetGeneration, + String operationToken, + String actorReference, + NotificationReasonCode reasonCode, + Optional quiescenceAttestationToken) { + return new SwitchNotificationWriterOwnershipCommand( + Action.COMPLETE_SWITCH, + route, + expectedGeneration, + reviewedTargetGeneration, + operationToken, + actorReference, + reasonCode, + Optional.empty(), + quiescenceAttestationToken); + } + + public static SwitchNotificationWriterOwnershipCommand abortDrain( + NotificationCanonicalWriterRouteSet.RouteRevision route, + long expectedGeneration, + long reviewedTargetGeneration, + String operationToken, + String actorReference, + NotificationReasonCode reasonCode) { + return new SwitchNotificationWriterOwnershipCommand( + Action.ABORT_DRAIN, + route, + expectedGeneration, + reviewedTargetGeneration, + operationToken, + actorReference, + reasonCode, + Optional.empty(), + Optional.empty()); + } + + public enum Action { + BEGIN_DRAIN, + COMPLETE_SWITCH, + ABORT_DRAIN + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/SwitchNotificationWriterOwnershipOperation.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/SwitchNotificationWriterOwnershipOperation.java new file mode 100644 index 00000000..fede5816 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/SwitchNotificationWriterOwnershipOperation.java @@ -0,0 +1,18 @@ +package dev.caskeleton.application.notification; + +import java.time.Instant; +import java.util.Optional; + +/** + * Atomic persistence operation that locks/re-verifies retained evidence and applies only the closed + * LEGACY transition matrix. + */ +@FunctionalInterface +public interface SwitchNotificationWriterOwnershipOperation { + + SwitchNotificationWriterOwnershipResult switchOwnership( + SwitchNotificationWriterOwnershipCommand command, + Optional verifiedInventory, + NotificationWriterRouteSet.RouteProfile trustedRoute, + Instant requestedAt); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/SwitchNotificationWriterOwnershipResult.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/SwitchNotificationWriterOwnershipResult.java new file mode 100644 index 00000000..18ac693d --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/SwitchNotificationWriterOwnershipResult.java @@ -0,0 +1,70 @@ +package dev.caskeleton.application.notification; + +import java.util.Objects; + +/** Committed result of one closed writer-fence transition. */ +public record SwitchNotificationWriterOwnershipResult( + Status status, + SwitchNotificationWriterOwnershipCommand.Action action, + NotificationCanonicalWriterRouteSet.RouteRevision route, + FenceState state, + NotificationWriterOwnership owner, + long generation, + String operationToken) { + + public SwitchNotificationWriterOwnershipResult { + Objects.requireNonNull(status, "writer switch status must be non-null"); + Objects.requireNonNull(action, "writer switch action must be non-null"); + Objects.requireNonNull(route, "notification writer route must be non-null"); + Objects.requireNonNull(state, "writer fence state must be non-null"); + Objects.requireNonNull(owner, "notification writer owner must be non-null"); + if (generation < 0) { + throw new IllegalArgumentException("writer generation must be non-negative"); + } + operationToken = + NotificationIntentId.requireOpaque("writer switch operation token", operationToken); + validateMatrix(action, state, owner); + } + + public static SwitchNotificationWriterOwnershipResult applied( + SwitchNotificationWriterOwnershipCommand.Action action, + NotificationCanonicalWriterRouteSet.RouteRevision route, + NotificationWriterOwnership owner, + long generation, + String operationToken) { + FenceState state = + action == SwitchNotificationWriterOwnershipCommand.Action.BEGIN_DRAIN + ? FenceState.DRAINING + : FenceState.ACTIVE; + return new SwitchNotificationWriterOwnershipResult( + Status.APPLIED, action, route, state, owner, generation, operationToken); + } + + private static void validateMatrix( + SwitchNotificationWriterOwnershipCommand.Action action, + FenceState state, + NotificationWriterOwnership owner) { + boolean valid = + switch (action) { + case BEGIN_DRAIN -> + state == FenceState.DRAINING && owner == NotificationWriterOwnership.LEGACY; + case COMPLETE_SWITCH -> + state == FenceState.ACTIVE && owner == NotificationWriterOwnership.CANONICAL; + case ABORT_DRAIN -> + state == FenceState.ACTIVE && owner == NotificationWriterOwnership.LEGACY; + }; + if (!valid) { + throw new IllegalArgumentException("writer switch result violates closed transition matrix"); + } + } + + public enum Status { + APPLIED, + REPLAYED + } + + public enum FenceState { + ACTIVE, + DRAINING + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/SwitchNotificationWriterOwnershipUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/SwitchNotificationWriterOwnershipUseCase.java new file mode 100644 index 00000000..2e2aacca --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/SwitchNotificationWriterOwnershipUseCase.java @@ -0,0 +1,100 @@ +package dev.caskeleton.application.notification; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.security.RequiresPermission; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.CommandUseCase; +import java.time.Clock; +import java.util.Objects; +import java.util.Optional; + +/** + * Validates signed evidence requirements and root-commits one closed writer ownership transition. + */ +@RequiresPermission("notification:cutover") +@UseCaseCapability( + transactionMode = TransactionMode.WRITE, + idempotency = Idempotency.IDEMPOTENT, + repositoryAccess = RepositoryAccess.WRITE_REPOSITORY, + crossTenantAdmin = true) +public final class SwitchNotificationWriterOwnershipUseCase + implements CommandUseCase< + SwitchNotificationWriterOwnershipCommand, SwitchNotificationWriterOwnershipResult> { + + private final NotificationWriterRouteSet routes; + private final NotificationWriterInventoryEvidenceVerifierPort inventoryVerifier; + private final SwitchNotificationWriterOwnershipOperation operation; + private final TransactionPort transactions; + private final Clock clock; + + public SwitchNotificationWriterOwnershipUseCase( + NotificationWriterRouteSet routes, + NotificationWriterInventoryEvidenceVerifierPort inventoryVerifier, + SwitchNotificationWriterOwnershipOperation operation, + TransactionPort transactions, + Clock clock) { + this.routes = Objects.requireNonNull(routes, "notification writer route set must be non-null"); + this.inventoryVerifier = + Objects.requireNonNull(inventoryVerifier, "writer inventory verifier must be non-null"); + this.operation = + Objects.requireNonNull(operation, "writer ownership switch operation must be non-null"); + this.transactions = Objects.requireNonNull(transactions, "transaction port must be non-null"); + this.clock = Objects.requireNonNull(clock, "clock must be non-null"); + } + + @Override + public SwitchNotificationWriterOwnershipResult handle( + SwitchNotificationWriterOwnershipCommand command) { + Objects.requireNonNull(command, "writer ownership switch command must be non-null"); + NotificationWriterRouteSet.RouteProfile route = routes.requireRoute(command.route()); + validateEvidenceRequirement(command, route); + Optional inventory = verifyBeginInventory(command); + return transactions.inRootWrite( + () -> operation.switchOwnership(command, inventory, route, clock.instant())); + } + + private Optional verifyBeginInventory( + SwitchNotificationWriterOwnershipCommand command) { + if (command.action() != SwitchNotificationWriterOwnershipCommand.Action.BEGIN_DRAIN) { + return Optional.empty(); + } + SignedNotificationWriterInventoryManifest manifest = command.inventoryManifest().orElseThrow(); + if (!manifest.route().equals(command.route()) + || manifest.generation() != command.expectedGeneration()) { + throw new IllegalArgumentException( + "signed writer inventory must match BEGIN route and generation"); + } + NotificationWriterInventoryEvidence evidence = + Objects.requireNonNull( + inventoryVerifier.verify( + manifest, command.route(), command.expectedGeneration(), clock.instant()), + "verified writer inventory evidence must be non-null"); + if (!evidence.route().equals(command.route()) + || evidence.generation() != command.expectedGeneration()) { + throw new IllegalArgumentException( + "verified writer inventory does not match BEGIN route and generation"); + } + return Optional.of(evidence); + } + + private static void validateEvidenceRequirement( + SwitchNotificationWriterOwnershipCommand command, + NotificationWriterRouteSet.RouteProfile route) { + if (command.action() != SwitchNotificationWriterOwnershipCommand.Action.COMPLETE_SWITCH) { + return; + } + if (route.proofRequirement() == NotificationWriterRouteSet.ProofClass.QUIESCENCE_REQUIRED + && command.quiescenceAttestationToken().isEmpty()) { + throw new IllegalArgumentException( + "QUIESCENCE_REQUIRED route requires a committed attestation token"); + } + if (route.proofRequirement() == NotificationWriterRouteSet.ProofClass.HARD_BOUND_PROVEN + && command.quiescenceAttestationToken().isPresent()) { + throw new IllegalArgumentException( + "all-hard-bound route forbids quiescence attestation evidence"); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/TargetAttemptOutcome.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/TargetAttemptOutcome.java new file mode 100644 index 00000000..a7910d7e --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/TargetAttemptOutcome.java @@ -0,0 +1,25 @@ +package dev.caskeleton.application.notification; + +import java.util.Objects; + +/** Bounded target-ordinal outcome for one provider leg. */ +public record TargetAttemptOutcome( + int targetOrdinal, NotificationDeliveryId deliveryId, ProviderAttemptOutcome providerOutcome) { + + public TargetAttemptOutcome { + if (targetOrdinal < 0 || targetOrdinal >= 16) { + throw new IllegalArgumentException("target ordinal must be in 0..15"); + } + Objects.requireNonNull(deliveryId, "notification delivery ID must be non-null"); + Objects.requireNonNull(providerOutcome, "provider attempt outcome must be non-null"); + } + + @Override + public String toString() { + return "TargetAttemptOutcome[targetOrdinal=" + + targetOrdinal + + ", deliveryId=, providerOutcome=" + + providerOutcome + + "]"; + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/TerminalizeExpiredNotificationWriterPermitsCommand.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/TerminalizeExpiredNotificationWriterPermitsCommand.java new file mode 100644 index 00000000..03479d09 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/TerminalizeExpiredNotificationWriterPermitsCommand.java @@ -0,0 +1,31 @@ +package dev.caskeleton.application.notification; + +import dev.caskeleton.application.command.Command; +import java.util.Objects; + +/** PRE-only bounded DB-time terminalization request for expired ACTIVE legacy permits. */ +public record TerminalizeExpiredNotificationWriterPermitsCommand( + NotificationCanonicalWriterRouteSet.RouteRevision route, + long drainingGeneration, + int maximumPermits, + String operationToken, + String actorReference, + NotificationReasonCode reasonCode) + implements Command { + + public TerminalizeExpiredNotificationWriterPermitsCommand { + Objects.requireNonNull(route, "notification writer route must be non-null"); + if (drainingGeneration < 0) { + throw new IllegalArgumentException("draining generation must be non-negative"); + } + if (maximumPermits < 1 || maximumPermits > 100) { + throw new IllegalArgumentException("terminalization permit bound must be in 1..100"); + } + operationToken = + NotificationIntentId.requireOpaque( + "permit terminalization operation token", operationToken); + actorReference = + NotificationIntentId.requireOpaque("permit terminalization actor", actorReference); + Objects.requireNonNull(reasonCode, "notification reason code must be non-null"); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/TerminalizeExpiredNotificationWriterPermitsOperation.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/TerminalizeExpiredNotificationWriterPermitsOperation.java new file mode 100644 index 00000000..6bae7b2f --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/TerminalizeExpiredNotificationWriterPermitsOperation.java @@ -0,0 +1,13 @@ +package dev.caskeleton.application.notification; + +import java.time.Instant; + +/** Atomic operation selecting and CAS-terminalizing DB-time-expired permits in canonical order. */ +@FunctionalInterface +public interface TerminalizeExpiredNotificationWriterPermitsOperation { + + TerminalizeExpiredNotificationWriterPermitsResult terminalize( + TerminalizeExpiredNotificationWriterPermitsCommand command, + NotificationWriterRouteSet.RouteProfile trustedRoute, + Instant requestedAt); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/TerminalizeExpiredNotificationWriterPermitsResult.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/TerminalizeExpiredNotificationWriterPermitsResult.java new file mode 100644 index 00000000..394f9a1b --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/TerminalizeExpiredNotificationWriterPermitsResult.java @@ -0,0 +1,21 @@ +package dev.caskeleton.application.notification; + +import java.util.Objects; + +/** Committed terminalized permit count and canonical affected tuple-set digest. */ +public record TerminalizeExpiredNotificationWriterPermitsResult( + Status status, int affectedCount, String affectedSetDigest) { + + public TerminalizeExpiredNotificationWriterPermitsResult { + Objects.requireNonNull(status, "permit terminalization status must be non-null"); + if (affectedCount < 0 || affectedCount > 100) { + throw new IllegalArgumentException("terminalized permit count must be in 0..100"); + } + affectedSetDigest = InitializeNotificationWriterFencesCommand.requireDigest(affectedSetDigest); + } + + public enum Status { + APPLIED, + REPLAYED + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/notification/TerminalizeExpiredNotificationWriterPermitsUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/TerminalizeExpiredNotificationWriterPermitsUseCase.java new file mode 100644 index 00000000..03ae7828 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/notification/TerminalizeExpiredNotificationWriterPermitsUseCase.java @@ -0,0 +1,49 @@ +package dev.caskeleton.application.notification; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.security.RequiresPermission; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.CommandUseCase; +import java.time.Clock; +import java.util.Objects; + +/** Root-commits one bounded expired-permit terminalization operation. */ +@RequiresPermission("notification:cutover-terminalize") +@UseCaseCapability( + transactionMode = TransactionMode.WRITE, + idempotency = Idempotency.IDEMPOTENT, + repositoryAccess = RepositoryAccess.WRITE_REPOSITORY, + crossTenantAdmin = true) +public final class TerminalizeExpiredNotificationWriterPermitsUseCase + implements CommandUseCase< + TerminalizeExpiredNotificationWriterPermitsCommand, + TerminalizeExpiredNotificationWriterPermitsResult> { + + private final NotificationWriterRouteSet routes; + private final TerminalizeExpiredNotificationWriterPermitsOperation operation; + private final TransactionPort transactions; + private final Clock clock; + + public TerminalizeExpiredNotificationWriterPermitsUseCase( + NotificationWriterRouteSet routes, + TerminalizeExpiredNotificationWriterPermitsOperation operation, + TransactionPort transactions, + Clock clock) { + this.routes = Objects.requireNonNull(routes, "notification writer route set must be non-null"); + this.operation = + Objects.requireNonNull(operation, "permit terminalization operation must be non-null"); + this.transactions = Objects.requireNonNull(transactions, "transaction port must be non-null"); + this.clock = Objects.requireNonNull(clock, "clock must be non-null"); + } + + @Override + public TerminalizeExpiredNotificationWriterPermitsResult handle( + TerminalizeExpiredNotificationWriterPermitsCommand command) { + Objects.requireNonNull(command, "permit terminalization command must be non-null"); + NotificationWriterRouteSet.RouteProfile route = routes.requireRoute(command.route()); + return transactions.inRootWrite(() -> operation.terminalize(command, route, clock.instant())); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/transaction/NestedRootTransactionRejectedException.java b/src/application-core/src/main/java/dev/caskeleton/application/transaction/NestedRootTransactionRejectedException.java new file mode 100644 index 00000000..d431613b --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/transaction/NestedRootTransactionRejectedException.java @@ -0,0 +1,12 @@ +package dev.caskeleton.application.transaction; + +/** + * Raised when a root-only transaction operation is invoked while an actual transaction is already + * active on the calling thread. + */ +public final class NestedRootTransactionRejectedException extends RuntimeException { + + public NestedRootTransactionRejectedException() { + super("root write transaction requires no ambient actual transaction"); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/transaction/TransactionPort.java b/src/application-core/src/main/java/dev/caskeleton/application/transaction/TransactionPort.java index 35c79eb2..f710eefb 100644 --- a/src/application-core/src/main/java/dev/caskeleton/application/transaction/TransactionPort.java +++ b/src/application-core/src/main/java/dev/caskeleton/application/transaction/TransactionPort.java @@ -10,6 +10,8 @@ import java.util.function.Supplier; *
    *
  • {@link #inWrite(Supplier)} — REQUIRED + read-write, {@code READ_COMMITTED}. Command * default. + *
  • {@link #inRootWrite(Supplier)} — root-only REQUIRED + read-write, {@code READ_COMMITTED}; + * rejects an ambient actual transaction before invoking the action. *
  • {@link #inRead(Supplier)} — REQUIRED + read-only, {@code READ_COMMITTED}. Query default. *
  • {@link #inNew(Supplier)} — REQUIRES_NEW; outbox / audit / compensation only. *
@@ -22,6 +24,17 @@ public interface TransactionPort { T inWrite(Supplier action); + /** + * Run {@code action} in a root write transaction and return only after its physical commit. + * + *

An implementation must reject an already-active actual transaction before invoking the + * action or transaction manager. This is intentionally abstract: delegating to join-capable + * {@link #inWrite(Supplier)} would silently weaken the contract. + * + * @throws NestedRootTransactionRejectedException when an actual transaction is already active + */ + T inRootWrite(Supplier action); + T inRead(Supplier action); /** @@ -39,6 +52,14 @@ public interface TransactionPort { }); } + default void inRootWrite(Runnable action) { + inRootWrite( + () -> { + action.run(); + return null; + }); + } + default void inRead(Runnable action) { inRead( () -> { diff --git a/src/application-core/src/test/java/dev/caskeleton/application/notification/InitializeNotificationWriterFencesUseCaseTest.java b/src/application-core/src/test/java/dev/caskeleton/application/notification/InitializeNotificationWriterFencesUseCaseTest.java new file mode 100644 index 00000000..4172e1ad --- /dev/null +++ b/src/application-core/src/test/java/dev/caskeleton/application/notification/InitializeNotificationWriterFencesUseCaseTest.java @@ -0,0 +1,149 @@ +package dev.caskeleton.application.notification; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.application.transaction.TransactionPort; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Supplier; +import org.junit.jupiter.api.Test; + +class InitializeNotificationWriterFencesUseCaseTest { + + @Test + void exactReviewedRouteSetIsInitializedAtomicallyInsidePhysicalRootWrite() { + NotificationWriterRouteSet routeSet = routeSet(); + TrackingRootTransactions transactions = new TrackingRootTransactions(); + AtomicInteger operations = new AtomicInteger(); + InitializeNotificationWriterFencesOperation operation = + (command, trustedRoutes, now) -> { + assertThat(transactions.active).isTrue(); + assertThat(trustedRoutes).isSameAs(routeSet); + operations.incrementAndGet(); + return new InitializeNotificationWriterFencesResult( + InitializeNotificationWriterFencesResult.Status.INITIALIZED, + trustedRoutes.canonicalRoutes().routes().size(), + trustedRoutes.digest()); + }; + InitializeNotificationWriterFencesUseCase useCase = + new InitializeNotificationWriterFencesUseCase( + routeSet, + operation, + transactions, + Clock.fixed(Instant.parse("2026-07-28T00:00:00Z"), ZoneOffset.UTC)); + + InitializeNotificationWriterFencesResult result = + useCase.handle(command(routeSet.canonicalRoutes(), routeSet.digest())); + + assertThat(transactions.rootCalls).isEqualTo(1); + assertThat(operations).hasValue(1); + assertThat(result.status()) + .isEqualTo(InitializeNotificationWriterFencesResult.Status.INITIALIZED); + } + + @Test + void routeOrDigestDriftFailsBeforeMutationAndCommandCannotInitializePartially() { + NotificationWriterRouteSet routeSet = routeSet(); + TrackingRootTransactions transactions = new TrackingRootTransactions(); + AtomicInteger operations = new AtomicInteger(); + InitializeNotificationWriterFencesUseCase useCase = + new InitializeNotificationWriterFencesUseCase( + routeSet, + (command, trustedRoutes, now) -> { + operations.incrementAndGet(); + throw new AssertionError("operation must not run"); + }, + transactions, + Clock.systemUTC()); + NotificationCanonicalWriterRouteSet partial = + new NotificationCanonicalWriterRouteSet( + List.of(routeSet.canonicalRoutes().routes().getFirst())); + + assertThatThrownBy(() -> useCase.handle(command(partial, partial.digest()))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("exact"); + assertThatThrownBy(() -> useCase.handle(command(routeSet.canonicalRoutes(), "0".repeat(64)))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("digest"); + assertThat(transactions.rootCalls).isZero(); + assertThat(operations).hasValue(0); + } + + private static InitializeNotificationWriterFencesCommand command( + NotificationCanonicalWriterRouteSet reviewedRoutes, String digest) { + return new InitializeNotificationWriterFencesCommand( + "initialize-operation-42", + reviewedRoutes, + digest, + "operator-42", + new NotificationReasonCode("INITIALIZE_REVIEWED_ROUTES")); + } + + static NotificationWriterRouteSet routeSet() { + NotificationCanonicalWriterRouteSet.RouteRevision email = + new NotificationCanonicalWriterRouteSet.RouteRevision( + new NotificationRouteId("security-email"), 2, 7); + NotificationCanonicalWriterRouteSet.RouteRevision slack = + new NotificationCanonicalWriterRouteSet.RouteRevision( + new NotificationRouteId("security-slack"), 1, 3); + NotificationCanonicalWriterRouteSet canonical = + new NotificationCanonicalWriterRouteSet(List.of(email, slack)); + return new NotificationWriterRouteSet( + canonical, + List.of( + new NotificationWriterRouteSet.RouteProfile( + email, + java.util.Optional.of("legacy-email"), + List.of( + new NotificationWriterRouteSet.TransportProfile( + "legacy-http-v1", + NotificationWriterRouteSet.ProofClass.QUIESCENCE_REQUIRED, + "evidence-r1", + true))), + new NotificationWriterRouteSet.RouteProfile( + slack, + java.util.Optional.of("legacy-slack"), + List.of( + new NotificationWriterRouteSet.TransportProfile( + "legacy-slack-v1", + NotificationWriterRouteSet.ProofClass.HARD_BOUND_PROVEN, + "evidence-r2", + true))))); + } + + static final class TrackingRootTransactions implements TransactionPort { + + int rootCalls; + boolean active; + + @Override + public T inWrite(Supplier action) { + return action.get(); + } + + @Override + public T inRootWrite(Supplier action) { + rootCalls++; + active = true; + try { + return action.get(); + } finally { + active = false; + } + } + + @Override + public T inRead(Supplier action) { + return action.get(); + } + + @Override + public T inNew(Supplier action) { + return action.get(); + } + } +} diff --git a/src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationAdmissionGateUseCaseTest.java b/src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationAdmissionGateUseCaseTest.java new file mode 100644 index 00000000..007330a3 --- /dev/null +++ b/src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationAdmissionGateUseCaseTest.java @@ -0,0 +1,167 @@ +package dev.caskeleton.application.notification; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.application.transaction.TransactionPort; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Supplier; +import org.junit.jupiter.api.Test; + +class NotificationAdmissionGateUseCaseTest { + + @Test + void readinessProbeRunsOutsideTransactionThenResumeCasRunsInsideShortWrite() { + List trace = new ArrayList<>(); + TrackingTransactions transactions = new TrackingTransactions(trace); + NotificationAdmissionReadinessPort admission = + new NotificationAdmissionReadinessPort() { + @Override + public ParkResult park(ParkRequest request) { + return ParkResult.PARKED; + } + + @Override + public ReadinessProbe probe(ResumeRequest request) { + assertThat(transactions.active).isFalse(); + trace.add("probe"); + return new ReadinessProbe(true, new NotificationReasonCode("READINESS_CONFIRMED")); + } + + @Override + public ResumeResult resume( + ResumeRequest request, ReadinessProbe probe, Instant resumedAt) { + assertThat(transactions.active).isTrue(); + assertThat(request.operationToken()).isEqualTo("resume-operation-42"); + assertThat(request.actorReference()).isEqualTo("operator-42"); + assertThat(request.maximumParkedLegs()).isEqualTo(10); + trace.add("resume"); + return new ResumeResult( + ResumeStatus.RESUMED, request.expectedGeneration() + 1, 4, 2, 1, 1, 0, 0); + } + }; + NotificationAdmissionGateUseCase useCase = + new NotificationAdmissionGateUseCase( + admission, + transactions, + Clock.fixed(Instant.parse("2026-07-28T00:00:00Z"), ZoneOffset.UTC)); + + NotificationAdmissionGateUseCase.Result result = useCase.handle(command()); + + assertThat(trace).containsExactly("probe", "tx-begin", "resume", "tx-end"); + assertThat(result.status()).isEqualTo(NotificationAdmissionGateUseCase.Result.Status.RESUMED); + } + + @Test + void failedProbeDoesNotEnterMutationTransaction() { + List trace = new ArrayList<>(); + TrackingTransactions transactions = new TrackingTransactions(trace); + NotificationAdmissionReadinessPort admission = + new NotificationAdmissionReadinessPort() { + @Override + public ParkResult park(ParkRequest request) { + return ParkResult.PARKED; + } + + @Override + public ReadinessProbe probe(ResumeRequest request) { + trace.add("probe"); + return new ReadinessProbe(false, new NotificationReasonCode("READINESS_FAILED")); + } + + @Override + public ResumeResult resume( + ResumeRequest request, ReadinessProbe probe, Instant resumedAt) { + throw new AssertionError("resume must not run"); + } + }; + + NotificationAdmissionGateUseCase.Result result = + new NotificationAdmissionGateUseCase( + admission, + transactions, + Clock.fixed(Instant.parse("2026-07-28T00:00:00Z"), ZoneOffset.UTC)) + .handle(command()); + + assertThat(trace).containsExactly("probe"); + assertThat(result.status()).isEqualTo(NotificationAdmissionGateUseCase.Result.Status.NOT_READY); + } + + @Test + void resumeOutcomeIsBoundedAndInitialFallbackActivationIsForbidden() { + assertThatThrownBy( + () -> + new NotificationAdmissionReadinessPort.ResumeResult( + NotificationAdmissionReadinessPort.ResumeStatus.RESUMED, 8, 1, 0, 0, 0, 0, 1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("fallback"); + assertThatThrownBy( + () -> + new NotificationAdmissionGateCommand( + "resume-operation-42", + new NotificationRouteId("security-email"), + 2, + NotificationFaultScope.PROVIDER_BINDING, + "provider-binding-scope-42", + 7, + 101, + "operator-42", + new NotificationReasonCode("OPERATOR_RESUME"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("1..100"); + } + + private static NotificationAdmissionGateCommand command() { + return new NotificationAdmissionGateCommand( + "resume-operation-42", + new NotificationRouteId("security-email"), + 2, + NotificationFaultScope.PROVIDER_BINDING, + "provider-binding-scope-42", + 7, + 10, + "operator-42", + new NotificationReasonCode("OPERATOR_RESUME")); + } + + private static final class TrackingTransactions implements TransactionPort { + + private final List trace; + private boolean active; + + private TrackingTransactions(List trace) { + this.trace = trace; + } + + @Override + public T inWrite(Supplier action) { + trace.add("tx-begin"); + active = true; + try { + return action.get(); + } finally { + active = false; + trace.add("tx-end"); + } + } + + @Override + public T inRootWrite(Supplier action) { + return inWrite(action); + } + + @Override + public T inRead(Supplier action) { + return action.get(); + } + + @Override + public T inNew(Supplier action) { + return action.get(); + } + } +} diff --git a/src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationCanonicalWriterFenceGuardTest.java b/src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationCanonicalWriterFenceGuardTest.java new file mode 100644 index 00000000..a06f9096 --- /dev/null +++ b/src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationCanonicalWriterFenceGuardTest.java @@ -0,0 +1,65 @@ +package dev.caskeleton.application.notification; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; + +class NotificationCanonicalWriterFenceGuardTest { + + @Test + void exactCanonicalOwnerAndGenerationAreRequiredBeforeIntentAppend() { + NotificationCanonicalWriterRouteSet.RouteRevision route = route(); + NotificationCanonicalWriterFencePort port = + request -> + new NotificationCanonicalWriterFencePort.FenceSnapshot( + request.route(), NotificationWriterOwnership.CANONICAL, 8); + NotificationCanonicalWriterFenceGuard guard = + new NotificationCanonicalWriterFenceGuard( + port, new NotificationCanonicalWriterRouteSet(List.of(route))); + AtomicInteger appends = new AtomicInteger(); + + guard.assertCanonical(route, 8); + appends.incrementAndGet(); + + assertThat(appends).hasValue(1); + assertThatThrownBy(() -> guard.assertCanonical(route, 7)) + .isInstanceOf(NotificationApplicationException.class) + .hasMessageContaining("STALE_CANONICAL_WRITER_GENERATION"); + } + + @Test + void legacyOwnerOrUnknownRouteFailsClosedBeforeAppend() { + NotificationCanonicalWriterRouteSet.RouteRevision route = route(); + NotificationCanonicalWriterFenceGuard legacyGuard = + new NotificationCanonicalWriterFenceGuard( + request -> + new NotificationCanonicalWriterFencePort.FenceSnapshot( + request.route(), NotificationWriterOwnership.LEGACY, 7), + new NotificationCanonicalWriterRouteSet(List.of(route))); + AtomicInteger appends = new AtomicInteger(); + + assertThatThrownBy( + () -> { + legacyGuard.assertCanonical(route, 7); + appends.incrementAndGet(); + }) + .isInstanceOf(NotificationApplicationException.class) + .hasMessageContaining("CANONICAL_WRITER_NOT_OWNER"); + assertThatThrownBy( + () -> + legacyGuard.assertCanonical( + new NotificationCanonicalWriterRouteSet.RouteRevision( + new NotificationRouteId("unknown-route"), 1, 0), + 7)) + .isInstanceOf(IllegalArgumentException.class); + assertThat(appends).hasValue(0); + } + + private static NotificationCanonicalWriterRouteSet.RouteRevision route() { + return new NotificationCanonicalWriterRouteSet.RouteRevision( + new NotificationRouteId("security-email"), 2, 7); + } +} diff --git a/src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationCapabilityCompatibilityValidatorTest.java b/src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationCapabilityCompatibilityValidatorTest.java new file mode 100644 index 00000000..3d4f8adc --- /dev/null +++ b/src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationCapabilityCompatibilityValidatorTest.java @@ -0,0 +1,157 @@ +package dev.caskeleton.application.notification; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Duration; +import java.util.Optional; +import java.util.Set; +import org.junit.jupiter.api.Test; + +class NotificationCapabilityCompatibilityValidatorTest { + + @Test + void pureValidatorAcceptsExactDurableReceiptCapableApplicationContract() { + NotificationKindPolicy policy = policy(); + NotificationCapabilityCompatibilityValidator validator = + new NotificationCapabilityCompatibilityValidator(); + + NotificationCapabilityCompatibilityValidator.Compatibility result = + validator.validate(policy, provider(true), store(policy), Optional.of(ingress()), true); + + assertThat(result.compatible()).isTrue(); + assertThat(result.reasonCodes()).isEmpty(); + } + + @Test + void modeAdmissionReceiptAndFrozenRevisionMismatchesFailClosedWithoutAdapterTypes() { + NotificationKindPolicy policy = policy(); + NotificationCapabilityCompatibilityValidator validator = + new NotificationCapabilityCompatibilityValidator(); + + NotificationCapabilityCompatibilityValidator.Compatibility result = + validator.validate( + policy, + provider(false), + new NotificationStoreCapabilityDescriptor( + false, + false, + true, + 100, + Set.of(policy.policyRevision() - 1), + Set.of(new NotificationTemplateRef("security-alert", 99))), + Optional.empty(), + true); + + assertThat(result.compatible()).isFalse(); + assertThat(result.reasonCodes()) + .contains( + new NotificationReasonCode("PROVIDER_RECEIPT_UNSUPPORTED"), + new NotificationReasonCode("DURABLE_STORE_UNAVAILABLE"), + new NotificationReasonCode("POLICY_REVISION_UNAVAILABLE"), + new NotificationReasonCode("TEMPLATE_REVISION_UNAVAILABLE"), + new NotificationReasonCode("RECEIPT_INGRESS_UNAVAILABLE")); + assertThat( + java.util.Arrays.stream( + NotificationCapabilityCompatibilityValidator.class.getDeclaredMethods()) + .flatMap( + method -> + java.util.Arrays.stream(method.getGenericParameterTypes()) + .map(java.lang.reflect.Type::getTypeName))) + .noneMatch( + name -> + name.contains("adapter.") + || name.contains("Settings") + || name.contains("CompiledNotificationBinding")); + } + + @Test + void providerChannelOrCodeOwnedModeMismatchIsRejected() { + NotificationKindPolicy policy = policy(); + NotificationProviderCapabilityDescriptor wrongChannel = + new NotificationProviderCapabilityDescriptor( + "provider-capability-42", + NotificationChannel.SLACK, + Set.of(NotificationMode.DURABLE_ASYNC), + true, + true, + true, + 16, + 1_000_000); + + assertThatThrownBy( + () -> + new NotificationCapabilityCompatibilityValidator() + .requireCompatible( + policy, wrongChannel, store(policy), Optional.of(ingress()), true)) + .isInstanceOf(NotificationApplicationException.class) + .hasMessageContaining("NOTIFICATION_CAPABILITY_INCOMPATIBLE"); + } + + @Test + void reconciliationPolicyRequiresProviderReconciliationCapability() { + NotificationKindPolicy policy = policy(); + NotificationProviderCapabilityDescriptor withoutReconciliation = + new NotificationProviderCapabilityDescriptor( + "provider-capability-42", + NotificationChannel.EMAIL, + Set.of(NotificationMode.DURABLE_ASYNC), + true, + false, + true, + 16, + 1_000_000); + + NotificationCapabilityCompatibilityValidator.Compatibility result = + new NotificationCapabilityCompatibilityValidator() + .validate(policy, withoutReconciliation, store(policy), Optional.of(ingress()), true); + + assertThat(result.compatible()).isFalse(); + assertThat(result.reasonCodes()) + .containsExactly(new NotificationReasonCode("PROVIDER_RECONCILIATION_UNSUPPORTED")); + } + + private static NotificationKindPolicy policy() { + return new NotificationKindPolicy( + new NotificationKindId("security-alert"), + NotificationChannel.EMAIL, + new NotificationRouteId("security-email"), + new NotificationTemplateRef("security-alert", 3), + NotificationMode.DURABLE_ASYNC, + NotificationAdmissionClass.SECURITY_CRITICAL, + NotificationRouteStrategy.SINGLE, + ConsentCheckMode.RECHECK_BEFORE_EACH_DELIVERY, + 7, + 1, + 3, + 0, + 1, + 4, + Duration.ofHours(2)); + } + + private static NotificationProviderCapabilityDescriptor provider(boolean receipts) { + return new NotificationProviderCapabilityDescriptor( + "provider-capability-42", + NotificationChannel.EMAIL, + Set.of(NotificationMode.DURABLE_ASYNC), + receipts, + true, + true, + 16, + 1_000_000); + } + + private static NotificationStoreCapabilityDescriptor store(NotificationKindPolicy policy) { + return new NotificationStoreCapabilityDescriptor( + true, true, true, 100, Set.of(policy.policyRevision()), Set.of(policy.templateRef())); + } + + private static NotificationReceiptIngressCapabilityDescriptor ingress() { + return new NotificationReceiptIngressCapabilityDescriptor( + NotificationChannel.EMAIL, + true, + true, + java.util.EnumSet.allOf(NotificationReceiptFact.Type.class)); + } +} diff --git a/src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationDispatchUseCaseTest.java b/src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationDispatchUseCaseTest.java new file mode 100644 index 00000000..b5f9b91c --- /dev/null +++ b/src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationDispatchUseCaseTest.java @@ -0,0 +1,426 @@ +package dev.caskeleton.application.notification; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.application.transaction.TransactionPort; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Supplier; +import org.junit.jupiter.api.Test; + +class NotificationDispatchUseCaseTest { + + private static final Instant NOW = Instant.parse("2026-07-28T00:00:00Z"); + + @Test + void claimAuthorizeProviderAndFinalizeUseShortTransactionsAroundProviderIo() { + List trace = new ArrayList<>(); + TrackingTransactionPort transactions = new TrackingTransactionPort(trace); + RecordingStore store = new RecordingStore(trace, sampleClaim(), finalizationApplied()); + NotificationProviderAttemptPort provider = + attempt -> { + assertThat(transactions.active()).isFalse(); + trace.add("provider"); + return accepted(); + }; + NotificationDispatchUseCase useCase = + new NotificationDispatchUseCase( + store, + provider, + request -> NotificationAdmissionReadinessPort.ParkResult.PARKED, + transactions, + Clock.fixed(NOW, ZoneOffset.UTC)); + + NotificationDispatchResult result = useCase.handle(new NotificationDispatchCommand(10)); + + assertThat(trace) + .containsExactly( + "tx-begin", + "claim", + "tx-end", + "tx-begin", + "authorize", + "tx-end", + "provider", + "tx-begin", + "finalize", + "tx-end"); + assertThat(store.authorizedAttempt.claimToken()) + .isNotEqualTo(store.authorizedAttempt.executionToken()); + assertThat(result).isEqualTo(new NotificationDispatchResult(1, 1, 1, 1, 0, 0, 0)); + } + + @Test + void staleClaimNeverCallsProviderOrFinalize() { + List trace = new ArrayList<>(); + TrackingTransactionPort transactions = new TrackingTransactionPort(trace); + RecordingStore store = + new RecordingStore( + trace, + sampleClaim(), + new NotificationDeliveryStorePort.StaleClaim( + sampleClaim().deliveryId(), new NotificationReasonCode("STALE_CLAIM_TOKEN"))); + AtomicBoolean providerCalled = new AtomicBoolean(); + NotificationDispatchUseCase useCase = + new NotificationDispatchUseCase( + store, + attempt -> { + providerCalled.set(true); + return accepted(); + }, + request -> NotificationAdmissionReadinessPort.ParkResult.PARKED, + transactions, + Clock.fixed(NOW, ZoneOffset.UTC)); + + NotificationDispatchResult result = useCase.handle(new NotificationDispatchCommand(1)); + + assertThat(providerCalled).isFalse(); + assertThat(store.finalization).isNull(); + assertThat(result.staleClaimCount()).isEqualTo(1); + } + + @Test + void fallbackIsEligibleOnlyForDefinitelyNotAppliedAndIndeterminateIsTerminal() { + RecordingStore definiteStore = + dispatchWithOutcome( + new ProviderAttemptOutcome( + SubmissionCertainty.DEFINITELY_NOT_APPLIED, + RetryDisposition.TERMINAL, + NotificationFaultScope.DELIVERY, + new NotificationReasonCode("INVALID_RECIPIENT"), + Optional.empty(), + "correlation-42", + Optional.empty())); + RecordingStore indeterminateStore = + dispatchWithOutcome( + new ProviderAttemptOutcome( + SubmissionCertainty.INDETERMINATE, + RetryDisposition.NOT_APPLICABLE, + NotificationFaultScope.DELIVERY, + new NotificationReasonCode("RESPONSE_LOST"), + Optional.empty(), + "correlation-42", + Optional.empty())); + + assertThat(definiteStore.finalization.fallbackEligible()).isTrue(); + assertThat(definiteStore.finalization.terminalState()) + .isEqualTo(NotificationDeliveryStorePort.TerminalState.TERMINAL_FAILURE); + assertThat(indeterminateStore.finalization.fallbackEligible()).isFalse(); + assertThat(indeterminateStore.finalization.terminalState()) + .isEqualTo(NotificationDeliveryStorePort.TerminalState.TERMINAL_INDETERMINATE); + } + + @Test + void parkBindingCasRunsInFinalizeTransactionAndDoesNotActivateInitialFallback() { + List trace = new ArrayList<>(); + TrackingTransactionPort transactions = new TrackingTransactionPort(trace); + RecordingStore store = new RecordingStore(trace, sampleClaim(), finalizationApplied()); + AtomicBoolean parkInsideTransaction = new AtomicBoolean(); + NotificationDispatchUseCase useCase = + new NotificationDispatchUseCase( + store, + attempt -> + new ProviderAttemptOutcome( + SubmissionCertainty.DEFINITELY_NOT_APPLIED, + RetryDisposition.PARK_BINDING, + NotificationFaultScope.PROVIDER_BINDING, + new NotificationReasonCode("PROVIDER_AUTH_REJECTED"), + Optional.empty(), + "correlation-42", + Optional.empty()), + request -> { + parkInsideTransaction.set(transactions.active()); + assertThat(request.expectedGeneration()).isEqualTo(7); + return NotificationAdmissionReadinessPort.ParkResult.PARKED; + }, + transactions, + Clock.fixed(NOW, ZoneOffset.UTC)); + + NotificationDispatchResult result = useCase.handle(new NotificationDispatchCommand(1)); + + assertThat(parkInsideTransaction).isTrue(); + assertThat(store.finalization.terminalState()) + .isEqualTo(NotificationDeliveryStorePort.TerminalState.PARKED_BINDING); + assertThat(store.finalization.fallbackEligible()).isFalse(); + assertThat(result.parkedCount()).isEqualTo(1); + } + + @Test + void staleParkGenerationRequeuesTheLegInsteadOfStrandingItAsParked() { + List trace = new ArrayList<>(); + RecordingStore store = new RecordingStore(trace, sampleClaim(), finalizationApplied()); + NotificationDispatchResult result = + new NotificationDispatchUseCase( + store, + attempt -> + new ProviderAttemptOutcome( + SubmissionCertainty.DEFINITELY_NOT_APPLIED, + RetryDisposition.PARK_BINDING, + NotificationFaultScope.PROVIDER_BINDING, + new NotificationReasonCode("PROVIDER_AUTH_REJECTED"), + Optional.empty(), + "correlation-42", + Optional.empty()), + request -> NotificationAdmissionReadinessPort.ParkResult.STALE_GENERATION, + new TrackingTransactionPort(trace), + Clock.fixed(NOW, ZoneOffset.UTC)) + .handle(new NotificationDispatchCommand(1)); + + assertThat(store.finalization.parkResult()) + .isEqualTo(NotificationAdmissionReadinessPort.ParkResult.STALE_GENERATION); + assertThat(store.finalization.terminalState()) + .isEqualTo(NotificationDeliveryStorePort.TerminalState.RETRY_SCHEDULED); + assertThat(result.parkedCount()).isZero(); + } + + @Test + void exactLateResultIsCountedWithoutBlindProviderRetry() { + RecordingStore store = + new RecordingStore(new ArrayList<>(), sampleClaim(), finalizationLateExact()); + AtomicBoolean providerCalled = new AtomicBoolean(); + NotificationDispatchUseCase useCase = + new NotificationDispatchUseCase( + store, + attempt -> { + providerCalled.set(true); + return accepted(); + }, + request -> NotificationAdmissionReadinessPort.ParkResult.PARKED, + new TrackingTransactionPort(new ArrayList<>()), + Clock.fixed(NOW, ZoneOffset.UTC)); + + NotificationDispatchResult result = useCase.handle(new NotificationDispatchCommand(1)); + + assertThat(providerCalled).isTrue(); + assertThat(result.providerCallCount()).isEqualTo(1); + assertThat(result.finalizedCount()).isEqualTo(1); + } + + @Test + void staleAttemptExecutionTokenCannotFinalizeOrTriggerAnotherProviderCall() { + RecordingStore store = + new RecordingStore( + new ArrayList<>(), + sampleClaim(), + NotificationDeliveryStorePort.FinalizationResult.STALE_EXECUTION_TOKEN); + AtomicBoolean providerCalled = new AtomicBoolean(); + NotificationDispatchResult result = + new NotificationDispatchUseCase( + store, + attempt -> { + providerCalled.set(true); + return accepted(); + }, + request -> NotificationAdmissionReadinessPort.ParkResult.PARKED, + new TrackingTransactionPort(new ArrayList<>()), + Clock.fixed(NOW, ZoneOffset.UTC)) + .handle(new NotificationDispatchCommand(1)); + + assertThat(providerCalled).isTrue(); + assertThat(result.providerCallCount()).isEqualTo(1); + assertThat(result.finalizedCount()).isZero(); + } + + private static RecordingStore dispatchWithOutcome(ProviderAttemptOutcome outcome) { + RecordingStore store = + new RecordingStore(new ArrayList<>(), sampleClaim(), finalizationApplied()); + NotificationDispatchUseCase useCase = + new NotificationDispatchUseCase( + store, + attempt -> outcome, + request -> NotificationAdmissionReadinessPort.ParkResult.PARKED, + new TrackingTransactionPort(new ArrayList<>()), + Clock.fixed(NOW, ZoneOffset.UTC)); + useCase.handle(new NotificationDispatchCommand(1)); + return store; + } + + private static NotificationDeliveryStorePort.ClaimedDelivery sampleClaim() { + return new NotificationDeliveryStorePort.ClaimedDelivery( + new NotificationDeliveryId("delivery-42"), samplePlan(), 0, "claim-token-42", 3, 7); + } + + private static NotificationFrozenPlan samplePlan() { + NotificationKindPolicy policy = + new NotificationKindPolicy( + new NotificationKindId("security-alert"), + NotificationChannel.EMAIL, + new NotificationRouteId("security-email"), + new NotificationTemplateRef("security-alert", 1), + NotificationMode.DURABLE_ASYNC, + NotificationAdmissionClass.SECURITY_CRITICAL, + NotificationRouteStrategy.SINGLE, + ConsentCheckMode.SNAPSHOT_AT_APPEND, + 2, + 1, + 2, + 0, + 1, + 3, + java.time.Duration.ofHours(1)); + NotificationIntentDraft draft = + new NotificationIntentDraft( + new NotificationIntentId("intent-42"), + policy, + java.util.Locale.ENGLISH, + new EmailRecipientReference("recipient-ref-42"), + new NotificationTemplateParameters( + Map.of("displayName", new NotificationTemplateValue.SafeText("Ada"))), + "security-alert", + "source-operation-42", + Optional.empty(), + "correlation-42", + Optional.empty(), + NOW, + NOW.plusSeconds(600)); + return NotificationFrozenPlan.from( + draft, + java.util.Locale.ENGLISH, + NotificationTestFixtures.binding(draft.policy().channel())); + } + + private static ProviderAttemptOutcome accepted() { + return new ProviderAttemptOutcome( + SubmissionCertainty.PROVIDER_ACCEPTED, + RetryDisposition.NOT_APPLICABLE, + NotificationFaultScope.DELIVERY, + new NotificationReasonCode("PROVIDER_ACCEPTED"), + Optional.empty(), + "correlation-42", + Optional.of("provider-message-42")); + } + + private static NotificationDeliveryStorePort.FinalizationResult finalizationApplied() { + return NotificationDeliveryStorePort.FinalizationResult.APPLIED; + } + + private static NotificationDeliveryStorePort.FinalizationResult finalizationLateExact() { + return NotificationDeliveryStorePort.FinalizationResult.LATE_EXACT_APPLIED; + } + + private static final class RecordingStore implements NotificationDeliveryStorePort { + + private final List trace; + private final ClaimedDelivery claim; + private final AttemptAuthorization authorization; + private final FinalizationResult finalizationResult; + private AuthorizedAttempt authorizedAttempt; + private AttemptFinalization finalization; + + private RecordingStore( + List trace, ClaimedDelivery claim, FinalizationResult finalizationResult) { + this.trace = trace; + this.claim = claim; + this.authorization = + new Authorized( + new AuthorizedAttempt( + claim.deliveryId(), + new NotificationAttemptId("attempt-42"), + claim.plan(), + claim.targetOrdinal(), + claim.claimToken(), + "execution-token-42", + claim.rowVersion(), + claim.admissionGeneration(), + "provider-binding-scope-42", + NOW.plusSeconds(30))); + this.finalizationResult = finalizationResult; + } + + private RecordingStore( + List trace, ClaimedDelivery claim, AttemptAuthorization authorization) { + this.trace = trace; + this.claim = claim; + this.authorization = authorization; + this.finalizationResult = FinalizationResult.APPLIED; + } + + @Override + public List claimEligible(int maximumClaims, Instant now) { + trace.add("claim"); + return List.of(claim); + } + + @Override + public AttemptAuthorization reserveAndAuthorize(ClaimedDelivery claimed, Instant now) { + trace.add("authorize"); + if (authorization instanceof Authorized authorized) { + authorizedAttempt = authorized.attempt(); + } + return authorization; + } + + @Override + public FinalizationResult finalizeAttempt( + AuthorizedAttempt attempt, AttemptFinalization finalization, Instant now) { + trace.add("finalize"); + this.finalization = finalization; + return finalizationResult; + } + + @Override + public List claimForReconciliation(int maximumClaims, Instant now) { + return List.of(); + } + + @Override + public ReconciliationFinalizationResult finalizeReconciliation( + ReconciliationClaim claim, + NotificationReconciliationPort.ReconciliationOutcome outcome, + Instant now) { + throw new UnsupportedOperationException(); + } + + @Override + public int attachOrphanReceipts(int maximumAttachments, Instant now) { + return 0; + } + } + + private static final class TrackingTransactionPort implements TransactionPort { + + private final List trace; + private boolean active; + + private TrackingTransactionPort(List trace) { + this.trace = trace; + } + + @Override + public T inWrite(Supplier action) { + trace.add("tx-begin"); + active = true; + try { + return action.get(); + } finally { + active = false; + trace.add("tx-end"); + } + } + + @Override + public T inRootWrite(Supplier action) { + return inWrite(action); + } + + @Override + public T inRead(Supplier action) { + return action.get(); + } + + @Override + public T inNew(Supplier action) { + return action.get(); + } + + private boolean active() { + return active; + } + } +} diff --git a/src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationKindPolicyTest.java b/src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationKindPolicyTest.java new file mode 100644 index 00000000..264d1a77 --- /dev/null +++ b/src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationKindPolicyTest.java @@ -0,0 +1,110 @@ +package dev.caskeleton.application.notification; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Duration; +import org.junit.jupiter.api.Test; + +class NotificationKindPolicyTest { + + @Test + void modeAndAdmissionClassAreCodeOwnedAndRuntimeConfigCanOnlyAssertExactValues() { + NotificationKindPolicy policy = + policy( + NotificationMode.DURABLE_ASYNC, + NotificationAdmissionClass.SECURITY_CRITICAL, + NotificationRouteStrategy.SINGLE); + + assertThat( + policy.assertRuntimeExpectation( + NotificationMode.DURABLE_ASYNC, NotificationAdmissionClass.SECURITY_CRITICAL)) + .isSameAs(policy); + assertThatThrownBy( + () -> + policy.assertRuntimeExpectation( + NotificationMode.BEST_EFFORT_INLINE, + NotificationAdmissionClass.SECURITY_CRITICAL)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("mode"); + assertThatThrownBy( + () -> + policy.assertRuntimeExpectation( + NotificationMode.DURABLE_ASYNC, NotificationAdmissionClass.TRANSACTIONAL)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("admission"); + } + + @Test + void configCannotStrengthenInlinePolicyToDurable() { + NotificationKindPolicy policy = + policy( + NotificationMode.BEST_EFFORT_INLINE, + NotificationAdmissionClass.BULK_LOW_VALUE, + NotificationRouteStrategy.FAN_OUT_ALL); + + assertThatThrownBy( + () -> + policy.assertRuntimeExpectation( + NotificationMode.DURABLE_ASYNC, NotificationAdmissionClass.BULK_LOW_VALUE)) + .isInstanceOf(IllegalStateException.class); + } + + @Test + void criticalKindCannotBindBestEffortInline() { + assertThatThrownBy( + () -> + policy( + NotificationMode.BEST_EFFORT_INLINE, + NotificationAdmissionClass.SECURITY_CRITICAL, + NotificationRouteStrategy.SINGLE)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("SECURITY_CRITICAL"); + } + + @Test + void amplificationBudgetsAreClosedAndInternallyConsistent() { + assertThatThrownBy( + () -> + new NotificationKindPolicy( + new NotificationKindId("security-alert"), + NotificationChannel.SLACK, + new NotificationRouteId("slack-security"), + new NotificationTemplateRef("security-alert", 1), + NotificationMode.DURABLE_ASYNC, + NotificationAdmissionClass.SECURITY_CRITICAL, + NotificationRouteStrategy.FAN_OUT_ALL, + ConsentCheckMode.RECHECK_BEFORE_EACH_DELIVERY, + 1, + 4, + 3, + 0, + 1, + 3, + Duration.ofHours(1))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("provider calls"); + } + + private static NotificationKindPolicy policy( + NotificationMode mode, + NotificationAdmissionClass admissionClass, + NotificationRouteStrategy strategy) { + return new NotificationKindPolicy( + new NotificationKindId("security-alert"), + NotificationChannel.SLACK, + new NotificationRouteId("slack-security"), + new NotificationTemplateRef("security-alert", 1), + mode, + admissionClass, + strategy, + ConsentCheckMode.RECHECK_BEFORE_EACH_DELIVERY, + 3, + strategy == NotificationRouteStrategy.SINGLE ? 1 : 2, + mode == NotificationMode.BEST_EFFORT_INLINE ? 1 : 2, + strategy == NotificationRouteStrategy.ORDERED_FALLBACK ? 1 : 0, + mode == NotificationMode.BEST_EFFORT_INLINE ? 0 : 1, + 6, + Duration.ofHours(2)); + } +} diff --git a/src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationLegacyWriterPermitUseCaseTest.java b/src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationLegacyWriterPermitUseCaseTest.java new file mode 100644 index 00000000..e263bfa6 --- /dev/null +++ b/src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationLegacyWriterPermitUseCaseTest.java @@ -0,0 +1,149 @@ +package dev.caskeleton.application.notification; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; + +class NotificationLegacyWriterPermitUseCaseTest { + + private static final Instant NOW = Instant.parse("2026-07-28T00:00:00Z"); + + @Test + void acquireRootCommitsDbTimeWireDeadlineAndExpiryBeforeCallerCanStartNetwork() { + NotificationWriterRouteSet routeSet = InitializeNotificationWriterFencesUseCaseTest.routeSet(); + InitializeNotificationWriterFencesUseCaseTest.TrackingRootTransactions transactions = + new InitializeNotificationWriterFencesUseCaseTest.TrackingRootTransactions(); + NotificationWriterCutoverPort port = new RecordingCutoverPort(transactions); + NotificationLegacyWriterPermitUseCase useCase = + new NotificationLegacyWriterPermitUseCase( + routeSet, port, transactions, Clock.fixed(NOW, ZoneOffset.UTC)); + + NotificationLegacyWriterPermitResult result = + useCase.handle( + NotificationLegacyWriterPermitCommand.acquire( + routeSet.canonicalRoutes().routes().getFirst(), + 7, + "legacy-http-v1", + "permit-42", + "node-42", + "permit-operation-42", + "operator-42", + new NotificationReasonCode("LEGACY_SEND"), + Duration.ofSeconds(5))); + + assertThat(transactions.rootCalls).isEqualTo(1); + assertThat(result.status()).isEqualTo(NotificationLegacyWriterPermitResult.Status.ACQUIRED); + assertThat(result.acquiredAt()).contains(NOW); + assertThat(result.wireDeadline()).contains(NOW.plusSeconds(5)); + assertThat(result.expiresAt()).contains(NOW.plusSeconds(10)); + assertThat(result.wireDeadline().orElseThrow()) + .isBeforeOrEqualTo(result.expiresAt().orElseThrow()); + } + + @Test + void unknownProfileAndUnboundedWireBudgetFailBeforePortMutation() { + NotificationWriterRouteSet routeSet = InitializeNotificationWriterFencesUseCaseTest.routeSet(); + InitializeNotificationWriterFencesUseCaseTest.TrackingRootTransactions transactions = + new InitializeNotificationWriterFencesUseCaseTest.TrackingRootTransactions(); + AtomicInteger calls = new AtomicInteger(); + NotificationLegacyWriterPermitUseCase useCase = + new NotificationLegacyWriterPermitUseCase( + routeSet, + new NotificationWriterCutoverPort() { + @Override + public NotificationLegacyWriterPermitResult acquireLegacyPermit( + NotificationLegacyWriterPermitCommand command, + NotificationWriterRouteSet.RouteProfile routeProfile, + Instant requestedAt) { + calls.incrementAndGet(); + throw new AssertionError("port must not run"); + } + + @Override + public NotificationLegacyWriterPermitResult releaseLegacyPermit( + NotificationLegacyWriterPermitCommand command, + NotificationWriterRouteSet.RouteProfile routeProfile, + Instant requestedAt) { + calls.incrementAndGet(); + throw new AssertionError("port must not run"); + } + }, + transactions, + Clock.fixed(NOW, ZoneOffset.UTC)); + + assertThatThrownBy( + () -> + useCase.handle( + NotificationLegacyWriterPermitCommand.acquire( + routeSet.canonicalRoutes().routes().getFirst(), + 7, + "unknown-profile", + "permit-42", + "node-42", + "permit-operation-42", + "operator-42", + new NotificationReasonCode("LEGACY_SEND"), + Duration.ofSeconds(5)))) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy( + () -> + NotificationLegacyWriterPermitCommand.acquire( + routeSet.canonicalRoutes().routes().getFirst(), + 7, + "legacy-http-v1", + "permit-42", + "node-42", + "permit-operation-42", + "operator-42", + new NotificationReasonCode("LEGACY_SEND"), + Duration.ofMinutes(2))) + .isInstanceOf(IllegalArgumentException.class); + assertThat(calls).hasValue(0); + assertThat(transactions.rootCalls).isZero(); + } + + private static final class RecordingCutoverPort implements NotificationWriterCutoverPort { + + private final InitializeNotificationWriterFencesUseCaseTest.TrackingRootTransactions + transactions; + + private RecordingCutoverPort( + InitializeNotificationWriterFencesUseCaseTest.TrackingRootTransactions transactions) { + this.transactions = transactions; + } + + @Override + public NotificationLegacyWriterPermitResult acquireLegacyPermit( + NotificationLegacyWriterPermitCommand command, + NotificationWriterRouteSet.RouteProfile routeProfile, + Instant requestedAt) { + assertThat(transactions.active).isTrue(); + return new NotificationLegacyWriterPermitResult( + NotificationLegacyWriterPermitResult.Status.ACQUIRED, + command.permitToken(), + Optional.of(requestedAt), + Optional.of(requestedAt.plus(command.wireBudget().orElseThrow())), + Optional.of(requestedAt.plusSeconds(10))); + } + + @Override + public NotificationLegacyWriterPermitResult releaseLegacyPermit( + NotificationLegacyWriterPermitCommand command, + NotificationWriterRouteSet.RouteProfile routeProfile, + Instant requestedAt) { + return new NotificationLegacyWriterPermitResult( + NotificationLegacyWriterPermitResult.Status.RELEASED, + command.permitToken(), + Optional.empty(), + Optional.empty(), + Optional.empty()); + } + } +} diff --git a/src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationMaintenanceUseCaseTest.java b/src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationMaintenanceUseCaseTest.java new file mode 100644 index 00000000..f57e571b --- /dev/null +++ b/src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationMaintenanceUseCaseTest.java @@ -0,0 +1,80 @@ +package dev.caskeleton.application.notification; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.application.transaction.TransactionPort; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.function.Supplier; +import org.junit.jupiter.api.Test; + +class NotificationMaintenanceUseCaseTest { + + @Test + void maintenanceIsBoundedAndRunsInsideOneShortWriteTransaction() { + TrackingTransactions transactions = new TrackingTransactions(); + NotificationMaintenanceStorePort store = + (command, now) -> { + assertThat(transactions.active).isTrue(); + return new NotificationMaintenanceStorePort.MutationResult(3, 2, 1); + }; + NotificationMaintenanceUseCase useCase = + new NotificationMaintenanceUseCase( + store, + transactions, + Clock.fixed(Instant.parse("2026-07-28T00:00:00Z"), ZoneOffset.UTC)); + + NotificationMaintenanceResult result = + useCase.handle(new NotificationMaintenanceCommand(40, 30, 30)); + + assertThat(transactions.writeCalls).isEqualTo(1); + assertThat(result).isEqualTo(new NotificationMaintenanceResult(3, 2, 1)); + assertThatThrownBy(() -> new NotificationMaintenanceCommand(50, 50, 1)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy( + () -> new NotificationMaintenanceCommand(Integer.MAX_VALUE, Integer.MAX_VALUE, 3)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy( + () -> + new NotificationMaintenanceStorePort.MutationResult( + Integer.MAX_VALUE, Integer.MAX_VALUE, 3)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy( + () -> new NotificationMaintenanceResult(Integer.MAX_VALUE, Integer.MAX_VALUE, 3)) + .isInstanceOf(IllegalArgumentException.class); + } + + private static final class TrackingTransactions implements TransactionPort { + + private boolean active; + private int writeCalls; + + @Override + public T inWrite(Supplier action) { + writeCalls++; + active = true; + try { + return action.get(); + } finally { + active = false; + } + } + + @Override + public T inRootWrite(Supplier action) { + return inWrite(action); + } + + @Override + public T inRead(Supplier action) { + return action.get(); + } + + @Override + public T inNew(Supplier action) { + return action.get(); + } + } +} diff --git a/src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationOperationsSnapshotUseCaseTest.java b/src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationOperationsSnapshotUseCaseTest.java new file mode 100644 index 00000000..183ad9d8 --- /dev/null +++ b/src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationOperationsSnapshotUseCaseTest.java @@ -0,0 +1,111 @@ +package dev.caskeleton.application.notification; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import dev.caskeleton.application.usecase.QueryUseCase; +import java.time.Instant; +import java.util.List; +import java.util.function.Supplier; +import org.junit.jupiter.api.Test; + +class NotificationOperationsSnapshotUseCaseTest { + + @Test + void concreteQueryUseCaseLoadsOnlyBoundedSnapshotInsideReadTransaction() { + TrackingTransactions transactions = new TrackingTransactions(); + NotificationOperationsSnapshot expected = + new NotificationOperationsSnapshot( + Instant.parse("2026-07-28T00:00:00Z"), + 12, + 2, + 1, + 0, + List.of( + new NotificationOperationsSnapshot.RouteWriterStatus( + new NotificationRouteId("security-email"), + 2, + NotificationWriterOwnership.CANONICAL, + 8, + false))); + NotificationOperationsSnapshotPort port = + query -> { + assertThat(transactions.readActive).isTrue(); + assertThat(query.maximumRoutes()).isEqualTo(100); + return expected; + }; + NotificationOperationsSnapshotUseCase useCase = + new NotificationOperationsSnapshotUseCase(port, transactions); + + NotificationOperationsSnapshot result = + useCase.handle(new NotificationOperationsSnapshotQuery(100)); + + assertThat(result).isEqualTo(expected); + assertThat(transactions.readCalls).isEqualTo(1); + assertThat(useCase).isInstanceOf(QueryUseCase.class); + UseCaseCapability capability = + NotificationOperationsSnapshotUseCase.class.getAnnotation(UseCaseCapability.class); + assertThat(capability.transactionMode()).isEqualTo(TransactionMode.READ_ONLY); + assertThat(capability.repositoryAccess()).isEqualTo(RepositoryAccess.READ_REPOSITORY); + assertThat(capability.idempotency()).isEqualTo(Idempotency.IDEMPOTENT); + assertThat(capability.externalOutboundAllowed()).isFalse(); + assertThat(capability.sensitiveRead()).isFalse(); + assertThat(capability.crossTenantAdmin()).isTrue(); + } + + @Test + void snapshotDefensivelyCopiesBoundedNonSensitiveRouteValues() { + java.util.ArrayList mutable = + new java.util.ArrayList<>( + List.of( + new NotificationOperationsSnapshot.RouteWriterStatus( + new NotificationRouteId("security-email"), + 2, + NotificationWriterOwnership.LEGACY, + 7, + true))); + NotificationOperationsSnapshot snapshot = + new NotificationOperationsSnapshot( + Instant.parse("2026-07-28T00:00:00Z"), 0, 0, 0, 0, mutable); + mutable.clear(); + + assertThat(snapshot.writerRoutes()).hasSize(1); + assertThat(snapshot.toString()).doesNotContain("recipient", "payload", "credential"); + } + + private static final class TrackingTransactions implements TransactionPort { + + private boolean readActive; + private int readCalls; + + @Override + public T inWrite(Supplier action) { + return action.get(); + } + + @Override + public T inRootWrite(Supplier action) { + return action.get(); + } + + @Override + public T inRead(Supplier action) { + readCalls++; + readActive = true; + try { + return action.get(); + } finally { + readActive = false; + } + } + + @Override + public T inNew(Supplier action) { + return action.get(); + } + } +} diff --git a/src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationPlanningBoundaryTest.java b/src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationPlanningBoundaryTest.java new file mode 100644 index 00000000..970c719a --- /dev/null +++ b/src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationPlanningBoundaryTest.java @@ -0,0 +1,160 @@ +package dev.caskeleton.application.notification; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Duration; +import java.time.Instant; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; + +class NotificationPlanningBoundaryTest { + + @Test + void featureFactoryAndCodePolicyProduceDraftThenPlannerReturnsApplicationFrozenPlan() { + NotificationKindPolicy policy = securityAlertPolicy(); + SecurityAlertNotificationRequestFactory factory = + new SecurityAlertNotificationRequestFactory(policy); + NotificationIntentDraft draft = + factory.create( + "intent-42", + "recipient-ref-42", + "source-operation-42", + Instant.parse("2026-07-28T00:00:00Z")); + NotificationPlanPort planner = + requested -> + new NotificationPlanningResult.Planned( + NotificationFrozenPlan.from( + requested, + Locale.ENGLISH, + NotificationTestFixtures.binding(requested.policy().channel()))); + + NotificationPlanningResult result = planner.plan(draft); + + assertThat(result).isInstanceOf(NotificationPlanningResult.Planned.class); + NotificationFrozenPlan plan = ((NotificationPlanningResult.Planned) result).plan(); + assertThat(plan.policy()).isSameAs(policy); + assertThat(plan.mode()).isEqualTo(NotificationMode.DURABLE_ASYNC); + assertThat(plan.selectedLocale()).isEqualTo(Locale.ENGLISH); + } + + @Test + void appendAndInlinePortsConsumeTheSameFrozenApplicationPlan() { + NotificationIntentDraft draft = + new SecurityAlertNotificationRequestFactory(securityAlertPolicy()) + .create( + "intent-42", + "recipient-ref-42", + "source-operation-42", + Instant.parse("2026-07-28T00:00:00Z")); + NotificationFrozenPlan plan = + NotificationFrozenPlan.from( + draft, Locale.ENGLISH, NotificationTestFixtures.binding(draft.policy().channel())); + AtomicReference appended = new AtomicReference<>(); + AtomicReference attempted = new AtomicReference<>(); + NotificationIntentAppendPort appendPort = + frozenPlan -> { + appended.set(frozenPlan); + return new NotificationAppendResult.Appended(frozenPlan.intentId()); + }; + InlineNotificationAttemptPort inlinePort = + frozenPlan -> { + attempted.set(frozenPlan); + return new NotificationRequestResult.InlineCompleted( + frozenPlan.intentId(), + java.util.List.of( + new TargetAttemptOutcome( + 0, + new NotificationDeliveryId("delivery-42"), + new ProviderAttemptOutcome( + SubmissionCertainty.PROVIDER_ACCEPTED, + RetryDisposition.NOT_APPLICABLE, + NotificationFaultScope.DELIVERY, + new NotificationReasonCode("PROVIDER_ACCEPTED"), + Optional.empty(), + "correlation-42", + Optional.of("provider-message-42"))))); + }; + + assertThat(appendPort.append(plan)) + .isEqualTo(new NotificationAppendResult.Appended(plan.intentId())); + assertThat(inlinePort.attempt(plan).intentId()).isEqualTo(plan.intentId()); + assertThat(appended).hasValue(plan); + assertThat(attempted).hasValue(plan); + } + + @Test + void planningFailuresStayBoundedAndProviderNeutral() { + NotificationPlanPort planner = + draft -> + new NotificationPlanningResult.Rejected( + new NotificationReasonCode("ROUTE_NOT_QUALIFIED")); + + NotificationPlanningResult result = + planner.plan( + new SecurityAlertNotificationRequestFactory(securityAlertPolicy()) + .create( + "intent-42", + "recipient-ref-42", + "source-operation-42", + Instant.parse("2026-07-28T00:00:00Z"))); + + assertThat(result) + .isEqualTo( + new NotificationPlanningResult.Rejected( + new NotificationReasonCode("ROUTE_NOT_QUALIFIED"))); + assertThat(result.toString()) + .doesNotContain("software.amazon", "slack.api", "webhook", "credential"); + } + + private static NotificationKindPolicy securityAlertPolicy() { + return new NotificationKindPolicy( + new NotificationKindId("security-alert"), + NotificationChannel.EMAIL, + new NotificationRouteId("security-alert-email"), + new NotificationTemplateRef("security-alert", 1), + NotificationMode.DURABLE_ASYNC, + NotificationAdmissionClass.SECURITY_CRITICAL, + NotificationRouteStrategy.SINGLE, + ConsentCheckMode.SNAPSHOT_AT_APPEND, + 1, + 1, + 2, + 0, + 1, + 3, + Duration.ofHours(2)); + } + + /** + * Feature-specific factory example intentionally kept in test fixtures, not generic production. + */ + private static final class SecurityAlertNotificationRequestFactory { + + private final NotificationKindPolicy policy; + + private SecurityAlertNotificationRequestFactory(NotificationKindPolicy policy) { + this.policy = policy; + } + + private NotificationIntentDraft create( + String intentId, String recipientReference, String sourceOperationId, Instant now) { + return new NotificationIntentDraft( + new NotificationIntentId(intentId), + policy, + Locale.forLanguageTag("ko-KR"), + new EmailRecipientReference(recipientReference), + new NotificationTemplateParameters( + Map.of("displayName", new NotificationTemplateValue.SafeText("redacted-value"))), + "security-alert", + sourceOperationId, + Optional.empty(), + "correlation-42", + Optional.empty(), + now, + now.plus(Duration.ofMinutes(30))); + } + } +} diff --git a/src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationPortBoundaryTest.java b/src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationPortBoundaryTest.java new file mode 100644 index 00000000..bdd61210 --- /dev/null +++ b/src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationPortBoundaryTest.java @@ -0,0 +1,82 @@ +package dev.caskeleton.application.notification; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.Locale; +import java.util.Set; +import java.util.stream.Collectors; +import org.junit.jupiter.api.Test; + +class NotificationPortBoundaryTest { + + @Test + void appendInlineAndPlanningPortsAreNarrowAndOwnNoTransactionMechanism() { + assertThat(NotificationPlanPort.class.getDeclaredMethods()).hasSize(1); + assertThat(NotificationIntentAppendPort.class.getDeclaredMethods()).hasSize(1); + assertThat(InlineNotificationAttemptPort.class.getDeclaredMethods()).hasSize(1); + + Method append = NotificationIntentAppendPort.class.getDeclaredMethods()[0]; + assertThat(append.getName()).isEqualTo("append"); + assertThat(append.getParameterTypes()).containsExactly(NotificationFrozenPlan.class); + assertThat(append.getReturnType()).isEqualTo(NotificationAppendResult.class); + assertThat(Arrays.stream(append.getParameterTypes()).map(Class::getName)) + .noneMatch(name -> name.contains("Transaction") || name.contains("Entity")); + } + + @Test + void publicPortSignaturesContainOnlyApplicationAndJdkTypes() { + Set typeNames = + java.util.stream.Stream.of( + NotificationPlanPort.class, + NotificationIntentAppendPort.class, + InlineNotificationAttemptPort.class) + .flatMap(type -> Arrays.stream(type.getDeclaredMethods())) + .flatMap( + method -> + java.util.stream.Stream.concat( + java.util.stream.Stream.of(method.getGenericReturnType().getTypeName()), + Arrays.stream(method.getGenericParameterTypes()) + .map(java.lang.reflect.Type::getTypeName))) + .collect(Collectors.toSet()); + + assertThat(typeNames) + .allMatch( + name -> + name.startsWith("dev.caskeleton.application.notification.") + || name.startsWith("java.")); + assertThat(typeNames.stream().map(name -> name.toLowerCase(Locale.ROOT))) + .noneMatch( + name -> + name.contains("slack.api") + || name.contains("software.amazon") + || name.contains("spring") + || name.contains("jakarta.persistence") + || name.contains("entity") + || name.contains("dto") + || name.contains("compilednotificationbinding") + || name.contains("providerruntimeprofile")); + } + + @Test + void noGiantNotificationPortCombinesPlanningAppendAttemptStoreAndReceipt() { + Set methodNames = + java.util.stream.Stream.of( + NotificationPlanPort.class, + NotificationIntentAppendPort.class, + InlineNotificationAttemptPort.class) + .flatMap(type -> Arrays.stream(type.getDeclaredMethods())) + .map(Method::getName) + .collect(Collectors.toSet()); + + assertThat(methodNames).containsExactlyInAnyOrder("plan", "append", "attempt"); + assertThat( + java.util.stream.Stream.of( + NotificationPlanPort.class, + NotificationIntentAppendPort.class, + InlineNotificationAttemptPort.class) + .map(Class::getSimpleName)) + .noneMatch(name -> name.equals("NotificationStorePort") || name.equals("NotificationPort")); + } +} diff --git a/src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationReceiptReducerTest.java b/src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationReceiptReducerTest.java new file mode 100644 index 00000000..2584ee2b --- /dev/null +++ b/src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationReceiptReducerTest.java @@ -0,0 +1,231 @@ +package dev.caskeleton.application.notification; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.application.transaction.TransactionPort; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.function.Supplier; +import org.junit.jupiter.api.Test; + +class NotificationReceiptReducerTest { + + private static final Instant NOW = Instant.parse("2026-07-28T00:00:00Z"); + + @Test + void receiptFactOrderDoesNotChangeOrthogonalProjectionAndAcceptanceIsNeverErased() { + List facts = + List.of( + fact(NotificationReceiptFact.Type.SEND, NotificationReceiptFact.BounceClass.NONE, 1), + fact( + NotificationReceiptFact.Type.DELIVERY, NotificationReceiptFact.BounceClass.NONE, 2), + fact(NotificationReceiptFact.Type.BOUNCE, NotificationReceiptFact.BounceClass.HARD, 3), + fact( + NotificationReceiptFact.Type.COMPLAINT, + NotificationReceiptFact.BounceClass.NONE, + 4), + fact( + NotificationReceiptFact.Type.DELIVERY_DELAY, + NotificationReceiptFact.BounceClass.NONE, + 5)); + NotificationReceiptProjection canonical = NotificationReceiptProjection.reduce(facts); + List reversed = new ArrayList<>(facts); + Collections.reverse(reversed); + List rotated = new ArrayList<>(facts); + Collections.rotate(rotated, 2); + + assertThat(NotificationReceiptProjection.reduce(reversed)).isEqualTo(canonical); + assertThat(NotificationReceiptProjection.reduce(rotated)).isEqualTo(canonical); + assertThat(canonical.submissionAccepted()).isTrue(); + assertThat(canonical.delivered()).isTrue(); + assertThat(canonical.hardBounced()).isTrue(); + assertThat(canonical.complained()).isTrue(); + assertThat(canonical.deliveryDelayed()).isTrue(); + assertThat(canonical.latestFactAt()).isEqualTo(NOW.plusSeconds(5)); + } + + @Test + void onlyHardBounceAndComplaintEmitTechnicalSuppression() { + assertThat( + apply( + fact( + NotificationReceiptFact.Type.BOUNCE, + NotificationReceiptFact.BounceClass.HARD, + 1)) + .suppressed) + .isEqualTo(1); + assertThat( + apply( + fact( + NotificationReceiptFact.Type.COMPLAINT, + NotificationReceiptFact.BounceClass.NONE, + 1)) + .suppressed) + .isEqualTo(1); + assertThat( + apply( + fact( + NotificationReceiptFact.Type.BOUNCE, + NotificationReceiptFact.BounceClass.SOFT, + 1)) + .suppressed) + .isZero(); + assertThat( + apply( + fact( + NotificationReceiptFact.Type.DELIVERY_DELAY, + NotificationReceiptFact.BounceClass.NONE, + 1)) + .suppressed) + .isZero(); + } + + @Test + void applyUsesPhysicalRootWriteAndDuplicateDoesNotRepeatSuppression() { + TrackingRootTransactionPort transactions = new TrackingRootTransactionPort(); + RecordingReceiptStore store = + new RecordingReceiptStore( + new NotificationReceiptStorePort.Duplicate( + NotificationReceiptProjection.reduce( + List.of( + fact( + NotificationReceiptFact.Type.COMPLAINT, + NotificationReceiptFact.BounceClass.NONE, + 1))))); + RecordingSuppressionPort suppression = new RecordingSuppressionPort(transactions); + ApplyNotificationReceiptUseCase useCase = + new ApplyNotificationReceiptUseCase( + store, suppression, transactions, Clock.fixed(NOW, ZoneOffset.UTC)); + + ApplyNotificationReceiptResult result = + useCase.handle( + command( + fact( + NotificationReceiptFact.Type.COMPLAINT, + NotificationReceiptFact.BounceClass.NONE, + 1))); + + assertThat(transactions.rootWriteCalls).isEqualTo(1); + assertThat(store.savedProjection).isNull(); + assertThat(suppression.suppressed).isZero(); + assertThat(result.status()).isEqualTo(ApplyNotificationReceiptResult.Status.DUPLICATE); + } + + private static ReceiptFixture apply(NotificationReceiptFact fact) { + TrackingRootTransactionPort transactions = new TrackingRootTransactionPort(); + NotificationReceiptProjection projection = NotificationReceiptProjection.reduce(List.of(fact)); + RecordingReceiptStore store = + new RecordingReceiptStore( + new NotificationReceiptStorePort.Appended( + new NotificationReceiptStorePort.ReceiptAggregate( + new NotificationDeliveryId("delivery-42"), + new EmailRecipientReference("recipient-ref-42"), + List.of(fact)))); + RecordingSuppressionPort suppression = new RecordingSuppressionPort(transactions); + ApplyNotificationReceiptResult result = + new ApplyNotificationReceiptUseCase( + store, suppression, transactions, Clock.fixed(NOW, ZoneOffset.UTC)) + .handle(command(fact)); + assertThat(result.projection()).isEqualTo(projection); + assertThat(store.savedProjection).isEqualTo(projection); + return new ReceiptFixture(suppression.suppressed); + } + + private static ApplyNotificationReceiptCommand command(NotificationReceiptFact fact) { + return new ApplyNotificationReceiptCommand( + new NormalizedNotificationReceiptCommand( + new NotificationReceiptEventId("receipt-42"), + new NotificationDeliveryId("delivery-42"), + fact)); + } + + private static NotificationReceiptFact fact( + NotificationReceiptFact.Type type, + NotificationReceiptFact.BounceClass bounceClass, + long second) { + return new NotificationReceiptFact( + type, + bounceClass, + new NotificationReasonCode( + type == NotificationReceiptFact.Type.BOUNCE + ? "RECIPIENT_BOUNCE" + : "PROVIDER_" + type.name()), + NOW.plusSeconds(second)); + } + + private record ReceiptFixture(int suppressed) {} + + private static final class RecordingReceiptStore implements NotificationReceiptStorePort { + + private final AppendResult appendResult; + private NotificationReceiptProjection savedProjection; + + private RecordingReceiptStore(AppendResult appendResult) { + this.appendResult = appendResult; + } + + @Override + public AppendResult appendIfAbsent(NormalizedNotificationReceiptCommand command) { + return appendResult; + } + + @Override + public void saveProjection( + NotificationDeliveryId deliveryId, NotificationReceiptProjection projection) { + savedProjection = projection; + } + } + + private static final class RecordingSuppressionPort + implements NotificationTechnicalSuppressionPort { + + private final TrackingRootTransactionPort transactions; + private int suppressed; + + private RecordingSuppressionPort(TrackingRootTransactionPort transactions) { + this.transactions = transactions; + } + + @Override + public void suppress(SuppressionMutation mutation) { + assertThat(transactions.active).isTrue(); + suppressed++; + } + } + + private static final class TrackingRootTransactionPort implements TransactionPort { + + private int rootWriteCalls; + private boolean active; + + @Override + public T inWrite(Supplier action) { + return action.get(); + } + + @Override + public T inRootWrite(Supplier action) { + rootWriteCalls++; + active = true; + try { + return action.get(); + } finally { + active = false; + } + } + + @Override + public T inRead(Supplier action) { + return action.get(); + } + + @Override + public T inNew(Supplier action) { + return action.get(); + } + } +} diff --git a/src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationRequestResultTest.java b/src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationRequestResultTest.java new file mode 100644 index 00000000..963daa64 --- /dev/null +++ b/src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationRequestResultTest.java @@ -0,0 +1,144 @@ +package dev.caskeleton.application.notification; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.Test; + +class NotificationRequestResultTest { + + @Test + void providerOutcomeKeepsSubmissionRetryAndFaultAxesOrthogonal() { + Instant retryAt = Instant.parse("2026-07-28T00:01:00Z"); + ProviderAttemptOutcome outcome = + new ProviderAttemptOutcome( + SubmissionCertainty.DEFINITELY_NOT_APPLIED, + RetryDisposition.RETRY_AT, + NotificationFaultScope.PROVIDER_BINDING, + new NotificationReasonCode("PROVIDER_THROTTLED"), + Optional.of(retryAt), + "attempt-correlation-42", + Optional.empty()); + + assertThat(outcome.submissionCertainty()).isEqualTo(SubmissionCertainty.DEFINITELY_NOT_APPLIED); + assertThat(outcome.retryDisposition()).isEqualTo(RetryDisposition.RETRY_AT); + assertThat(outcome.faultScope()).isEqualTo(NotificationFaultScope.PROVIDER_BINDING); + assertThat(outcome.retryNotBefore()).contains(retryAt); + } + + @Test + void outcomeRejectsContradictoryAxes() { + assertThatThrownBy( + () -> + new ProviderAttemptOutcome( + SubmissionCertainty.PROVIDER_ACCEPTED, + RetryDisposition.RETRY_AT, + NotificationFaultScope.DELIVERY, + new NotificationReasonCode("ACCEPTED"), + Optional.of(Instant.parse("2026-07-28T00:01:00Z")), + "attempt-correlation-42", + Optional.of("provider-message-ref-42"))) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy( + () -> + new ProviderAttemptOutcome( + SubmissionCertainty.DEFINITELY_NOT_APPLIED, + RetryDisposition.RETRY_AT, + NotificationFaultScope.PROVIDER_BINDING, + new NotificationReasonCode("PROVIDER_THROTTLED"), + Optional.empty(), + "attempt-correlation-42", + Optional.empty())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("retryNotBefore"); + assertThatThrownBy( + () -> + new ProviderAttemptOutcome( + SubmissionCertainty.INDETERMINATE, + RetryDisposition.NOT_APPLICABLE, + NotificationFaultScope.DELIVERY, + new NotificationReasonCode("RESPONSE_LOST"), + Optional.empty(), + "attempt-correlation-42", + Optional.of("message-that-cannot-be-known"))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void providerAndTargetOutcomesRedactOpaqueReferences() { + ProviderAttemptOutcome providerOutcome = + new ProviderAttemptOutcome( + SubmissionCertainty.PROVIDER_ACCEPTED, + RetryDisposition.NOT_APPLICABLE, + NotificationFaultScope.DELIVERY, + new NotificationReasonCode("PROVIDER_ACCEPTED"), + Optional.empty(), + "attempt-correlation-secret", + Optional.of("provider-message-secret")); + TargetAttemptOutcome target = + new TargetAttemptOutcome(0, new NotificationDeliveryId("delivery-secret"), providerOutcome); + + assertThat(providerOutcome.toString()) + .doesNotContain("attempt-correlation-secret") + .doesNotContain("provider-message-secret"); + assertThat(target.toString()).doesNotContain("delivery-secret"); + } + + @Test + void requestResultIsAClosedUnionAndInlineTargetListIsBoundedAndImmutable() { + List mutable = + new ArrayList<>( + List.of( + new TargetAttemptOutcome( + 0, + new NotificationDeliveryId("delivery-1"), + acceptedOutcome("correlation-1", "provider-message-1")))); + NotificationRequestResult.InlineCompleted completed = + new NotificationRequestResult.InlineCompleted( + new NotificationIntentId("intent-1"), mutable); + mutable.clear(); + + assertThat(completed.outcomes()).hasSize(1); + assertThatThrownBy(() -> completed.outcomes().clear()) + .isInstanceOf(UnsupportedOperationException.class); + assertThatThrownBy( + () -> + new NotificationRequestResult.InlineCompleted( + new NotificationIntentId("intent-1"), + java.util.stream.IntStream.range(0, 17) + .mapToObj( + index -> + new TargetAttemptOutcome( + index, + new NotificationDeliveryId("delivery-" + index), + acceptedOutcome( + "correlation-" + index, "provider-message-" + index))) + .toList())) + .isInstanceOf(IllegalArgumentException.class); + + assertThat(NotificationRequestResult.class.getPermittedSubclasses()) + .containsExactlyInAnyOrder( + NotificationRequestResult.InlineCompleted.class, + NotificationRequestResult.AppendedDurably.class, + NotificationRequestResult.DuplicateExistingIntent.class, + NotificationRequestResult.RejectedByBusinessPolicy.class, + NotificationRequestResult.RejectedInvalidRequest.class, + NotificationRequestResult.CapabilityUnavailable.class); + } + + private static ProviderAttemptOutcome acceptedOutcome( + String correlationReference, String providerReference) { + return new ProviderAttemptOutcome( + SubmissionCertainty.PROVIDER_ACCEPTED, + RetryDisposition.NOT_APPLICABLE, + NotificationFaultScope.DELIVERY, + new NotificationReasonCode("PROVIDER_ACCEPTED"), + Optional.empty(), + correlationReference, + Optional.of(providerReference)); + } +} diff --git a/src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationTestFixtures.java b/src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationTestFixtures.java new file mode 100644 index 00000000..f0ba8c13 --- /dev/null +++ b/src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationTestFixtures.java @@ -0,0 +1,26 @@ +package dev.caskeleton.application.notification; + +import java.time.Duration; +import java.util.List; + +final class NotificationTestFixtures { + + private NotificationTestFixtures() {} + + static NotificationFrozenPlan.BindingSnapshot binding(NotificationChannel channel) { + String capability = + channel == NotificationChannel.EMAIL + ? "aws-ses-v2-durable-single-local-sns-v1" + : "slack-web-api-durable-single-local-v1"; + return new NotificationFrozenPlan.BindingSnapshot( + 3, + "a".repeat(64), + "b".repeat(64), + "renderer-r3", + List.of( + new NotificationFrozenPlan.FrozenTarget( + 0, "target-r3", capability, "provider-binding-r3", "credential-r3")), + channel == NotificationChannel.EMAIL, + Duration.ofSeconds(5)); + } +} diff --git a/src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationValueContractTest.java b/src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationValueContractTest.java new file mode 100644 index 00000000..4625f627 --- /dev/null +++ b/src/application-core/src/test/java/dev/caskeleton/application/notification/NotificationValueContractTest.java @@ -0,0 +1,301 @@ +package dev.caskeleton.application.notification; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.math.BigDecimal; +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Currency; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.Test; + +class NotificationValueContractTest { + + @Test + void identifiersAreNonBlankBoundedAndRejectControlCharacters() { + assertThatThrownBy(() -> new NotificationIntentId(" ")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new NotificationDeliveryId("delivery\n1")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new NotificationAttemptId("a".repeat(129))) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new NotificationReceiptEventId("event/1")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new NotificationKindId("PASSWORD_RESET")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new NotificationRouteId("-primary")) + .isInstanceOf(IllegalArgumentException.class); + + assertThat(new NotificationIntentId("01J1234567890ABCDEFGHJKMNP").value()) + .isEqualTo("01J1234567890ABCDEFGHJKMNP"); + } + + @Test + void recipientReferencesAreChannelTypedAndRedacted() { + NotificationRecipientReference email = new EmailRecipientReference("recipient-email-ref-42"); + NotificationRecipientReference slack = + new SlackAudienceReference("workspace-binding-1", "audience-ref-42"); + + assertThat(email.channel()).isEqualTo(NotificationChannel.EMAIL); + assertThat(slack.channel()).isEqualTo(NotificationChannel.SLACK); + assertThat(email.toString()).doesNotContain("recipient-email-ref-42"); + assertThat(slack.toString()) + .doesNotContain("workspace-binding-1") + .doesNotContain("audience-ref-42"); + } + + @Test + void templateParametersAcceptOnlyTheClosedScalarSetAndAreImmutableAndRedacted() { + Map values = new LinkedHashMap<>(); + values.put("displayName", new NotificationTemplateValue.SafeText("Ada Lovelace")); + values.put( + "resetLink", + new NotificationTemplateValue.TrustedAbsoluteLinkReference("reset-link-ref-42")); + values.put( + "businessDate", + new NotificationTemplateValue.LocalDateValue(LocalDate.parse("2026-07-28"))); + values.put( + "expiresAt", + new NotificationTemplateValue.LocalDateTimeValue( + LocalDateTime.parse("2026-07-28T12:00:00"), ZoneId.of("Asia/Seoul"))); + values.put("attempts", new NotificationTemplateValue.IntegerValue(3)); + values.put( + "amount", + new NotificationTemplateValue.MoneyValue( + new BigDecimal("12500.00"), Currency.getInstance("KRW"))); + + NotificationTemplateParameters parameters = new NotificationTemplateParameters(values); + values.clear(); + + assertThat(parameters.values()).hasSize(6); + assertThatThrownBy( + () -> parameters.values().put("later", new NotificationTemplateValue.IntegerValue(1))) + .isInstanceOf(UnsupportedOperationException.class); + assertThat(parameters.toString()) + .doesNotContain("Ada Lovelace") + .doesNotContain("reset-link-ref-42") + .doesNotContain("12500.00"); + } + + @Test + void templateParametersRejectUnknownShapesAndUnboundedCollectionsByType() { + assertThat( + NotificationTemplateParameters.class + .getRecordComponents()[0] + .getGenericType() + .getTypeName()) + .isEqualTo( + "java.util.Map"); + assertThatThrownBy( + () -> + new NotificationTemplateParameters( + Map.of("bad key", new NotificationTemplateValue.IntegerValue(1)))) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy( + () -> + new NotificationTemplateParameters( + java.util.stream.IntStream.range(0, 33) + .boxed() + .collect( + java.util.stream.Collectors.toMap( + index -> "parameter" + index, + index -> new NotificationTemplateValue.IntegerValue(index))))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void draftHasExactlyOneTypedRecipientAndBoundedLocaleAndTimeWindow() { + NotificationKindPolicy policy = durablePolicy(Duration.ofHours(24)); + Instant notBefore = Instant.parse("2026-07-28T00:00:00Z"); + + NotificationIntentDraft draft = + new NotificationIntentDraft( + new NotificationIntentId("intent-42"), + policy, + Locale.forLanguageTag("ko-KR"), + new EmailRecipientReference("recipient-ref-42"), + new NotificationTemplateParameters( + Map.of("displayName", new NotificationTemplateValue.SafeText("Ada"))), + "password-reset", + "source-operation-42", + Optional.of("tenant-42"), + "correlation-42", + Optional.of("causation-42"), + notBefore, + notBefore.plus(Duration.ofHours(2))); + + assertThat(draft.recipient()).isInstanceOf(EmailRecipientReference.class); + assertThat(draft.policy().mode()).isEqualTo(NotificationMode.DURABLE_ASYNC); + assertThat(draft.toString()) + .doesNotContain("recipient-ref-42") + .doesNotContain("Ada") + .doesNotContain("tenant-42"); + + assertThatThrownBy( + () -> + new NotificationIntentDraft( + draft.intentId(), + policy, + Locale.ROOT, + draft.recipient(), + draft.parameters(), + draft.idempotencyScope(), + draft.sourceOperationId(), + draft.tenantReference(), + draft.correlationReference(), + draft.causationReference(), + notBefore, + notBefore.plusSeconds(1))) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy( + () -> + new NotificationIntentDraft( + draft.intentId(), + policy, + draft.requestedLocale(), + draft.recipient(), + draft.parameters(), + draft.idempotencyScope(), + draft.sourceOperationId(), + draft.tenantReference(), + draft.correlationReference(), + draft.causationReference(), + notBefore, + notBefore.plus(Duration.ofHours(25)))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void frozenPlanCopiesOnlyApplicationOwnedValuesAndPinsSelectedLocale() { + NotificationKindPolicy policy = durablePolicy(Duration.ofHours(24)); + Instant notBefore = Instant.parse("2026-07-28T00:00:00Z"); + NotificationIntentDraft draft = + new NotificationIntentDraft( + new NotificationIntentId("intent-42"), + policy, + Locale.forLanguageTag("ko-KR"), + new EmailRecipientReference("recipient-ref-42"), + new NotificationTemplateParameters( + Map.of("displayName", new NotificationTemplateValue.SafeText("Ada"))), + "password-reset", + "source-operation-42", + Optional.empty(), + "correlation-42", + Optional.empty(), + notBefore, + notBefore.plusSeconds(60)); + + NotificationFrozenPlan plan = + NotificationFrozenPlan.from( + draft, + Locale.forLanguageTag("en"), + NotificationTestFixtures.binding(draft.policy().channel())); + + assertThat(plan.selectedLocale()).isEqualTo(Locale.ENGLISH); + assertThat(plan.mode()).isEqualTo(NotificationMode.DURABLE_ASYNC); + assertThat(plan.binding().targets()).isUnmodifiable(); + assertThat(plan.routeId()).isEqualTo(new NotificationRouteId("email-primary")); + assertThat(plan.toString()).doesNotContain("recipient-ref-42").doesNotContain("Ada"); + } + + @Test + void publicFrozenPlanConstructorPreservesDraftContextAndRetryHorizonInvariants() { + NotificationKindPolicy policy = durablePolicy(Duration.ofHours(1)); + Instant notBefore = Instant.parse("2026-07-28T00:00:00Z"); + NotificationFrozenPlan valid = + new NotificationFrozenPlan( + new NotificationIntentId("intent-42"), + policy, + Locale.ENGLISH, + NotificationTestFixtures.binding(policy.channel()), + new EmailRecipientReference("recipient-ref-42"), + new NotificationTemplateParameters( + Map.of("displayName", new NotificationTemplateValue.SafeText("Ada"))), + "password-reset", + "source-operation-42", + Optional.empty(), + "correlation-42", + Optional.empty(), + notBefore, + notBefore.plusSeconds(60)); + + assertThatThrownBy( + () -> + new NotificationFrozenPlan( + valid.intentId(), + policy, + valid.selectedLocale(), + valid.binding(), + valid.recipient(), + valid.parameters(), + valid.idempotencyScope(), + valid.sourceOperationId(), + Optional.of(" "), + valid.correlationReference(), + valid.causationReference(), + notBefore, + notBefore.plusSeconds(60))) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy( + () -> + new NotificationFrozenPlan( + valid.intentId(), + policy, + valid.selectedLocale(), + valid.binding(), + valid.recipient(), + valid.parameters(), + valid.idempotencyScope(), + valid.sourceOperationId(), + valid.tenantReference(), + valid.correlationReference(), + Optional.of("\n"), + notBefore, + notBefore.plusSeconds(60))) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy( + () -> + new NotificationFrozenPlan( + valid.intentId(), + policy, + valid.selectedLocale(), + valid.binding(), + valid.recipient(), + valid.parameters(), + valid.idempotencyScope(), + valid.sourceOperationId(), + valid.tenantReference(), + valid.correlationReference(), + valid.causationReference(), + notBefore, + notBefore.plus(Duration.ofHours(2)))) + .isInstanceOf(IllegalArgumentException.class); + } + + private static NotificationKindPolicy durablePolicy(Duration retryHorizon) { + return new NotificationKindPolicy( + new NotificationKindId("password-reset"), + NotificationChannel.EMAIL, + new NotificationRouteId("email-primary"), + new NotificationTemplateRef("password-reset", 3), + NotificationMode.DURABLE_ASYNC, + NotificationAdmissionClass.SECURITY_CRITICAL, + NotificationRouteStrategy.SINGLE, + ConsentCheckMode.SNAPSHOT_AT_APPEND, + 7, + 1, + 3, + 0, + 1, + 4, + retryHorizon); + } +} diff --git a/src/application-core/src/test/java/dev/caskeleton/application/notification/ReconcileNotificationDeliveriesUseCaseTest.java b/src/application-core/src/test/java/dev/caskeleton/application/notification/ReconcileNotificationDeliveriesUseCaseTest.java new file mode 100644 index 00000000..64b8ef40 --- /dev/null +++ b/src/application-core/src/test/java/dev/caskeleton/application/notification/ReconcileNotificationDeliveriesUseCaseTest.java @@ -0,0 +1,174 @@ +package dev.caskeleton.application.notification; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.application.transaction.TransactionPort; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Supplier; +import org.junit.jupiter.api.Test; + +class ReconcileNotificationDeliveriesUseCaseTest { + + private static final Instant NOW = Instant.parse("2026-07-28T00:00:00Z"); + + @Test + void claimThenProviderThenTokenGuardedFinalizeAndOrphanAttachRespectBoundaries() { + List trace = new ArrayList<>(); + TrackingTransactions transactions = new TrackingTransactions(trace); + RecordingStore store = new RecordingStore(trace); + NotificationReconciliationPort provider = + claim -> { + assertThat(transactions.active).isFalse(); + trace.add("provider"); + return new NotificationReconciliationPort.ReconciliationOutcome( + SubmissionCertainty.PROVIDER_ACCEPTED, + new NotificationReasonCode("RECONCILED_ACCEPTED")); + }; + ReconcileNotificationDeliveriesUseCase useCase = + new ReconcileNotificationDeliveriesUseCase( + store, provider, transactions, Clock.fixed(NOW, ZoneOffset.UTC)); + + ReconcileNotificationDeliveriesResult result = + useCase.handle(new ReconcileNotificationDeliveriesCommand(10, 10)); + + assertThat(trace) + .containsExactly( + "tx-begin", + "attach-orphans", + "tx-end", + "tx-begin", + "claim", + "tx-end", + "provider", + "tx-begin", + "finalize", + "tx-end"); + assertThat(result).isEqualTo(new ReconcileNotificationDeliveriesResult(1, 1, 1, 1)); + } + + @Test + void orphanOnlyCyclePerformsNoProviderIo() { + TrackingTransactions transactions = new TrackingTransactions(new ArrayList<>()); + RecordingStore store = new RecordingStore(new ArrayList<>()); + store.claims = List.of(); + final int[] providerCalls = {0}; + + ReconcileNotificationDeliveriesResult result = + new ReconcileNotificationDeliveriesUseCase( + store, + claim -> { + providerCalls[0]++; + throw new AssertionError("provider must not run for orphan-only cycle"); + }, + transactions, + Clock.fixed(NOW, ZoneOffset.UTC)) + .handle(new ReconcileNotificationDeliveriesCommand(0, 10)); + + assertThat(providerCalls[0]).isZero(); + assertThat(result.orphanAttachedCount()).isEqualTo(1); + } + + private static final class RecordingStore implements NotificationDeliveryStorePort { + + private final List trace; + private List claims = + List.of( + new ReconciliationClaim( + new NotificationDeliveryId("delivery-42"), + "reconcile-token-42", + 3, + new NotificationRouteId("security-slack"), + 3, + "a".repeat(64), + 0, + "slack-primary", + "slack-web-api-durable-single-local-v1", + "slack-binding-r1", + "credential-r1", + "provider-message-42", + NotificationDeliveryStorePort.ReconciliationLookupKind.MESSAGE_REFERENCE, + NOW.plusSeconds(30))); + + private RecordingStore(List trace) { + this.trace = trace; + } + + @Override + public List claimEligible(int maximumClaims, Instant now) { + return List.of(); + } + + @Override + public AttemptAuthorization reserveAndAuthorize(ClaimedDelivery claimed, Instant now) { + throw new UnsupportedOperationException(); + } + + @Override + public FinalizationResult finalizeAttempt( + AuthorizedAttempt attempt, AttemptFinalization finalization, Instant now) { + throw new UnsupportedOperationException(); + } + + @Override + public List claimForReconciliation(int maximumClaims, Instant now) { + trace.add("claim"); + return claims; + } + + @Override + public ReconciliationFinalizationResult finalizeReconciliation( + ReconciliationClaim claim, + NotificationReconciliationPort.ReconciliationOutcome outcome, + Instant now) { + trace.add("finalize"); + return ReconciliationFinalizationResult.APPLIED; + } + + @Override + public int attachOrphanReceipts(int maximumAttachments, Instant now) { + trace.add("attach-orphans"); + return maximumAttachments == 0 ? 0 : 1; + } + } + + private static final class TrackingTransactions implements TransactionPort { + + private final List trace; + private boolean active; + + private TrackingTransactions(List trace) { + this.trace = trace; + } + + @Override + public T inWrite(Supplier action) { + trace.add("tx-begin"); + active = true; + try { + return action.get(); + } finally { + active = false; + trace.add("tx-end"); + } + } + + @Override + public T inRootWrite(Supplier action) { + return inWrite(action); + } + + @Override + public T inRead(Supplier action) { + return action.get(); + } + + @Override + public T inNew(Supplier action) { + return action.get(); + } + } +} diff --git a/src/application-core/src/test/java/dev/caskeleton/application/notification/RecordNotificationWriterQuiescenceAttestationUseCaseTest.java b/src/application-core/src/test/java/dev/caskeleton/application/notification/RecordNotificationWriterQuiescenceAttestationUseCaseTest.java new file mode 100644 index 00000000..b8546c46 --- /dev/null +++ b/src/application-core/src/test/java/dev/caskeleton/application/notification/RecordNotificationWriterQuiescenceAttestationUseCaseTest.java @@ -0,0 +1,216 @@ +package dev.caskeleton.application.notification; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; + +class RecordNotificationWriterQuiescenceAttestationUseCaseTest { + + private static final Instant NOW = Instant.parse("2026-07-28T00:00:00Z"); + + @Test + void signedExactQuiescenceEvidenceIsVerifiedThenRootCommitted() { + NotificationWriterRouteSet routeSet = InitializeNotificationWriterFencesUseCaseTest.routeSet(); + NotificationCanonicalWriterRouteSet.RouteRevision route = + routeSet.canonicalRoutes().routes().getFirst(); + InitializeNotificationWriterFencesUseCaseTest.TrackingRootTransactions transactions = + new InitializeNotificationWriterFencesUseCaseTest.TrackingRootTransactions(); + SignedNotificationWriterQuiescenceManifest manifest = quiescenceManifest(route); + NotificationWriterQuiescenceAttestationPort verifier = + (signed, expectedRoute, expectedGeneration, trustedRoute, verifiedAt) -> { + assertThat(transactions.active).isFalse(); + assertThat(signed).isSameAs(manifest); + assertThat(trustedRoute.proofRequirement()) + .isEqualTo(NotificationWriterRouteSet.ProofClass.QUIESCENCE_REQUIRED); + return new NotificationWriterQuiescenceAttestationPort.VerifiedQuiescenceEvidence( + expectedRoute, + expectedGeneration, + signed.header().childSetDigest(), + signed.header().childCount(), + verifiedAt); + }; + RecordNotificationWriterQuiescenceAttestationOperation operation = + (command, evidence, trustedRoute, now) -> { + assertThat(transactions.active).isTrue(); + return new RecordNotificationWriterQuiescenceAttestationResult( + RecordNotificationWriterQuiescenceAttestationResult.Status.RECORDED, + command.operationToken(), + evidence.route(), + evidence.generation(), + evidence.childSetDigest()); + }; + RecordNotificationWriterQuiescenceAttestationUseCase useCase = + new RecordNotificationWriterQuiescenceAttestationUseCase( + routeSet, verifier, operation, transactions, Clock.fixed(NOW, ZoneOffset.UTC)); + + RecordNotificationWriterQuiescenceAttestationResult result = + useCase.handle( + new RecordNotificationWriterQuiescenceAttestationCommand( + "attestation-operation-42", + manifest, + "operator-42", + new NotificationReasonCode("QUIESCENCE_REVIEWED"))); + + assertThat(transactions.rootCalls).isEqualTo(1); + assertThat(result.status()) + .isEqualTo(RecordNotificationWriterQuiescenceAttestationResult.Status.RECORDED); + } + + @Test + void invalidSignatureOrHardBoundRouteFailsBeforePersistence() { + NotificationWriterRouteSet routeSet = InitializeNotificationWriterFencesUseCaseTest.routeSet(); + NotificationCanonicalWriterRouteSet.RouteRevision route = + routeSet.canonicalRoutes().routes().getFirst(); + InitializeNotificationWriterFencesUseCaseTest.TrackingRootTransactions transactions = + new InitializeNotificationWriterFencesUseCaseTest.TrackingRootTransactions(); + AtomicInteger operations = new AtomicInteger(); + RecordNotificationWriterQuiescenceAttestationUseCase invalidVerifierUseCase = + new RecordNotificationWriterQuiescenceAttestationUseCase( + routeSet, + (signed, expectedRoute, expectedGeneration, trustedRoute, verifiedAt) -> { + throw new NotificationApplicationException( + new NotificationReasonCode("EVIDENCE_SIGNATURE_INVALID"), null); + }, + (command, evidence, trustedRoute, now) -> { + operations.incrementAndGet(); + throw new AssertionError("operation must not run"); + }, + transactions, + Clock.fixed(NOW, ZoneOffset.UTC)); + + assertThatThrownBy( + () -> + invalidVerifierUseCase.handle( + new RecordNotificationWriterQuiescenceAttestationCommand( + "attestation-operation-42", + quiescenceManifest(route), + "operator-42", + new NotificationReasonCode("QUIESCENCE_REVIEWED")))) + .isInstanceOf(NotificationApplicationException.class); + + NotificationCanonicalWriterRouteSet.RouteRevision hardBoundRoute = + routeSet.canonicalRoutes().routes().get(1); + assertThatThrownBy( + () -> + invalidVerifierUseCase.handle( + new RecordNotificationWriterQuiescenceAttestationCommand( + "attestation-operation-43", + quiescenceManifest(hardBoundRoute), + "operator-42", + new NotificationReasonCode("QUIESCENCE_REVIEWED")))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("forbids"); + assertThat(operations).hasValue(0); + assertThat(transactions.rootCalls).isZero(); + } + + @Test + void transportProfilesMustExactlyMatchTrustedCurrentAndRetiringRegistry() { + NotificationWriterRouteSet routeSet = InitializeNotificationWriterFencesUseCaseTest.routeSet(); + NotificationCanonicalWriterRouteSet.RouteRevision route = + routeSet.canonicalRoutes().routes().getFirst(); + SignedNotificationWriterQuiescenceManifest valid = quiescenceManifest(route); + SignedNotificationWriterQuiescenceManifest drifted = + new SignedNotificationWriterQuiescenceManifest( + valid.header(), + valid.route(), + valid.drainingGeneration(), + Set.of("untrusted-profile"), + valid.nodes(), + valid.blockingPermitCount(), + valid.blockingPermitSetDigest(), + valid.permitHolderIds(), + valid.productionConsumerCount(), + valid.providerCallOpenCount()); + AtomicInteger verifierCalls = new AtomicInteger(); + RecordNotificationWriterQuiescenceAttestationUseCase useCase = + new RecordNotificationWriterQuiescenceAttestationUseCase( + routeSet, + (signed, expectedRoute, expectedGeneration, trustedRoute, verifiedAt) -> { + verifierCalls.incrementAndGet(); + throw new AssertionError("verifier must not run"); + }, + (command, evidence, trustedRoute, now) -> { + throw new AssertionError("operation must not run"); + }, + new InitializeNotificationWriterFencesUseCaseTest.TrackingRootTransactions(), + Clock.fixed(NOW, ZoneOffset.UTC)); + + assertThatThrownBy( + () -> + useCase.handle( + new RecordNotificationWriterQuiescenceAttestationCommand( + "attestation-operation-44", + drifted, + "operator-42", + new NotificationReasonCode("QUIESCENCE_REVIEWED")))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("exactly match"); + assertThat(verifierCalls).hasValue(0); + } + + static SignedNotificationWriterQuiescenceManifest quiescenceManifest( + NotificationCanonicalWriterRouteSet.RouteRevision route) { + NotificationSignedEvidenceHeader header = evidenceHeader(2, "b".repeat(64)); + return new SignedNotificationWriterQuiescenceManifest( + header, + route, + route.predecessorGeneration(), + Set.of("legacy-http-v1"), + List.of( + new SignedNotificationWriterQuiescenceManifest.NodeQuiescence( + "node-42", true, true, true, true, true)), + 0, + "c".repeat(64), + Set.of(), + 0, + 0); + } + + static SignedNotificationWriterInventoryManifest inventoryManifest( + NotificationCanonicalWriterRouteSet.RouteRevision route) { + return new SignedNotificationWriterInventoryManifest( + evidenceHeader(1, "d".repeat(64)), + route, + route.predecessorGeneration(), + List.of( + new SignedNotificationWriterInventoryManifest.NodeInventory( + "node-42", "legacy-artifact-r1"))); + } + + private static NotificationSignedEvidenceHeader evidenceHeader( + int childCount, String childDigest) { + return new NotificationSignedEvidenceHeader( + "notification-evidence-v1", + "canonical-payload".getBytes(java.nio.charset.StandardCharsets.UTF_8), + new byte[64], + "Ed25519", + "issuer-key-42", + new byte[44], + "e".repeat(64), + new NotificationEvidenceTrustSnapshot( + "trust-r1", + NotificationEvidenceTrustSnapshot.HistoricalKeyStatus.ALLOWED, + "e".repeat(64)), + NOW.minusSeconds(30), + NOW.plusSeconds(300), + Duration.ofSeconds(10), + Duration.ofSeconds(5), + "production", + "notification-db", + "legacy-artifact-r1", + "production-consumers-r1", + "provider-call-ledger-r1", + "provider-call-ledger-snapshot-42", + childCount, + childDigest); + } +} diff --git a/src/application-core/src/test/java/dev/caskeleton/application/notification/SwitchNotificationWriterOwnershipUseCaseTest.java b/src/application-core/src/test/java/dev/caskeleton/application/notification/SwitchNotificationWriterOwnershipUseCaseTest.java new file mode 100644 index 00000000..db6f1cbe --- /dev/null +++ b/src/application-core/src/test/java/dev/caskeleton/application/notification/SwitchNotificationWriterOwnershipUseCaseTest.java @@ -0,0 +1,173 @@ +package dev.caskeleton.application.notification; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Arrays; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; + +class SwitchNotificationWriterOwnershipUseCaseTest { + + private static final Instant NOW = Instant.parse("2026-07-28T00:00:00Z"); + + @Test + void beginDrainVerifiesSignedInventoryThenRootCommitsClosedTransition() { + NotificationWriterRouteSet routeSet = InitializeNotificationWriterFencesUseCaseTest.routeSet(); + NotificationCanonicalWriterRouteSet.RouteRevision route = + routeSet.canonicalRoutes().routes().getFirst(); + SignedNotificationWriterInventoryManifest manifest = + RecordNotificationWriterQuiescenceAttestationUseCaseTest.inventoryManifest(route); + InitializeNotificationWriterFencesUseCaseTest.TrackingRootTransactions transactions = + new InitializeNotificationWriterFencesUseCaseTest.TrackingRootTransactions(); + NotificationWriterInventoryEvidenceVerifierPort verifier = + (signed, expectedRoute, expectedGeneration, verifiedAt) -> + new NotificationWriterInventoryEvidence( + expectedRoute, + expectedGeneration, + signed.nodeIds(), + signed.header().childSetDigest(), + verifiedAt); + SwitchNotificationWriterOwnershipOperation operation = + (command, inventoryEvidence, trustedRoute, now) -> { + assertThat(transactions.active).isTrue(); + assertThat(inventoryEvidence).isPresent(); + return SwitchNotificationWriterOwnershipResult.applied( + command.action(), + command.route(), + NotificationWriterOwnership.LEGACY, + command.expectedGeneration(), + command.operationToken()); + }; + SwitchNotificationWriterOwnershipUseCase useCase = + new SwitchNotificationWriterOwnershipUseCase( + routeSet, verifier, operation, transactions, Clock.fixed(NOW, ZoneOffset.UTC)); + + SwitchNotificationWriterOwnershipResult result = + useCase.handle( + SwitchNotificationWriterOwnershipCommand.beginDrain( + route, + 7, + "switch-operation-42", + "operator-42", + new NotificationReasonCode("BEGIN_REVIEWED_DRAIN"), + manifest)); + + assertThat(transactions.rootCalls).isEqualTo(1); + assertThat(result.state()) + .isEqualTo(SwitchNotificationWriterOwnershipResult.FenceState.DRAINING); + assertThat(result.owner()).isEqualTo(NotificationWriterOwnership.LEGACY); + assertThat(result.generation()).isEqualTo(7); + } + + @Test + void completeAndAbortDeriveOwnerAndNextGenerationWithoutTargetOwnerInput() { + NotificationWriterRouteSet routeSet = InitializeNotificationWriterFencesUseCaseTest.routeSet(); + NotificationCanonicalWriterRouteSet.RouteRevision quiescenceRoute = + routeSet.canonicalRoutes().routes().getFirst(); + InitializeNotificationWriterFencesUseCaseTest.TrackingRootTransactions transactions = + new InitializeNotificationWriterFencesUseCaseTest.TrackingRootTransactions(); + SwitchNotificationWriterOwnershipUseCase useCase = + new SwitchNotificationWriterOwnershipUseCase( + routeSet, + (signed, expectedRoute, expectedGeneration, verifiedAt) -> { + throw new AssertionError("inventory verifier is only used for BEGIN"); + }, + (command, inventoryEvidence, trustedRoute, now) -> { + NotificationWriterOwnership owner = + command.action() + == SwitchNotificationWriterOwnershipCommand.Action.COMPLETE_SWITCH + ? NotificationWriterOwnership.CANONICAL + : NotificationWriterOwnership.LEGACY; + return SwitchNotificationWriterOwnershipResult.applied( + command.action(), + command.route(), + owner, + command.reviewedTargetGeneration(), + command.operationToken()); + }, + transactions, + Clock.fixed(NOW, ZoneOffset.UTC)); + + SwitchNotificationWriterOwnershipResult complete = + useCase.handle( + SwitchNotificationWriterOwnershipCommand.completeSwitch( + quiescenceRoute, + 7, + 8, + "switch-operation-43", + "operator-42", + new NotificationReasonCode("COMPLETE_REVIEWED_SWITCH"), + Optional.of("attestation-operation-42"))); + SwitchNotificationWriterOwnershipResult abort = + useCase.handle( + SwitchNotificationWriterOwnershipCommand.abortDrain( + quiescenceRoute, + 7, + 8, + "switch-operation-44", + "operator-42", + new NotificationReasonCode("ABORT_REVIEWED_DRAIN"))); + + assertThat(complete.owner()).isEqualTo(NotificationWriterOwnership.CANONICAL); + assertThat(complete.generation()).isEqualTo(8); + assertThat(abort.owner()).isEqualTo(NotificationWriterOwnership.LEGACY); + assertThat(abort.generation()).isEqualTo(8); + assertThat( + Arrays.stream(SwitchNotificationWriterOwnershipCommand.class.getRecordComponents()) + .map(java.lang.reflect.RecordComponent::getName)) + .noneMatch(name -> name.toLowerCase(java.util.Locale.ROOT).contains("owner")); + } + + @Test + void quiescenceRequirementAndClosedGenerationMatrixFailBeforeOperation() { + NotificationWriterRouteSet routeSet = InitializeNotificationWriterFencesUseCaseTest.routeSet(); + NotificationCanonicalWriterRouteSet.RouteRevision route = + routeSet.canonicalRoutes().routes().getFirst(); + AtomicInteger operations = new AtomicInteger(); + InitializeNotificationWriterFencesUseCaseTest.TrackingRootTransactions transactions = + new InitializeNotificationWriterFencesUseCaseTest.TrackingRootTransactions(); + SwitchNotificationWriterOwnershipUseCase useCase = + new SwitchNotificationWriterOwnershipUseCase( + routeSet, + (signed, expectedRoute, expectedGeneration, verifiedAt) -> { + throw new AssertionError("not called"); + }, + (command, inventoryEvidence, trustedRoute, now) -> { + operations.incrementAndGet(); + throw new AssertionError("operation must not run"); + }, + transactions, + Clock.systemUTC()); + + assertThatThrownBy( + () -> + useCase.handle( + SwitchNotificationWriterOwnershipCommand.completeSwitch( + route, + 7, + 8, + "switch-operation-42", + "operator-42", + new NotificationReasonCode("COMPLETE_REVIEWED_SWITCH"), + Optional.empty()))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("attestation"); + assertThatThrownBy( + () -> + SwitchNotificationWriterOwnershipCommand.abortDrain( + route, + 7, + 9, + "switch-operation-42", + "operator-42", + new NotificationReasonCode("ABORT_REVIEWED_DRAIN"))) + .isInstanceOf(IllegalArgumentException.class); + assertThat(operations).hasValue(0); + assertThat(transactions.rootCalls).isZero(); + } +} diff --git a/src/application-core/src/test/java/dev/caskeleton/application/notification/TerminalizeExpiredNotificationWriterPermitsUseCaseTest.java b/src/application-core/src/test/java/dev/caskeleton/application/notification/TerminalizeExpiredNotificationWriterPermitsUseCaseTest.java new file mode 100644 index 00000000..40d919e9 --- /dev/null +++ b/src/application-core/src/test/java/dev/caskeleton/application/notification/TerminalizeExpiredNotificationWriterPermitsUseCaseTest.java @@ -0,0 +1,89 @@ +package dev.caskeleton.application.notification; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; + +class TerminalizeExpiredNotificationWriterPermitsUseCaseTest { + + @Test + void terminalizationIsBoundedAuthenticatedAndRootCommittedAgainstTrustedRegistry() { + NotificationWriterRouteSet routeSet = InitializeNotificationWriterFencesUseCaseTest.routeSet(); + InitializeNotificationWriterFencesUseCaseTest.TrackingRootTransactions transactions = + new InitializeNotificationWriterFencesUseCaseTest.TrackingRootTransactions(); + TerminalizeExpiredNotificationWriterPermitsOperation operation = + (command, trustedRoute, now) -> { + assertThat(transactions.active).isTrue(); + assertThat(trustedRoute.proofRequirement()) + .isEqualTo(NotificationWriterRouteSet.ProofClass.QUIESCENCE_REQUIRED); + return new TerminalizeExpiredNotificationWriterPermitsResult( + TerminalizeExpiredNotificationWriterPermitsResult.Status.APPLIED, 2, "a".repeat(64)); + }; + TerminalizeExpiredNotificationWriterPermitsUseCase useCase = + new TerminalizeExpiredNotificationWriterPermitsUseCase( + routeSet, + operation, + transactions, + Clock.fixed(Instant.parse("2026-07-28T00:00:00Z"), ZoneOffset.UTC)); + + TerminalizeExpiredNotificationWriterPermitsResult result = + useCase.handle( + new TerminalizeExpiredNotificationWriterPermitsCommand( + routeSet.canonicalRoutes().routes().getFirst(), + 7, + 100, + "terminalize-operation-42", + "operator-42", + new NotificationReasonCode("TERMINALIZE_EXPIRED_PERMITS"))); + + assertThat(transactions.rootCalls).isEqualTo(1); + assertThat(result.affectedCount()).isEqualTo(2); + } + + @Test + void batchAboveHundredAndUnknownRouteFailWithoutMutation() { + NotificationWriterRouteSet routeSet = InitializeNotificationWriterFencesUseCaseTest.routeSet(); + InitializeNotificationWriterFencesUseCaseTest.TrackingRootTransactions transactions = + new InitializeNotificationWriterFencesUseCaseTest.TrackingRootTransactions(); + AtomicInteger calls = new AtomicInteger(); + TerminalizeExpiredNotificationWriterPermitsUseCase useCase = + new TerminalizeExpiredNotificationWriterPermitsUseCase( + routeSet, + (command, trustedRoute, now) -> { + calls.incrementAndGet(); + throw new AssertionError("operation must not run"); + }, + transactions, + Clock.systemUTC()); + + assertThatThrownBy( + () -> + new TerminalizeExpiredNotificationWriterPermitsCommand( + routeSet.canonicalRoutes().routes().getFirst(), + 7, + 101, + "terminalize-operation-42", + "operator-42", + new NotificationReasonCode("TERMINALIZE_EXPIRED_PERMITS"))) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy( + () -> + useCase.handle( + new TerminalizeExpiredNotificationWriterPermitsCommand( + new NotificationCanonicalWriterRouteSet.RouteRevision( + new NotificationRouteId("unknown-route"), 1, 0), + 0, + 1, + "terminalize-operation-42", + "operator-42", + new NotificationReasonCode("TERMINALIZE_EXPIRED_PERMITS")))) + .isInstanceOf(IllegalArgumentException.class); + assertThat(calls).hasValue(0); + assertThat(transactions.rootCalls).isZero(); + } +} diff --git a/src/application-core/src/test/java/dev/caskeleton/application/outbox/PublishPendingOutboxEventsUseCaseTest.java b/src/application-core/src/test/java/dev/caskeleton/application/outbox/PublishPendingOutboxEventsUseCaseTest.java index f7730f9c..abdb1b02 100644 --- a/src/application-core/src/test/java/dev/caskeleton/application/outbox/PublishPendingOutboxEventsUseCaseTest.java +++ b/src/application-core/src/test/java/dev/caskeleton/application/outbox/PublishPendingOutboxEventsUseCaseTest.java @@ -550,6 +550,11 @@ class PublishPendingOutboxEventsUseCaseTest { return action.get(); } + @Override + public T inRootWrite(Supplier action) { + return action.get(); + } + @Override public T inRead(Supplier action) { return action.get(); diff --git a/src/application-core/src/test/java/dev/caskeleton/application/transaction/TransactionPortTest.java b/src/application-core/src/test/java/dev/caskeleton/application/transaction/TransactionPortTest.java index 0281bc95..ba276967 100644 --- a/src/application-core/src/test/java/dev/caskeleton/application/transaction/TransactionPortTest.java +++ b/src/application-core/src/test/java/dev/caskeleton/application/transaction/TransactionPortTest.java @@ -20,6 +20,17 @@ class TransactionPortTest { assertThat(sideEffect).containsExactly("ran"); } + @Test + void runnableInRootWriteDelegatesToSupplierInRootWrite() { + RecordingTransactionPort port = new RecordingTransactionPort(); + List sideEffect = new ArrayList<>(); + + port.inRootWrite(() -> sideEffect.add("ran")); + + assertThat(port.invocations).containsExactly(TransactionMode.WRITE); + assertThat(sideEffect).containsExactly("ran"); + } + @Test void runnableInReadDelegatesToSupplierInRead() { RecordingTransactionPort port = new RecordingTransactionPort(); @@ -52,6 +63,16 @@ class TransactionPortTest { assertThat(port.invocations).containsExactly(TransactionMode.WRITE); } + @Test + void supplierInRootWriteReturnsActionValue() { + RecordingTransactionPort port = new RecordingTransactionPort(); + + String result = port.inRootWrite(() -> "v"); + + assertThat(result).isEqualTo("v"); + assertThat(port.invocations).containsExactly(TransactionMode.WRITE); + } + private static final class RecordingTransactionPort implements TransactionPort { private final List invocations = new ArrayList<>(); @@ -62,6 +83,12 @@ class TransactionPortTest { return action.get(); } + @Override + public T inRootWrite(Supplier action) { + invocations.add(TransactionMode.WRITE); + return action.get(); + } + @Override public T inRead(Supplier action) { invocations.add(TransactionMode.READ_ONLY); diff --git a/src/gradlew.bat b/src/gradlew.bat index e509b2dd..c4bdd3ab 100644 --- a/src/gradlew.bat +++ b/src/gradlew.bat @@ -1,93 +1,93 @@ -@rem -@rem Copyright 2015 the original author or authors. -@rem -@rem Licensed under the Apache License, Version 2.0 (the "License"); -@rem you may not use this file except in compliance with the License. -@rem You may obtain a copy of the License at -@rem -@rem https://www.apache.org/licenses/LICENSE-2.0 -@rem -@rem Unless required by applicable law or agreed to in writing, software -@rem distributed under the License is distributed on an "AS IS" BASIS, -@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -@rem See the License for the specific language governing permissions and -@rem limitations under the License. -@rem -@rem SPDX-License-Identifier: Apache-2.0 -@rem - -@if "%DEBUG%"=="" @echo off -@rem ########################################################################## -@rem -@rem Gradle startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal - -set DIRNAME=%~dp0 -if "%DIRNAME%"=="" set DIRNAME=. -@rem This is normally unused -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@rem Resolve any "." and ".." in APP_HOME to make it shorter. -for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi - -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" - -@rem Find java.exe -if defined JAVA_HOME goto findJavaFromJavaHome - -set JAVA_EXE=java.exe -%JAVA_EXE% -version >NUL 2>&1 -if %ERRORLEVEL% equ 0 goto execute - -echo. 1>&2 -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - -goto fail - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto execute - -echo. 1>&2 -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - -goto fail - -:execute -@rem Setup the command line - - - -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* - -:end -@rem End local scope for the variables with windows NT shell -if %ERRORLEVEL% equ 0 goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -set EXIT_CODE=%ERRORLEVEL% -if %EXIT_CODE% equ 0 set EXIT_CODE=1 -if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% -exit /b %EXIT_CODE% - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/worklog/CreateWorkLogOutboxTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/worklog/CreateWorkLogOutboxTest.java index df0b2dde..8b351137 100644 --- a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/worklog/CreateWorkLogOutboxTest.java +++ b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/worklog/CreateWorkLogOutboxTest.java @@ -106,6 +106,11 @@ class CreateWorkLogOutboxTest { return result; } + @Override + public T inRootWrite(Supplier action) { + return inWrite(action); + } + @Override public T inRead(Supplier action) { return action.get(); @@ -269,6 +274,11 @@ class CreateWorkLogOutboxTest { return a.get(); } + @Override + public T inRootWrite(Supplier a) { + return a.get(); + } + public T inRead(Supplier a) { return a.get(); } diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/worklog/ListRecentWorkLogSummariesUseCaseTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/worklog/ListRecentWorkLogSummariesUseCaseTest.java index cb6a566f..aa20e9eb 100644 --- a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/worklog/ListRecentWorkLogSummariesUseCaseTest.java +++ b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/worklog/ListRecentWorkLogSummariesUseCaseTest.java @@ -23,6 +23,11 @@ class ListRecentWorkLogSummariesUseCaseTest { return action.get(); } + @Override + public T inRootWrite(Supplier action) { + return action.get(); + } + @Override public T inRead(Supplier action) { inReadCalled = true; diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/worklog/WorkLogUseCasesTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/worklog/WorkLogUseCasesTest.java index a132016a..878d327d 100644 --- a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/worklog/WorkLogUseCasesTest.java +++ b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/application/worklog/WorkLogUseCasesTest.java @@ -80,6 +80,11 @@ class WorkLogUseCasesTest { return a.get(); } + @Override + public T inRootWrite(Supplier a) { + return a.get(); + } + public T inRead(Supplier a) { return a.get(); } diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/authz/WorkLogAuthorizationContractTest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/authz/WorkLogAuthorizationContractTest.java index 20b7b6c1..9a515fda 100644 --- a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/authz/WorkLogAuthorizationContractTest.java +++ b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/authz/WorkLogAuthorizationContractTest.java @@ -178,6 +178,11 @@ class WorkLogAuthorizationContractTest { return action.get(); } + @Override + public T inRootWrite(Supplier action) { + return action.get(); + } + @Override public T inRead(Supplier action) { return action.get(); diff --git a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/authz/WorkLogAuthorizationE2ETest.java b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/authz/WorkLogAuthorizationE2ETest.java index e796c752..00d40ba5 100644 --- a/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/authz/WorkLogAuthorizationE2ETest.java +++ b/src/sample-portfolio/src/test/java/dev/caskeleton/sample/portfolio/authz/WorkLogAuthorizationE2ETest.java @@ -156,6 +156,11 @@ class WorkLogAuthorizationE2ETest { return action.get(); } + @Override + public T inRootWrite(Supplier action) { + return action.get(); + } + @Override public T inRead(Supplier action) { return action.get();