diff --git a/docs/superpowers/plans/2026-07-28-notification-production-capability.md b/docs/superpowers/plans/2026-07-28-notification-production-capability.md index 80bb35c..3a7747f 100644 --- a/docs/superpowers/plans/2026-07-28-notification-production-capability.md +++ b/docs/superpowers/plans/2026-07-28-notification-production-capability.md @@ -7,7 +7,13 @@ > RED/GREEN/verification 규칙을 수동으로 명시한다. - 작성일: 2026-07-28 -- 상태: proposed implementation plan, 구현 미착수 +- 상태: Wave A/B 및 Task 8 구현 checkpoint를 main 통합. Task 9 이후는 미착수 +- 원 작업 위치: `codex/notification-production-capability` 격리 worktree +- 통합 위치: `/home/donghyeon/workspace/clean-architecture-backend-template` (`main`) +- 현재 checkpoint: Wave A application/transaction foundation, Wave B catalog/render/provider + fake protocol, Task 8 local crypto primitive 구현. Task 9의 migration/persistence model과 이후 + provider/runtime 범위는 미착수다. 아래 isolated-worktree 검증 기록과 main 통합 검증 결과는 + 서로 다른 증거로 구분한다. - 설계 정본: [Notification Production Capability Deep Design](../specs/2026-07-28-notification-production-capability-design.md) - 비교한 실행 계획: @@ -783,7 +789,7 @@ removing bindings, but these types must remain while compiled consumers exist. ### Wave A exit gate -- [ ] Run: +- [x] Run: ```bash cd src && ./gradlew :application-core:check \ @@ -795,14 +801,11 @@ removing bindings, but these types must remain while compiled consumers exist. --tests '*CleanArchitectureTest' --console=plain ``` - Current evidence: the first command passes. The wildcard architecture command executes - `CleanArchitectureTest` successfully (59/59) but remains blocked by the unrelated - `DisabledAdapterArchitectureTest` Redis optional-bean gating violation. The exact - `dev.caskeleton.bootstrap.architecture.CleanArchitectureTest` command passes. + 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. -- [ ] Request an application/transaction boundary review before Wave B. +- [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. --- @@ -832,9 +835,10 @@ removing bindings, but these types must remain while compiled consumers exist. - `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` -- [ ] RED cases: unknown/blank/duplicate local catalog entry; channel/provider mismatch; +- [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; cyclic binding. + 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`. @@ -858,7 +862,7 @@ removing bindings, but these types must remain while compiled consumers exist. 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. -- [ ] Verify RED: +- [x] Verify RED: ```bash cd src && ./gradlew :adapter:outbound:notification:test \ @@ -870,14 +874,14 @@ removing bindings, but these types must remain while compiled consumers exist. Expected failure: canonical descriptors/compiler do not exist. -- [ ] Implement a pure, deterministic compiler over explicit input; do not inspect Spring beans, +- [x] Implement a pure, deterministic compiler over explicit input; do not inspect Spring beans, application context, persistence schema or inbound adapters. -- [ ] Keep `expected-state`, exact actual/expected binding IDs, application mode/admission matching, +- [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. -- [ ] Register only the three initial card IDs. Legacy descriptors must explicitly advertise R0, +- [x] Register only the three initial card IDs. Legacy descriptors must explicitly advertise R0, no durable/receipt capability. -- [ ] Emit a sorted immutable binding graph and manifest digest; unknown inputs fail closed. +- [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 @@ -891,14 +895,16 @@ removing bindings, but these types must remain while compiled consumers exist. 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. -- [ ] Implement `NotificationPlanPort` by converting the selected adapter-local compiled binding into + 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. -- [ ] Derive the application-owned provider capability descriptor from the actual compiled cards, +- [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. -- [ ] Verify GREEN with the same command. -- [ ] Acceptance claim: local graph compatibility only, not actual composition/readiness. +- [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. @@ -923,24 +929,24 @@ composition succeeds. - `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` -- [ ] RED cases: checksum/revision drift; missing/unknown/unused parameter; exact locale fallback +- [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. -- [ ] Verify RED: +- [x] Verify RED: ```bash cd src && ./gradlew :adapter:outbound:notification:test \ --tests '*NotificationTemplateRendererTest' --console=plain ``` -- [ ] Implement checked-in resource loading by exact manifest/checksum. Keep business-specific +- [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. -- [ ] Produce local email subject/text/HTML and Slack Block Kit through typed builders; never accept +- [x] Produce local email subject/text/HTML and Slack Block Kit through typed builders; never accept caller-supplied arbitrary JSON or provider block objects. -- [ ] Verify GREEN with the same command. -- [ ] Acceptance claim: deterministic local render R1; no provider call. +- [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. @@ -973,11 +979,11 @@ durable append, this task is independently reversible. - `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` -- [ ] RED cases: `prepare` has no I/O; one authorization invokes client exactly once; deadline is +- [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. -- [ ] Verify RED: +- [x] Verify RED: ```bash cd src && ./gradlew :adapter:outbound:notification:test \ @@ -989,40 +995,40 @@ durable append, this task is independently reversible. --console=plain ``` -- [ ] Keep this SPI adapter-internal. Implement the application +- [x] Keep this SPI adapter-internal. Implement the application `NotificationProviderAttemptPort` with compiled binding + renderer + internal client lookup. -- [ ] Keep secret material resolution, control-plane readiness and provider-local rate admission +- [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. -- [ ] Secret acquisition returns a versioned `AutoCloseable` mutable byte/char handle. Acquire it per +- [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. -- [ ] Implement the application-owned `NotificationAdmissionReadinessPort` with the adapter-internal +- [x] Implement the application-owned `NotificationAdmissionReadinessPort` with the adapter-internal readiness probes. Application admission use cases must never import the internal probe type. -- [ ] Freeze the outbound binding matrix: +- [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. -- [ ] Keep attempt correlation, optional provider operation key and post-response message reference +- [x] Keep attempt correlation, optional provider operation key and post-response message reference as distinct types. -- [ ] Verify GREEN with the same command and: +- [x] Verify GREEN with the same command and: ```bash cd src && ./gradlew :adapter:outbound:notification:check --console=plain ``` -- [ ] Acceptance claim: deterministic fake protocol R1, no exact provider card qualification. +- [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 -- [ ] Run: +- [x] Run: ```bash cd src && ./gradlew :application-core:check \ @@ -1031,10 +1037,11 @@ Task 18. --console=plain ``` -- [ ] Verify the notification leaf has no project dependency on persistence, inbound-web or +- [x] Verify the notification leaf has no project dependency on persistence, inbound-web or httpclient. -- [ ] Request catalog/template/provider-SPI review. -- [ ] Update the LLM Wiki branch-note with Wave B evidence and an explicit derived-document decision. +- [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. --- 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 0000000..589a6a1 --- /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 0000000..6a3825e --- /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 0000000..066b7a1 --- /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 0000000..f82c7ee --- /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 0000000..2fd0408 --- /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 0000000..ddcab40 --- /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 0000000..4eefb82 --- /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 0000000..a5f6322 --- /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 0000000..9ebffde --- /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 0000000..da9f2cd --- /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 0000000..e473bfa --- /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 0000000..b8d3e17 --- /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 0000000..3a1929a --- /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 0000000..8426e14 --- /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 0000000..df65ef0 --- /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 0000000..28f03e1 --- /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 0000000..83b67ce --- /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 0000000..75e1fb2 --- /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 0000000..49fbf9f --- /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 0000000..90f5abc --- /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 0000000..3c4eee6 --- /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 0000000..effdc81 --- /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 0000000..a869b59 --- /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 0000000..3b4d8e8 --- /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 0000000..157d1b2 --- /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 0000000..a164147 --- /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 0000000..fdd03f4 --- /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 0000000..4652840 --- /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 0000000..17f3acb --- /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 0000000..0549f40 --- /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 0000000..79524df --- /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 0000000..de1aa1e --- /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 0000000..3e61ef2 --- /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 0000000..5656d46 --- /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 0000000..4e9c525 --- /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 0000000..d0f1992 --- /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 0000000..e162f1a --- /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 0000000..a2bff37 --- /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 0000000..6e46da9 --- /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 0000000..a2558bf --- /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 0000000..0fdad56 --- /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 0000000..d2e8ba7 --- /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 0000000..979fe69 --- /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 0000000..c1000b0 --- /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 0000000..bea116c --- /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 0000000..756df6f --- /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 0000000..ce4b590 --- /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/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 0000000..652d9e5 --- /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 0000000..043c87b --- /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 0000000..126da8b --- /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 0000000..5ed4397 --- /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 0000000..0561a37 --- /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 0000000..6a59506 --- /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/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 0000000..229c47a --- /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 0000000..837f9e2 --- /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/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 43434fe..bbb6a52 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.fixtures.application.RootWriteTransactionBoundaryUseCase; import dev.caskeleton.bootstrap.architecture.violations.adapter.outbound.RawExternalResponseFixture; 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 78dcc3d..fbc00db 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 @@ -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 @@ -1277,7 +1278,7 @@ class CleanArchitectureTest { "..adapter.inbound.web..", "..adapter.outbound.persistence..")) .as( - "B7: externally reachable outbound adapter public methods must return domain types" + "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 @Bean factory methods and @ConfigurationProperties" @@ -1553,6 +1554,17 @@ class CleanArchitectureTest { }; } + 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/violations/adapter/outbound/RawTypeLeakingAdapterFixture.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/adapter/outbound/RawTypeLeakingAdapterFixture.java index d7a3e0f..d22619c 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/adapter/outbound/httpclient/activation/EvilActivationLeak.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/adapter/outbound/httpclient/activation/EvilActivationLeak.java index d1e7fa3..fb65711 100644 --- a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/adapter/outbound/httpclient/activation/EvilActivationLeak.java +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/adapter/outbound/httpclient/activation/EvilActivationLeak.java @@ -1,11 +1,13 @@ package dev.caskeleton.bootstrap.architecture.violations.adapter.outbound.httpclient.activation; import dev.caskeleton.bootstrap.architecture.violations.adapter.outbound.RawExternalResponseFixture; +import dev.caskeleton.bootstrap.architecture.violations.application.RawLeakPortFixture; /** Proves that an activation package name cannot bypass the outbound raw-type leak guard. */ -public class EvilActivationLeak { +public class EvilActivationLeak implements RawLeakPortFixture { - public RawExternalResponseFixture require() { + @Override + public RawExternalResponseFixture leakRaw() { return new RawExternalResponseFixture(); } diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/adapter/outbound/settingsbypass/EvilSettings.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/adapter/outbound/settingsbypass/EvilSettings.java index 2e4624e..6dd7105 100644 --- a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/adapter/outbound/settingsbypass/EvilSettings.java +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/adapter/outbound/settingsbypass/EvilSettings.java @@ -1,11 +1,13 @@ package dev.caskeleton.bootstrap.architecture.violations.adapter.outbound.settingsbypass; import dev.caskeleton.bootstrap.architecture.violations.adapter.outbound.RawExternalResponseFixture; +import dev.caskeleton.bootstrap.architecture.violations.application.RawLeakPortFixture; /** Proves that a Settings suffix cannot bypass the outbound raw-type leak guard. */ -public class EvilSettings { +public class EvilSettings implements RawLeakPortFixture { - public RawExternalResponseFixture leak() { + @Override + public RawExternalResponseFixture leakRaw() { return new RawExternalResponseFixture(); } } 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 0000000..f063428 --- /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 402fb1e..636cbca 100644 --- a/src/application-core/CLAUDE.md +++ b/src/application-core/CLAUDE.md @@ -74,6 +74,29 @@ Package root: `dev.caskeleton.application`. ## 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. + +## 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. 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 index 2302f6b..088c69f 100644 --- 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 @@ -27,6 +27,10 @@ public final class NotificationCapabilityCompatibilityValidator { 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, 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 index 0f24b82..339337b 100644 --- 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 @@ -146,6 +146,20 @@ public interface NotificationDeliveryStorePort { && 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"); + } + } } } @@ -168,19 +182,46 @@ public interface NotificationDeliveryStorePort { NotificationDeliveryId deliveryId, String executionToken, long expectedRowVersion, - String providerMessageReference, + 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) { - throw new IllegalArgumentException("reconciliation row version must be non-negative"); + if (expectedRowVersion < 0 || routeRevision < 1 || targetOrdinal < 0 || targetOrdinal > 15) { + throw new IllegalArgumentException( + "reconciliation row version, route revision and target ordinal are invalid"); } - providerMessageReference = + 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( - "provider message reference", providerMessageReference); + "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"); } @@ -189,12 +230,34 @@ public interface NotificationDeliveryStorePort { return "ReconciliationClaim[deliveryId=, executionToken=, " + "expectedRowVersion=" + expectedRowVersion - + ", providerMessageReference=, absoluteDeadline=" + + ", 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, 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 index cfac1be..b9d225f 100644 --- 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 @@ -134,7 +134,7 @@ public final class NotificationDispatchUseCase NotificationDeliveryStorePort.AttemptFinalization finalization = new NotificationDeliveryStorePort.AttemptFinalization( - outcome, terminalState(outcome), fallbackEligible(outcome), parkResult); + outcome, terminalState(outcome, parkResult), fallbackEligible(outcome), parkResult); NotificationDeliveryStorePort.FinalizationResult result = Objects.requireNonNull( store.finalizeAttempt(attempt, finalization, clock.instant()), @@ -143,7 +143,7 @@ public final class NotificationDispatchUseCase } private static NotificationDeliveryStorePort.TerminalState terminalState( - ProviderAttemptOutcome outcome) { + ProviderAttemptOutcome outcome, NotificationAdmissionReadinessPort.ParkResult parkResult) { if (outcome.submissionCertainty() == SubmissionCertainty.PROVIDER_ACCEPTED) { return NotificationDeliveryStorePort.TerminalState.ACCEPTED; } @@ -152,7 +152,15 @@ public final class NotificationDispatchUseCase } return switch (outcome.retryDisposition()) { case RETRY_AT -> NotificationDeliveryStorePort.TerminalState.RETRY_SCHEDULED; - case PARK_BINDING -> NotificationDeliveryStorePort.TerminalState.PARKED_BINDING; + 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( 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 index 6745e9c..ac26319 100644 --- 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 @@ -1,6 +1,10 @@ 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; @@ -10,6 +14,7 @@ public record NotificationFrozenPlan( NotificationIntentId intentId, NotificationKindPolicy policy, Locale selectedLocale, + BindingSnapshot binding, NotificationRecipientReference recipient, NotificationTemplateParameters parameters, String idempotencyScope, @@ -24,33 +29,44 @@ public record 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); - Objects.requireNonNull(tenantReference, "tenant reference container must be non-null"); + tenantReference = requireOptionalOpaque("tenant reference", tenantReference); correlationReference = NotificationIntentId.requireOpaque( "notification correlation reference", correlationReference); - Objects.requireNonNull(causationReference, "causation reference container must be non-null"); + 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 (!expiresAt.isAfter(notBefore)) { - throw new IllegalArgumentException("notification expiry must be after not-before"); + 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) { + 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(), @@ -70,6 +86,11 @@ public record NotificationFrozenPlan( 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=" @@ -80,10 +101,116 @@ public record NotificationFrozenPlan( + 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/NotificationMaintenanceCommand.java b/src/application-core/src/main/java/dev/caskeleton/application/notification/NotificationMaintenanceCommand.java index cb109f0..cc97996 100644 --- 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 @@ -8,11 +8,12 @@ public record NotificationMaintenanceCommand( implements Command { public NotificationMaintenanceCommand { + long total = (long) maximumExpiredIntents + maximumPayloadRedactions + maximumExpiredReceipts; if (maximumExpiredIntents < 0 || maximumPayloadRedactions < 0 || maximumExpiredReceipts < 0 - || maximumExpiredIntents + maximumPayloadRedactions + maximumExpiredReceipts < 1 - || maximumExpiredIntents + maximumPayloadRedactions + maximumExpiredReceipts > 100) { + || 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 index 6e421b6..13edab5 100644 --- 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 @@ -5,7 +5,7 @@ public record NotificationMaintenanceResult( int expiredIntentCount, int redactedPayloadCount, int expiredReceiptCount) { public NotificationMaintenanceResult { - int total = expiredIntentCount + redactedPayloadCount + expiredReceiptCount; + long total = (long) expiredIntentCount + redactedPayloadCount + expiredReceiptCount; if (expiredIntentCount < 0 || redactedPayloadCount < 0 || expiredReceiptCount < 0 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 index 738ca31..5379667 100644 --- 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 @@ -11,7 +11,7 @@ public interface NotificationMaintenanceStorePort { record MutationResult(int expiredIntentCount, int redactedPayloadCount, int expiredReceiptCount) { public MutationResult { - int total = expiredIntentCount + redactedPayloadCount + expiredReceiptCount; + long total = (long) expiredIntentCount + redactedPayloadCount + expiredReceiptCount; if (expiredIntentCount < 0 || redactedPayloadCount < 0 || expiredReceiptCount < 0 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 index 2952133..3d4f8ad 100644 --- 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 @@ -88,6 +88,29 @@ class NotificationCapabilityCompatibilityValidatorTest { .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"), 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 index cb1f839..b5f9b91 100644 --- 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 @@ -151,6 +151,34 @@ class NotificationDispatchUseCaseTest { 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 = @@ -251,7 +279,10 @@ class NotificationDispatchUseCaseTest { Optional.empty(), NOW, NOW.plusSeconds(600)); - return NotificationFrozenPlan.from(draft, java.util.Locale.ENGLISH); + return NotificationFrozenPlan.from( + draft, + java.util.Locale.ENGLISH, + NotificationTestFixtures.binding(draft.policy().channel())); } private static ProviderAttemptOutcome accepted() { 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 index 2c81b48..f57e571 100644 --- 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 @@ -33,6 +33,17 @@ class NotificationMaintenanceUseCaseTest { 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 { 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 index 6be96c2..970c719 100644 --- 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 @@ -26,7 +26,10 @@ class NotificationPlanningBoundaryTest { NotificationPlanPort planner = requested -> new NotificationPlanningResult.Planned( - NotificationFrozenPlan.from(requested, Locale.ENGLISH)); + NotificationFrozenPlan.from( + requested, + Locale.ENGLISH, + NotificationTestFixtures.binding(requested.policy().channel()))); NotificationPlanningResult result = planner.plan(draft); @@ -46,7 +49,9 @@ class NotificationPlanningBoundaryTest { "recipient-ref-42", "source-operation-42", Instant.parse("2026-07-28T00:00:00Z")); - NotificationFrozenPlan plan = NotificationFrozenPlan.from(draft, Locale.ENGLISH); + NotificationFrozenPlan plan = + NotificationFrozenPlan.from( + draft, Locale.ENGLISH, NotificationTestFixtures.binding(draft.policy().channel())); AtomicReference appended = new AtomicReference<>(); AtomicReference attempted = new AtomicReference<>(); NotificationIntentAppendPort appendPort = 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 0000000..f0ba8c1 --- /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 index f933911..4625f62 100644 --- 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 @@ -193,14 +193,93 @@ class NotificationValueContractTest { notBefore, notBefore.plusSeconds(60)); - NotificationFrozenPlan plan = NotificationFrozenPlan.from(draft, Locale.forLanguageTag("en")); + 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"), 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 index 3a20f38..64b8ef4 100644 --- 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 @@ -81,7 +81,16 @@ class ReconcileNotificationDeliveriesUseCaseTest { 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) {