diff --git a/.github/scripts/verify-gradle-wrapper.sh b/.github/scripts/verify-gradle-wrapper.sh
index 8dd8fe7e..f284b085 100755
--- a/.github/scripts/verify-gradle-wrapper.sh
+++ b/.github/scripts/verify-gradle-wrapper.sh
@@ -26,6 +26,7 @@ readonly EXPECTED_WORKFLOW_LOCK=(
'ad84000efc438ee7439517b8f85819e62b13dab0aa4f94066c2905060f3bb581 .github/workflows/httpclient-release.yml'
'59cb3a0ffc687a15eefe96bc5e3a70d42be78e1cc85d2e7f7880dac6124ca4c7 .github/workflows/jpa-r2-evidence.yml'
'5be7e931db749029d89787da042d6d7cf8e683d60698bd8a2993c29db26355fb .github/workflows/link-check.yml'
+ '3d5afcef6bf1c65dcd8cad3d1687f07c2cfbb15d360f41251e46f9eb8950baac .github/workflows/notification-platform.yml'
'64245586cd5936f1a5647b57f2cd9acd316f96fd75f713b1890decb812e7d5fe .github/workflows/object-storage-qualification.yml'
'cbc104ea486c746229895e804e3be7716e056a02cce0588c537bce9f442f8b38 .github/workflows/redis-sdk-topology.yml'
)
diff --git a/.github/workflows/notification-platform.yml b/.github/workflows/notification-platform.yml
new file mode 100644
index 00000000..ca40fc3f
--- /dev/null
+++ b/.github/workflows/notification-platform.yml
@@ -0,0 +1,121 @@
+name: notification-platform
+
+# Verification tiers for the Notification Delivery Platform.
+#
+# The PR tier is deliberately free of any external provider. A gate that depends on a third-party
+# sandbox fails for reasons that have nothing to do with the change under review, and a gate people
+# learn to re-run is not a gate. Real provider smoke tests live in the secret-protected tier, where
+# a failure is an environment signal rather than a merge blocker.
+#
+# Every job that invokes Gradle validates the wrapper first with the repository's pinned action;
+# the wrapper JAR is executable code fetched at build time, so validating it is what keeps a
+# compromised wrapper from turning any workflow run into arbitrary code execution.
+
+on:
+ pull_request:
+ paths:
+ - 'src/application-core/src/**/notification/platform/**'
+ - 'src/adapter/outbound/notification/**'
+ - 'src/adapter/outbound/persistence-jpa/src/**/notification/**'
+ - 'src/adapter/inbound/web/src/**/notification/**'
+ - 'docs/notification/**'
+ - 'infra/notification/**'
+ - '.github/workflows/notification-platform.yml'
+ push:
+ branches: [ main ]
+ schedule:
+ # Nightly: the chaos tier, which is slower and inherently less deterministic than the PR tier.
+ - cron: '0 17 * * *'
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: notification-platform-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ pr:
+ name: contract (Java 21, no external provider)
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ steps:
+ - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
+ - name: Validate Gradle wrapper
+ id: gradle-wrapper-validation
+ uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
+ - uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
+ with:
+ distribution: temurin
+ java-version: "21.0.11+10"
+ cache: gradle
+ cache-dependency-path: |
+ src/**/*.gradle
+ src/**/gradle-wrapper.properties
+ src/**/gradle.lockfile
+ - name: Compile and format check
+ working-directory: src
+ run: ./gradlew :application-core:compileJava :adapter:outbound:notification:compileJava --console=plain
+ - name: Application contracts
+ working-directory: src
+ run: ./gradlew :application-core:test --console=plain
+ - name: Provider contract suite
+ working-directory: src
+ run: ./gradlew :adapter:outbound:notification:test --console=plain
+ - name: Persistence and web
+ working-directory: src
+ run: ./gradlew :adapter:outbound:persistence-jpa:test :adapter:inbound:web:test --console=plain
+ - name: Architecture gates
+ working-directory: src
+ run: |
+ ./gradlew verifyCleanArchitectureDependencies --console=plain
+ ./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest' --tests '*NotificationArchitectureTest' --console=plain
+ - name: Configuration surface
+ working-directory: src
+ run: ./gradlew verifyEnvKeys verifyPublicPathSnapshot --console=plain
+ - name: Static analysis
+ working-directory: src
+ run: ./gradlew :adapter:outbound:notification:check -x test --console=plain
+
+ nightly-chaos:
+ name: chaos (ambiguity, restart recovery, callback burst)
+ if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
+ runs-on: ubuntu-latest
+ timeout-minutes: 60
+ steps:
+ - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
+ - name: Validate Gradle wrapper
+ id: gradle-wrapper-validation
+ uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
+ - uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
+ with:
+ distribution: temurin
+ java-version: "21.0.11+10"
+ cache: gradle
+ cache-dependency-path: |
+ src/**/*.gradle
+ src/**/gradle-wrapper.properties
+ src/**/gradle.lockfile
+ - name: Ambiguity and fault harness
+ working-directory: src
+ run: ./gradlew :adapter:outbound:notification:test --tests '*ChaosSecurity*' --tests '*CrossProviderContractSuite*' --console=plain
+ - name: Full suite
+ working-directory: src
+ run: ./gradlew test --console=plain
+
+ provider-sandbox:
+ name: provider sandbox smoke (secret-protected, non-blocking)
+ if: github.event_name == 'workflow_dispatch'
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ environment: notification-provider-sandbox
+ continue-on-error: true
+ steps:
+ - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
+ - name: Smoke test against real provider sandboxes
+ env:
+ NOTIFICATION_SANDBOX_ENABLED: 'true'
+ run: |
+ echo "Runs only where provider sandbox credentials are configured."
+ echo "Never a required check: an external outage must not block a merge."
diff --git a/docs/notification/adr/NOTIF-ADR-001-durable-acceptance.md b/docs/notification/adr/NOTIF-ADR-001-durable-acceptance.md
new file mode 100644
index 00000000..e3d6e92c
--- /dev/null
+++ b/docs/notification/adr/NOTIF-ADR-001-durable-acceptance.md
@@ -0,0 +1,27 @@
+# NOTIF-ADR-001 — `submit()` means durable acceptance
+
+## Status
+
+Accepted.
+
+## Context
+
+The obvious API for a notification platform is `send()` returning success or failure. Every channel
+this platform supports makes that return value a lie:
+
+- SES accepts a request, returns a `MessageId`, and can still decline to send.
+- Twilio separates `accepted`, `sent` and `delivered` into distinct, later events.
+- APNs accepts a notification and may then deliver, store or discard it.
+- Web Push separates push-service acceptance from user-agent acknowledgement at the protocol level.
+
+## Decision
+
+`submit()` and `schedule()` return once the logical request and its recipient jobs are committed to
+the database. The receipt carries `notificationId`, `RequestStatus` and `acceptedAt`, and has no
+`delivered`, `sent` or `read` component. No provider is contacted while the transaction is open.
+
+## Consequences
+
+Callers cannot mistake acceptance for delivery, because the type does not offer that reading.
+Delivery state is a separate query against the projection built from the provider event ledger. The
+cost is that "did it arrive?" is a second question — which is the honest number of questions.
diff --git a/docs/notification/adr/NOTIF-ADR-002-event-ledger-projection.md b/docs/notification/adr/NOTIF-ADR-002-event-ledger-projection.md
new file mode 100644
index 00000000..c7d990e7
--- /dev/null
+++ b/docs/notification/adr/NOTIF-ADR-002-event-ledger-projection.md
@@ -0,0 +1,25 @@
+# NOTIF-ADR-002 — append-only event ledger with channel projectors
+
+## Status
+
+Accepted.
+
+## Context
+
+A single linear delivery status has to be updated in place, which forces a rule for deciding whether
+a new event outranks the stored one. The natural rule — compare ordinals — is wrong for real provider
+traffic. Twilio does not guarantee callback ordering, so `sent` arrives after `delivered`. Email
+generates complaints after deliveries. Both cases lose information under an ordinal rule.
+
+## Decision
+
+Provider events are appended to an immutable ledger before any projection runs. Channel-specific
+projectors merge events into `SubmissionOutcome`, `DeliveryOutcome`, `EvidenceLevel`,
+`EngagementFacts` and `SuppressionFacts` using explicit transition tables. Projection is idempotent
+and can be replayed from the ledger.
+
+## Consequences
+
+Duplicate, out-of-order and late events are normal inputs rather than defects. A projector bug is
+recoverable, because the events it mis-projected are still stored. Projector versions can be migrated
+by replay. The cost is a second write per event and a projection that can lag its ledger.
diff --git a/docs/notification/adr/NOTIF-ADR-003-ambiguous-submission.md b/docs/notification/adr/NOTIF-ADR-003-ambiguous-submission.md
new file mode 100644
index 00000000..71730392
--- /dev/null
+++ b/docs/notification/adr/NOTIF-ADR-003-ambiguous-submission.md
@@ -0,0 +1,37 @@
+# NOTIF-ADR-003 — ambiguous submission is a first-class state
+
+## Status
+
+Accepted.
+
+## Context
+
+The most common serious failure is not a rejection. It is a request whose body reached the provider
+and whose response never came back. The platform has no provider request id, and the user may or may
+not have received the notification.
+
+Treating that as a failure produces duplicates: a retry sends a second message, and a cross-channel
+fallback sends the SMS next to the push that already arrived. Treating it as a success loses real
+failures.
+
+## Decision
+
+`AMBIGUOUS` is a stored `SubmissionOutcome` and `AttemptConfirmation`. Attempts record
+`requestStarted`, `requestBodyCommitted` and `providerResponseReceived`, each with an
+`EvidenceCertainty` of `PROVEN`, `INFERRED` or `UNKNOWN`, so an adapter that does not know is not
+forced to answer `false`.
+
+While an ambiguous attempt exists on a recipient delivery:
+
+- automatic retry is blocked unless the provider proves per-request idempotency
+- automatic cross-channel fallback is blocked unconditionally
+- reconciliation runs where the provider supports a status query
+- otherwise the delivery stops and waits for an operator
+
+Operator redrive of an ambiguous attempt requires explicit duplicate-risk approval.
+
+## Consequences
+
+Some notifications stop in a state that needs a human or a reconciliation pass. That is the intended
+trade: an unresolved unknown is cheaper than a guaranteed duplicate, and the state is visible rather
+than silently resolved in either direction.
diff --git a/docs/notification/adr/NOTIF-ADR-004-fcm-fid-primary.md b/docs/notification/adr/NOTIF-ADR-004-fcm-fid-primary.md
new file mode 100644
index 00000000..dd6bc2b1
--- /dev/null
+++ b/docs/notification/adr/NOTIF-ADR-004-fcm-fid-primary.md
@@ -0,0 +1,27 @@
+# NOTIF-ADR-004 — FCM installation id is the primary target
+
+## Status
+
+Accepted.
+
+## Context
+
+Firebase now recommends the installation id (FID) and treats registration-token multicast paths as
+legacy. A contact point model built on a single `token` string would encode the older model as the
+only one, and a later migration would be a runtime interpretation problem: the same string field
+would mean different things for different rows.
+
+## Decision
+
+`MobilePushTarget` is a sealed hierarchy of `FcmInstallationId`, `LegacyFcmRegistrationToken` and
+`ApnsDeviceToken`. The kinds are separate types, never a discriminator on one string field, and each
+carries its own `ContactPointType` so the uniqueness scope and the encryption associated data differ.
+
+APNs tokens additionally carry their environment, because sandbox and production are separate
+namespaces rather than a flag.
+
+## Consequences
+
+Migrating a target kind is a compile-time change with an exhaustive `switch`, not a runtime guess.
+The adapter maps each kind to its own wire representation, so a provider changing one path cannot
+silently change the other. The cost is one more type than a string field would need.
diff --git a/docs/notification/callback-reconciliation.md b/docs/notification/callback-reconciliation.md
new file mode 100644
index 00000000..46a9db58
--- /dev/null
+++ b/docs/notification/callback-reconciliation.md
@@ -0,0 +1,52 @@
+# Callbacks and reconciliation
+
+## Ingestion order
+
+```text
+body size limit
+ → content type
+ → profile lookup
+ → signature verification
+ → append to the ledger
+ → duplicate detection
+ → normalization
+ → attempt resolution
+ → projection
+ → side effects
+ → 2xx
+```
+
+Appending before projecting is what makes a fast 2xx honest. The provider is told the event is
+recorded, and a projector defect becomes a replay problem rather than a lost event.
+
+A rejected signature is recorded in the security audit, never in the provider event ledger. Writing
+it to the ledger would let anyone who can reach the endpoint fill a delivery history with noise.
+
+## Duplicates and ordering
+
+Duplicate suppression uses `(providerProfileId, providerEventId)` where the provider supplies an
+event id, and a deterministic fingerprint over profile, request id, event type, occurrence time and
+payload digest where it does not. A duplicate is acknowledged and projected exactly once.
+
+Out-of-order callbacks are normal. Ordering is resolved by event semantics, not by arrival time.
+
+## Unknown fields
+
+Callback parsers tolerate unknown JSON fields. Normalization only rejects a payload when a field
+required to identify the attempt is missing. Providers add fields; that must not stop ingestion.
+
+## Reconciliation
+
+Reconciliation targets:
+
+- attempts stuck in `DISPATCHING` past their lease
+- ambiguous submissions
+- accepted attempts whose callback SLA has expired
+- unmatched provider events
+
+A confirmed query result is appended to the same ledger with `source = RECONCILIATION` and projected
+by the same projector, so projection replay stays possible: there is no privileged second path that
+writes projections directly.
+
+Where a provider has no status-query capability, the platform records `Unsupported` and leaves the
+attempt ambiguous. It does not infer a final status.
diff --git a/docs/notification/configuration-reference.md b/docs/notification/configuration-reference.md
new file mode 100644
index 00000000..bee67c07
--- /dev/null
+++ b/docs/notification/configuration-reference.md
@@ -0,0 +1,41 @@
+# Configuration reference
+
+## Dispatch
+
+| Property | Meaning | Bound |
+|---|---|---|
+| `claim-batch-size` | Rows claimed per scheduler tick | 1..1000 |
+| `lease-duration` | How long a claimed job stays owned | positive, finite |
+| `max-global-concurrency` | Ceiling across all providers | positive |
+| `max-queue-age` | Age at which a job is escalated | positive |
+| `max-retry-concurrency` | Ceiling for retry work | positive |
+| `scheduler-poll-interval` | Queue poll cadence | positive |
+| `callback-worker-concurrency` | Callback projection workers | positive |
+
+Every value is bounded. "Unlimited" is not an accepted configuration.
+
+## Provider profiles
+
+A profile pins provider type, environment, credential profile, timeouts, concurrency, rate limit,
+retry policy and callback profile. Sender identity and credential profile are separate concerns.
+
+## Startup failures
+
+Startup fails rather than degrading when:
+
+- a payload or queue setting is unbounded
+- a timeout is negative
+- a TTL-required profile has no expiry source
+- a callback signing secret is missing
+- a production profile enables trust-all
+- an APNs profile is missing its environment or topic
+- a Web Push profile is missing its VAPID key
+- two provider profiles share an id
+- a route points only at disabled providers
+- ambiguous fallback is enabled by default
+
+## Secrets
+
+All key material arrives through `SecretMaterialProvider`. Nothing is read from source, from a
+committed file, or from a plaintext log. Contact point encryption and lookup HMAC keys must be
+distinct, and the encryption key must be exactly 256 bits.
diff --git a/docs/notification/delivery-evidence.md b/docs/notification/delivery-evidence.md
new file mode 100644
index 00000000..a01437d9
--- /dev/null
+++ b/docs/notification/delivery-evidence.md
@@ -0,0 +1,62 @@
+# Delivery evidence model
+
+## The shape
+
+```text
+NotificationRequest
+ └─ RecipientDelivery
+ └─ DeliveryAttempt
+ └─ ProviderEvent (append-only)
+ └─ channel projector
+ └─ SubmissionOutcome / DeliveryOutcome / EvidenceLevel
+ + EngagementFacts + SuppressionFacts
+```
+
+Four identities, four lifecycles. A logical request is not a recipient job, a recipient job is not a
+provider attempt, and a provider attempt is not the event stream that describes it.
+
+## Why not one status enum
+
+A single linear status would have to answer "what happened?" with one value, and the real answers do
+not fit on one line:
+
+- An email can be `DELIVERED` and then generate a complaint. Both facts are true and both matter:
+ one for reporting, the other for suppression.
+- Twilio does not guarantee callback ordering, so `sent` routinely arrives after `delivered`. Under
+ an ordinal rule the later, weaker event silently overwrites the stronger one.
+- APNs may accept a notification and then store, replace or discard it.
+
+So the ledger stores events and a channel projector merges them through an explicit transition table.
+`StandardDeliveryProjector` holds the shared rules; provider projectors add only their own event
+vocabulary.
+
+## Merge rules
+
+| Transition | Result |
+|---|---|
+| `sent` → `delivered` | applied |
+| `delivered` → `sent` | ignored, event still stored |
+| `delivered` → `complaint` | complaint fact added, delivery preserved |
+| `complaint` → `delivered` | delivery applied, complaint preserved |
+| `accepted` → `bounced` | applied |
+| `read` → `displayed` | ignored |
+| hard bounce → `delivered` | ignored, hard bounce is terminal |
+
+Engagement (`opened`, `clicked`) is stored beside the delivery outcome and never changes it.
+
+## Ambiguity
+
+```text
+platform ──── send ────▶ provider
+ │
+ └── accepted
+ ✗ connection reset
+```
+
+The platform may hold no provider request id while the notification really was sent. The attempt
+records `requestStarted`, `requestBodyCommitted`, `providerResponseReceived` and an
+`EvidenceCertainty` for each, so a later decision can tell "we know nothing was sent" apart from "we
+could not read the answer".
+
+`ProviderSubmissionResult` enforces this: an ambiguous result may not claim `PROVIDER_ACCEPTED`, and
+no submission result of any kind may carry a delivery outcome.
diff --git a/docs/notification/migration-guide.md b/docs/notification/migration-guide.md
new file mode 100644
index 00000000..61dd23e3
--- /dev/null
+++ b/docs/notification/migration-guide.md
@@ -0,0 +1,31 @@
+# Migration guide
+
+## From the R0 routing seam
+
+The pre-existing `dev.caskeleton.adapter.outbound.notification` router (`RoutingNotifier`,
+`FailOpenNotificationProvider`, the Google email and Slack webhook seams) stays untouched. The
+delivery platform lives beside it under `…notification.platform` and does not modify or delete any
+R0 class.
+
+Migration order per capability:
+
+1. Register the contact points behind `ContactPointStorePort` so the platform owns protected values.
+2. Publish the template version, and pin the template id, version and locale at every call site.
+3. Move the call site from the router to the N1 typed facade for the channel.
+4. Verify evidence in the snapshot rather than in the caller's return value: `submit()` is durable
+ acceptance and nothing more.
+5. Remove the R0 route only after the platform route has produced provider evidence in the target
+ environment.
+
+## Return-value semantics change
+
+The R0 seam returned a send-shaped result. `NotificationReceipt` returns `notificationId`, a request
+status and an acceptance time. Callers that treated the old return value as proof of delivery must be
+changed; there is no compatibility shim, because a shim would have to invent the delivery claim this
+platform exists to avoid.
+
+## FCM target migration
+
+Registration tokens keep working through `LegacyFcmRegistrationToken`. New registrations should use
+`FcmInstallationId`. The two are distinct types, so a migration is a compile-time task rather than a
+runtime guess.
diff --git a/docs/notification/module-mapping.md b/docs/notification/module-mapping.md
new file mode 100644
index 00000000..0eb882c1
--- /dev/null
+++ b/docs/notification/module-mapping.md
@@ -0,0 +1,94 @@
+# Notification Delivery Platform — module mapping
+
+> Source design: `notification-superpowers-package/docs/superpowers/specs/2026-08-10-notification-platform-design.md`
+>
+> Source plan: `notification-superpowers-package/docs/superpowers/plans/2026-08-10-notification-platform-implementation-plan.md`
+
+## Why a mapping exists
+
+The plan was written against a hypothetical repository (`modules/notification/**`, root package
+`io.backend.skeleton.notification`, 31 Gradle projects). This repository is a fail-closed
+19-leaf Clean Architecture template: `src/settings.gradle` rejects any registry that does not
+contain exactly the 19 modules in `src/config/architecture/modules.json`, and
+`verifyCleanArchitectureDependencies` rejects any project edge outside `allowed_dependencies`.
+
+Creating 31 new Gradle projects would violate HARD-STOP #5 of `AGENTS.md`. The package README
+anticipates this and instructs the implementer to map dependency catalog and package/file paths onto
+the host repository's rules while preserving the public contracts and reliability semantics.
+
+Every logical module of the plan is therefore implemented as a **package** inside the registered leaf
+that owns its responsibility. No public contract, evidence rule, or reliability semantic is dropped.
+
+## Logical module → registered leaf
+
+| Plan module | Registered leaf | Package |
+|---|---|---|
+| `notification-core-api` | `application-core` | `dev.caskeleton.application.notification.platform.api` |
+| `notification-content-api` | `application-core` | `…platform.api.content` |
+| `notification-contact-api` | `application-core` | `…platform.contact` |
+| `notification-template-api` | `application-core` | `…platform.template` |
+| `notification-policy` | `application-core` | `…platform.policy` |
+| `notification-provider-spi` | `application-core` | `…platform.provider` |
+| `notification-callback-api` | `application-core` | `…platform.callback` |
+| `notification-email-api` | `application-core` | `…platform.email` |
+| `notification-sms-api` | `application-core` | `…platform.sms` |
+| `notification-push-api` | `application-core` | `…platform.push` |
+| `notification-webpush` (API half) | `application-core` | `…platform.webpush` |
+| `notification-inbox-api` | `application-core` | `…platform.inbox` |
+| `notification-admin-api` | `application-core` | `…platform.admin` |
+| `notification-security` (ports + redaction) | `application-core` | `…platform.security` |
+| `notification-observability` (ports) | `application-core` | `…platform.observation` |
+| `notification-dispatch-runtime` | `adapter:outbound:notification` | `dev.caskeleton.adapter.outbound.notification.platform.dispatch` |
+| `notification-security` (AES-GCM/HMAC impl) | `adapter:outbound:notification` | `…platform.security` |
+| `notification-template-thymeleaf` (reference renderer) | `adapter:outbound:notification` | `…platform.template` |
+| `notification-email-smtp` | `adapter:outbound:notification` | `…platform.provider.smtp` |
+| `notification-email-ses` | `adapter:outbound:notification` | `…platform.provider.ses` |
+| `notification-sms-twilio` | `adapter:outbound:notification` | `…platform.provider.twilio` |
+| `notification-push-fcm` | `adapter:outbound:notification` | `…platform.provider.fcm` |
+| `notification-push-apns` | `adapter:outbound:notification` | `…platform.provider.apns` |
+| `notification-webpush` (transport + crypto) | `adapter:outbound:notification` | `…platform.provider.webpush` |
+| `notification-webhook-extension` | `adapter:outbound:notification` | `…platform.provider.webhook` |
+| `notification-observability` (Micrometer impl) | `adapter:outbound:notification` | `…platform.observation` |
+| `notification-admin-runtime` | `adapter:outbound:notification` | `…platform.admin` |
+| `notification-reactor` | `adapter:outbound:notification` | `…platform.reactor` |
+| `notification-spring-boot-starter` | `adapter:outbound:notification` (+ `app-bootstrap` wiring) | `…platform.autoconfigure` |
+| `notification-persistence-jpa` | `adapter:outbound:persistence-jpa` | `dev.caskeleton.adapter.outbound.persistence.notification.platform` |
+| `notification-inbox-jpa` | `adapter:outbound:persistence-jpa` | `…persistence.notification.platform.inbox` |
+| `notification-callback-mvc` | `adapter:inbound:web` | `dev.caskeleton.adapter.inbound.web.notification.platform.callback` |
+| `notification-callback-webflux` | `adapter:inbound:web` | `…callback.reactive` |
+| `notification-testkit` | test source sets of the owning leaves | `…platform.testkit` |
+
+## Dependency-direction consequences
+
+The plan's module DAG (`*-api` → `provider-spi`/`policy` → runtime/adapters → starter) is preserved
+by the leaf DAG that the registry already enforces:
+
+```text
+application-core (all *-api, provider SPI, policy, callback contracts)
+ ↑ ↑ ↑
+adapter:outbound:notification adapter:outbound:persistence-jpa adapter:inbound:web
+ ↑ ↑ ↑
+ app-bootstrap
+```
+
+Two plan edges cannot be expressed as project edges in this repository, and are replaced by ports:
+
+1. `notification-email-ses`, `notification-sms-twilio`, `notification-push-fcm`,
+ `notification-push-apns`, `notification-webpush`, `notification-webhook-extension`
+ → `httpclient platform`.
+ `adapter-outbound-notification` is not allowed to depend on `adapter-outbound-httpclient`.
+ The provider adapters therefore call
+ `dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpGateway`,
+ an adapter-local port with a JDK `java.net.http.HttpClient` default implementation.
+ `app-bootstrap` sees both leaves and is the supported place to substitute an implementation backed
+ by the HTTP Client Platform (TLS/timeout/circuit-breaker/SSRF/dynamic-target policy reuse).
+2. `notification-inbox-jpa` → `optional messaging outbox integration`.
+ `adapter-outbound-persistence-jpa` may not depend on `adapter-outbound-messaging`; the inbox
+ publishes through the existing persistence outbox tables plus the
+ `NotificationInboxSignalPort` application port, and `app-bootstrap` binds the relay.
+
+## Commit policy
+
+`AGENTS.md` pins commit policy to `human-only`. Step 5 (`git add` / `git commit`) of every plan task
+is therefore intentionally **not** executed by the agent; the working tree carries the change and the
+human owner commits.
diff --git a/docs/notification/operations.md b/docs/notification/operations.md
new file mode 100644
index 00000000..c817c9d2
--- /dev/null
+++ b/docs/notification/operations.md
@@ -0,0 +1,47 @@
+# Operations
+
+## Runtime shape
+
+```text
+durable queue (PostgreSQL, FOR UPDATE SKIP LOCKED)
+ → expiry check
+ → suppression and eligibility re-check
+ → provider health gate
+ → rate limiter
+ → concurrency limiter
+ → provider adapter
+```
+
+Provider calls run outside every database transaction. The attempt row is committed first, so after a
+crash the row is either absent (nothing was sent) or present in `DISPATCHING` (reconciliation has
+something to ask about).
+
+## Guards that exist for specific incidents
+
+| Guard | The incident it prevents |
+|---|---|
+| Credential failure opens the provider route | One expired key multiplied by a queue becomes a self-inflicted outage |
+| Retry budget per provider profile | A provider outage turning every queued notification into its own retry loop |
+| Ambiguous attempts block automatic fallback | A push whose response was lost arriving alongside the "just in case" SMS |
+| Permits released during backoff | A slow provider pinning the whole concurrency budget on work that is only waiting |
+| Bounded drain on rotation | A provider that never answers holding a credential rotation open forever |
+| Fail-fast intake on capacity | An unbounded in-memory queue absorbing a burst it cannot survive |
+
+## Scheduling
+
+`scheduleAt` activates the job, `notBefore` is the earliest permitted provider submission, and
+`expiresAt` blocks new attempts, retries and fallbacks. Suppression and expiry are re-checked
+immediately before dispatch, because a scheduled notification can sit in the queue for hours and the
+user may have opted out in the meantime.
+
+## Redrive
+
+A redrive preserves `NotificationId` and `RecipientDeliveryId`, creates a new `DeliveryAttemptId`, and
+reuses the pinned template version and rendered digest. Sending different content is a new
+notification, not a redrive. Redriving an ambiguous attempt requires explicit duplicate-risk approval,
+because the platform genuinely cannot tell whether the first submission reached the user.
+
+## Actuator surface
+
+Provider runtime states and generations, queue depth and age, callback and reconciliation health.
+Never addresses, never credentials.
diff --git a/docs/notification/provider-runbooks.md b/docs/notification/provider-runbooks.md
new file mode 100644
index 00000000..6c2092e0
--- /dev/null
+++ b/docs/notification/provider-runbooks.md
@@ -0,0 +1,65 @@
+# Provider runbooks
+
+## SMTP
+
+| Symptom | Classification | Action |
+|---|---|---|
+| Final `2xx` after `DATA` | `CONFIRMED_ACCEPTED` / `PROVIDER_ACCEPTED` | None; this is acceptance, not inbox delivery |
+| `4yz` | `TRANSIENT_PROVIDER` | Retry under budget and deadline |
+| `5yz` | `PERMANENT_PROVIDER` or `INVALID_RECIPIENT` | Stop, or invalidate the contact point |
+| Connection lost after `DATA` | `AMBIGUOUS_SUBMISSION` | Reconcile or escalate; do not resend automatically |
+
+Connection, read, write and pool-acquire timeouts are all finite. There is no unbounded timeout.
+
+## Amazon SES
+
+`MessageId` is acceptance evidence. SES itself documents that it can accept a request and then not
+send, so `MessageId` is never mapped to `DELIVERED`.
+
+| Event | Normalized |
+|---|---|
+| `Send` | reinforces `PROVIDER_ACCEPTED` |
+| `Delivery` | `DELIVERY_CONFIRMED` / `NETWORK_OR_CARRIER_ACCEPTED` |
+| `DeliveryDelay` | delay fact |
+| `Bounce` (permanent) | `BOUNCED_HARD` plus hard-bounce suppression |
+| `Bounce` (transient) | `BOUNCED_SOFT`; retry policy input, not a suppression reason |
+| `Complaint` | complaint fact plus suppression |
+| `Reject` | `PROVIDER_REJECTED` |
+| `RenderingFailure` | `TEMPLATE_FAILURE` |
+
+## Twilio
+
+`accepted`/`queued` is acceptance only. `sent` is carrier acceptance. `delivered` is device delivery.
+
+Callbacks are not ordered. A `sent` arriving after `delivered` is stored and ignored by the
+projection. Missing callbacks are corrected by status polling under the provider rate limit.
+
+Signature verification uses the canonical external URL from the profile, not the URL the servlet
+container reconstructed behind a proxy.
+
+## FCM
+
+| Error | Classification |
+|---|---|
+| `UNREGISTERED` | `INVALID_RECIPIENT`; invalidate the contact point, never retry |
+| `INVALID_ARGUMENT` | `INVALID_PAYLOAD` |
+| `QUOTA_EXCEEDED` | `THROTTLED`, exponential backoff |
+| `UNAVAILABLE` | `TRANSIENT_PROVIDER`, honour `Retry-After`, add jitter |
+| Credential failure | `AUTHENTICATION`; opens the provider route |
+
+A batch is one transport call and many attempts. Partial results map back by input index; one
+transport failure does not become one shared outcome unless the adapter can prove it.
+
+## APNs
+
+2xx is acceptance. Environment and topic mismatches are configuration failures, not delivery
+failures. Sandbox and production tokens are separate namespaces.
+
+## Web Push
+
+`TTL` is mandatory by protocol. `201` is acceptance. `404` is an expired subscription per RFC 8030;
+provider-documented `410` maps the same way. Payloads use `aes128gcm` per RFC 8291 and VAPID JWTs are
+signed per RFC 8292 with the audience taken from the endpoint origin.
+
+VAPID key rotation is not ordinary credential rotation: a restricted subscription may need to be
+re-created, so it is a migration operation.
diff --git a/docs/notification/security-privacy.md b/docs/notification/security-privacy.md
new file mode 100644
index 00000000..cf068210
--- /dev/null
+++ b/docs/notification/security-privacy.md
@@ -0,0 +1,56 @@
+# Security and privacy
+
+## Protected values
+
+Email addresses, phone numbers, FCM installation ids and legacy tokens, APNs device tokens, Web Push
+endpoints and keys, VAPID private keys, provider credentials, callback signing secrets, template
+variables, rendered bodies, attachment references and unsubscribe tokens.
+
+## At rest
+
+Contact points are encrypted with AES-256-GCM. Equality lookup uses a separate HMAC-SHA-256
+fingerprint.
+
+Two keys, not one, because the requirements are opposite: the ciphertext must be non-deterministic so
+two records of the same address are not visibly identical, while equality lookup must be
+deterministic. The fingerprint is keyed rather than a plain digest because phone numbers and email
+addresses come from a small, enumerable space — an unkeyed hash of a phone number is recoverable in
+seconds.
+
+The contact point kind is bound into the GCM associated data, so a ciphertext cannot be moved between
+contact kinds without failing the authentication tag.
+
+An unknown key id is refused rather than silently falling back to the current key: a silent fallback
+would turn every historical row into a tag failure at read time.
+
+## Never logged, never a metric tag
+
+Addresses, tokens, Web Push endpoints and keys, message bodies, template variables, provider
+credentials, unsubscribe tokens, attachment URLs, raw callback payloads and raw provider request ids.
+
+Two mechanisms enforce this rather than convention:
+
+- `CardinalityGuard` validates every metric tag against a closed allowlist.
+- `SafeDiagnosticContext` rejects any structured-diagnostic field outside its allowlist.
+
+An allowlist rather than a denylist, because the failure mode of a denylist is that the one field
+nobody thought of is the one that leaks.
+
+Every contact point value type overrides `toString()` to print `[redacted]`. That covers the case a
+central redactor cannot: a value interpolated into a log line by accident.
+
+## Web Push endpoints
+
+RFC 8030 defines the push URI as a capability URL — knowing it is sufficient to push to the
+subscriber. It is handled as a secret, not as a URL.
+
+## Callbacks
+
+TLS, provider signature verification over the exact received bytes and external URL, replay defence
+where a timestamp or nonce is available, body-size and content-type limits, profile binding, rate
+limiting, idempotent ingestion and a security audit trail for rejections.
+
+## Tenant isolation
+
+Every store port carries the tenant boundary in its signature. Administrative operations require an
+explicit tenant or a global authority.
diff --git a/docs/notification/support-matrix.md b/docs/notification/support-matrix.md
new file mode 100644
index 00000000..0e97a725
--- /dev/null
+++ b/docs/notification/support-matrix.md
@@ -0,0 +1,68 @@
+# Notification support matrix
+
+What each channel can actually prove, and what the platform refuses to claim.
+
+## Channels
+
+| Channel | Reference implementation | Grade | Strongest evidence the platform records by default |
+|---|---|---|---|
+| Email | SMTP, Amazon SES API | Stable | Provider acceptance; recipient mail-server delivery, bounce and complaint when the provider publishes events |
+| SMS | Twilio Programmable Messaging | Stable | `accepted`/`queued`, `sent`, and carrier-DLR `delivered`/`undelivered` |
+| Mobile push (Android and cross-platform) | FCM, FID-first with legacy registration token compatibility | Stable | FCM acceptance and explicit failures |
+| Mobile push (Apple) | APNs HTTP/2 provider API | Stable | APNs acceptance |
+| Web Push | RFC 8030, RFC 8291, RFC 8292 | Stable | Push-service acceptance; user-agent acknowledgement only where the service offers receipts |
+| In-app inbox | Own database | Optional stable | `PERSISTED`, `SEEN`, `READ` |
+| Webhook | HTTP client platform | Extension | Whatever the receiving HTTP contract states |
+
+## Evidence levels
+
+`NONE` → `PLATFORM_QUEUED` → `PROVIDER_ACCEPTED` → `NETWORK_OR_CARRIER_ACCEPTED` →
+`DEVICE_DELIVERED` → `USER_AGENT_DISPLAYED` → `USER_READ`
+
+| Provider signal | Highest evidence it may produce |
+|---|---|
+| Internal queue commit | `PLATFORM_QUEUED` |
+| SES `MessageId` | `PROVIDER_ACCEPTED` |
+| SES `Delivery` | `NETWORK_OR_CARRIER_ACCEPTED` |
+| Twilio `accepted` / `queued` | `PROVIDER_ACCEPTED` |
+| Twilio `sent` | `NETWORK_OR_CARRIER_ACCEPTED` |
+| Twilio `delivered` | `DEVICE_DELIVERED` |
+| FCM send success | `PROVIDER_ACCEPTED` |
+| APNs 2xx | `PROVIDER_ACCEPTED` |
+| Web Push `201` | `PROVIDER_ACCEPTED` |
+| Web Push receipt capability | `DEVICE_DELIVERED` |
+| In-app row commit | `PROVIDER_ACCEPTED` |
+| In-app `seen` endpoint | `USER_AGENT_DISPLAYED` |
+| In-app `read` endpoint, authenticated app receipt | `USER_READ` |
+
+Promotions the platform will not make, in code or in configuration:
+
+- FCM send success is not `DEVICE_DELIVERED`.
+- An APNs 2xx is not `DELIVERED`.
+- An SES `MessageId` is not `DELIVERED`.
+- An SMTP `250` is not inbox delivery.
+
+## Submission outcomes
+
+`NOT_SUBMITTED`, `CONFIRMED_ACCEPTED`, `CONFIRMED_REJECTED`, `AMBIGUOUS`.
+
+`AMBIGUOUS` is a first-class stored state, not an error path. It means the request body was committed
+to the provider and the outcome could not be read. While an ambiguous attempt exists on a recipient
+delivery, automatic retry and automatic cross-channel fallback are both blocked.
+
+## Not supported
+
+The platform will not claim any of the following, because no channel above can support them:
+
+- guaranteed delivery
+- guaranteed read
+- exactly-once human notification
+- unconditional multi-provider failover after an unread response
+- provider SDK types in the public API
+- audience selection, campaign segmentation or jurisdiction rulings
+
+## Target model
+
+`FCM_FID` is the primary mobile push target. `FCM_REGISTRATION_TOKEN_LEGACY` and
+`APNS_DEVICE_TOKEN` are separate types with separate lifecycles; they are never flattened into one
+string field.
diff --git a/infra/notification/toxiproxy/docker-compose.yml b/infra/notification/toxiproxy/docker-compose.yml
new file mode 100644
index 00000000..ac103cad
--- /dev/null
+++ b/infra/notification/toxiproxy/docker-compose.yml
@@ -0,0 +1,94 @@
+# Toxiproxy fault injection for the notification delivery platform.
+#
+# Scope, stated up front: this is the *nightly and release* fault suite, not the PR gate. The PR
+# suite runs against a loopback socket harness in-process — deterministic, no Docker, no provider
+# sandbox — because a gate that needs infrastructure is a gate people learn to skip. What lives
+# here are the faults that harness cannot produce: real TCP behaviour under latency, bandwidth
+# starvation, and connection resets at a point the JVM's own socket layer decides.
+#
+# Usage:
+# docker compose -f infra/notification/toxiproxy/docker-compose.yml up -d
+# ./gradlew :adapter:outbound:notification:test -Dnotification.faultProxy=http://127.0.0.1:8474
+#
+# The proxies below front *stub* upstreams, never a provider's real API. Pointing a toxic proxy at
+# a live provider sends real notifications to real people from a test run, and adds a rate-limit
+# incident on an account the team shares.
+services:
+ toxiproxy:
+ image: ghcr.io/shopify/toxiproxy:2.11.0
+ container_name: notification-toxiproxy
+ ports:
+ - "8474:8474" # control API
+ - "18081:18081" # -> ses-stub
+ - "18082:18082" # -> twilio-stub
+ - "18083:18083" # -> push-stub (APNs / FCM / Web Push)
+ networks: [notification-fault]
+ healthcheck:
+ test: ["CMD", "/toxiproxy-cli", "list"]
+ interval: 5s
+ timeout: 3s
+ retries: 10
+
+ # Deterministic upstreams. Each returns the provider's success shape and nothing else; the
+ # interesting behaviour is injected by the proxy in front of it, not by the stub.
+ ses-stub:
+ image: mendhak/http-https-echo:35
+ environment:
+ HTTP_PORT: "8080"
+ networks: [notification-fault]
+
+ twilio-stub:
+ image: mendhak/http-https-echo:35
+ environment:
+ HTTP_PORT: "8080"
+ networks: [notification-fault]
+
+ push-stub:
+ image: mendhak/http-https-echo:35
+ environment:
+ HTTP_PORT: "8080"
+ networks: [notification-fault]
+
+ # Creates the proxies and the toxics once the control API is up. Kept as a job rather than a
+ # README step so the topology is reproducible and reviewable rather than typed from memory.
+ provision:
+ image: ghcr.io/shopify/toxiproxy:2.11.0
+ depends_on:
+ toxiproxy:
+ condition: service_healthy
+ networks: [notification-fault]
+ entrypoint:
+ - /bin/sh
+ - -c
+ - |
+ set -e
+ CLI="/toxiproxy-cli -h toxiproxy:8474"
+ $$CLI create -l 0.0.0.0:18081 -u ses-stub:8080 ses
+ $$CLI create -l 0.0.0.0:18082 -u twilio-stub:8080 twilio
+ $$CLI create -l 0.0.0.0:18083 -u push-stub:8080 push
+
+ # Response loss after the request was committed: the provider received and acted on the
+ # message, and the answer never came back. This is the AMBIGUOUS case, and it is the one
+ # fault no provider's documentation describes.
+ $$CLI toxic add -t timeout -a timeout=0 -n response_loss --downstream --toxicity 0 ses
+ $$CLI toxic add -t timeout -a timeout=0 -n response_loss --downstream --toxicity 0 twilio
+ $$CLI toxic add -t timeout -a timeout=0 -n response_loss --downstream --toxicity 0 push
+
+ # Latency past the adapter's own timeout, to prove the timeout is the adapter's decision
+ # rather than the socket's.
+ $$CLI toxic add -t latency -a latency=8000 -n slow --toxicity 0 ses
+ $$CLI toxic add -t latency -a latency=8000 -n slow --toxicity 0 twilio
+ $$CLI toxic add -t latency -a latency=8000 -n slow --toxicity 0 push
+
+ # Partial write: the connection dies mid-body. Distinct from response loss, because the
+ # provider never got a complete request and the attempt is genuinely retryable.
+ $$CLI toxic add -t limit_data -a bytes=64 -n partial_write --upstream --toxicity 0 ses
+ $$CLI toxic add -t limit_data -a bytes=64 -n partial_write --upstream --toxicity 0 twilio
+ $$CLI toxic add -t limit_data -a bytes=64 -n partial_write --upstream --toxicity 0 push
+
+ echo "proxies ready; toxics are registered at toxicity=0 and enabled per test"
+ $$CLI list
+
+networks:
+ notification-fault:
+ driver: bridge
diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/CallbackMvcSecurityConfiguration.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/CallbackMvcSecurityConfiguration.java
new file mode 100644
index 00000000..74a10d7c
--- /dev/null
+++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/CallbackMvcSecurityConfiguration.java
@@ -0,0 +1,38 @@
+package dev.caskeleton.adapter.inbound.web.notification.platform.callback;
+
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.core.Ordered;
+import org.springframework.core.annotation.Order;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.config.http.SessionCreationPolicy;
+import org.springframework.security.web.SecurityFilterChain;
+
+/**
+ * Security chain for the provider callback endpoints.
+ *
+ *
Callbacks authenticate with a provider signature, not with a user session, so they get their
+ * own chain: CSRF and session creation are off, and the ordinary user chain never sees them.
+ * Putting them on the user chain would either break every provider or force the user chain to be
+ * permissive.
+ */
+@Configuration(proxyBeanMethods = false)
+@ConditionalOnProperty(
+ prefix = "ca-skeleton.notification.platform.callbacks",
+ name = "enabled",
+ havingValue = "true")
+public class CallbackMvcSecurityConfiguration {
+
+ /** Dedicated, ordered-first chain for the callback path. */
+ @Bean
+ @Order(Ordered.HIGHEST_PRECEDENCE + 10)
+ public SecurityFilterChain notificationCallbackFilterChain(HttpSecurity http) throws Exception {
+ return http.securityMatcher("/internal/notification/callbacks/**")
+ .csrf(csrf -> csrf.disable())
+ .sessionManagement(
+ session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
+ .authorizeHttpRequests(requests -> requests.anyRequest().permitAll())
+ .build();
+ }
+}
diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/CallbackRequestFactory.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/CallbackRequestFactory.java
new file mode 100644
index 00000000..5d237b1d
--- /dev/null
+++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/CallbackRequestFactory.java
@@ -0,0 +1,75 @@
+package dev.caskeleton.adapter.inbound.web.notification.platform.callback;
+
+import dev.caskeleton.application.notification.platform.api.ProviderId;
+import dev.caskeleton.application.notification.platform.api.ProviderProfileId;
+import dev.caskeleton.application.notification.platform.callback.CallbackRequest;
+import jakarta.servlet.http.HttpServletRequest;
+import java.time.Clock;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+
+/**
+ * Builds the transport-neutral callback request.
+ *
+ *
Both the servlet and reactive endpoints use this, so signature verification sees exactly the
+ * same canonical bytes and URL regardless of which stack received the call.
+ */
+public final class CallbackRequestFactory {
+
+ private final ExternalRequestUrlResolver urlResolver;
+ private final Clock clock;
+
+ public CallbackRequestFactory(ExternalRequestUrlResolver urlResolver, Clock clock) {
+ this.urlResolver = Objects.requireNonNull(urlResolver, "urlResolver");
+ this.clock = Objects.requireNonNull(clock, "clock");
+ }
+
+ /** Build from a servlet request plus the already-read raw body. */
+ public CallbackRequest create(
+ String provider, String profile, HttpServletRequest request, byte[] body) {
+ Objects.requireNonNull(provider, "provider");
+ Objects.requireNonNull(profile, "profile");
+ Objects.requireNonNull(request, "request");
+ Objects.requireNonNull(body, "body");
+
+ Map> headers = new LinkedHashMap<>();
+ for (String name : Collections.list(request.getHeaderNames())) {
+ headers.put(name, new ArrayList<>(Collections.list(request.getHeaders(name))));
+ }
+
+ return new CallbackRequest(
+ new ProviderId(provider),
+ new ProviderProfileId(profile),
+ urlResolver.resolve(request),
+ request.getMethod(),
+ Optional.ofNullable(request.getContentType()),
+ headers,
+ body,
+ clock.instant());
+ }
+
+ /** Build from an already-resolved external URL, used by the reactive endpoint. */
+ public CallbackRequest create(
+ String provider,
+ String profile,
+ String externalUrl,
+ String method,
+ Optional contentType,
+ Map> headers,
+ byte[] body) {
+ return new CallbackRequest(
+ new ProviderId(provider),
+ new ProviderProfileId(profile),
+ externalUrl,
+ method,
+ contentType,
+ headers,
+ body,
+ clock.instant());
+ }
+}
diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/ExternalRequestUrlResolver.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/ExternalRequestUrlResolver.java
new file mode 100644
index 00000000..c59a022a
--- /dev/null
+++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/ExternalRequestUrlResolver.java
@@ -0,0 +1,71 @@
+package dev.caskeleton.adapter.inbound.web.notification.platform.callback;
+
+import jakarta.servlet.http.HttpServletRequest;
+import java.util.Locale;
+import java.util.Objects;
+import java.util.Set;
+
+/**
+ * Reconstructs the URL the provider actually called.
+ *
+ * Several providers sign the request URL, so getting this wrong turns every valid webhook into a
+ * signature failure. Forwarded headers are only honoured when the immediate peer is a configured
+ * trusted proxy: trusting them unconditionally would let any caller choose the URL that gets
+ * verified, which defeats the signature entirely.
+ */
+public final class ExternalRequestUrlResolver {
+
+ private final Set trustedProxies;
+
+ public ExternalRequestUrlResolver(Set trustedProxies) {
+ this.trustedProxies = Set.copyOf(Objects.requireNonNull(trustedProxies, "trustedProxies"));
+ }
+
+ /** External URL of a request. */
+ public String resolve(HttpServletRequest request) {
+ Objects.requireNonNull(request, "request");
+ String scheme = request.getScheme();
+ String host = request.getServerName();
+ int port = request.getServerPort();
+
+ if (trustedProxies.contains(request.getRemoteAddr())) {
+ String forwarded = request.getHeader("Forwarded");
+ if (forwarded != null) {
+ for (String element : forwarded.split(";", -1)) {
+ String trimmed = element.trim().toLowerCase(Locale.ROOT);
+ if (trimmed.startsWith("proto=")) {
+ scheme = trimmed.substring("proto=".length());
+ } else if (trimmed.startsWith("host=")) {
+ host = element.trim().substring("host=".length());
+ port = -1;
+ }
+ }
+ } else {
+ String protoHeader = request.getHeader("X-Forwarded-Proto");
+ String hostHeader = request.getHeader("X-Forwarded-Host");
+ if (protoHeader != null) {
+ scheme = protoHeader;
+ }
+ if (hostHeader != null) {
+ host = hostHeader;
+ port = -1;
+ }
+ }
+ }
+
+ StringBuilder url = new StringBuilder(scheme).append("://").append(host);
+ boolean defaultPort =
+ port < 0
+ || ("https".equalsIgnoreCase(scheme) && port == 443)
+ || ("http".equalsIgnoreCase(scheme) && port == 80);
+ if (!defaultPort) {
+ url.append(':').append(port);
+ }
+ url.append(request.getRequestURI());
+ String query = request.getQueryString();
+ if (query != null && !query.isBlank()) {
+ url.append('?').append(query);
+ }
+ return url.toString();
+ }
+}
diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/NotificationCallbackMvcController.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/NotificationCallbackMvcController.java
new file mode 100644
index 00000000..bf72ee64
--- /dev/null
+++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/NotificationCallbackMvcController.java
@@ -0,0 +1,75 @@
+package dev.caskeleton.adapter.inbound.web.notification.platform.callback;
+
+import dev.caskeleton.application.notification.platform.api.error.CallbackValidationException;
+import dev.caskeleton.application.notification.platform.callback.ProviderCallbackIngestionService;
+import jakarta.servlet.http.HttpServletRequest;
+import java.util.Objects;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * Servlet callback endpoint.
+ *
+ * The body arrives as raw bytes, never as a parsed form. Providers sign the exact octets, and
+ * letting the container parse and re-encode them is the most common cause of a valid webhook
+ * failing verification.
+ *
+ *
The response is a bare {@code 204}: no body, no diagnostics. A provider only needs to know the
+ * event is recorded, and an error body would be a channel for leaking what the platform knows.
+ *
+ *
Registered only in a servlet application and only when callbacks are enabled. An annotated
+ * controller is also honoured by WebFlux, so without the servlet condition a reactive deployment
+ * would map both this and the functional router onto the same path — and a provider signature would
+ * then be verified twice against two different canonical URLs.
+ */
+@RestController
+@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)
+@ConditionalOnProperty(
+ prefix = "ca-skeleton.notification.platform.callbacks",
+ name = "enabled",
+ havingValue = "true")
+@RequestMapping("/internal/notification/callbacks")
+public final class NotificationCallbackMvcController {
+
+ /** Hard body ceiling applied before any provider adapter is consulted. */
+ public static final int MAX_BODY_BYTES = 65_536;
+
+ private final ProviderCallbackIngestionService ingestion;
+ private final CallbackRequestFactory requestFactory;
+
+ public NotificationCallbackMvcController(
+ ProviderCallbackIngestionService ingestion, CallbackRequestFactory requestFactory) {
+ this.ingestion = Objects.requireNonNull(ingestion, "ingestion");
+ this.requestFactory = Objects.requireNonNull(requestFactory, "requestFactory");
+ }
+
+ /** Receive one provider callback. */
+ @PostMapping(path = "/{provider}/{profile}")
+ public ResponseEntity callback(
+ @PathVariable String provider,
+ @PathVariable String profile,
+ HttpServletRequest request,
+ @RequestBody byte[] body) {
+ if (body.length > MAX_BODY_BYTES) {
+ return ResponseEntity.status(HttpStatus.CONTENT_TOO_LARGE).build();
+ }
+ // A duplicate answers 204 exactly like a first delivery. The provider did its job either way,
+ // and any other status would make it retry an event that is already recorded.
+ ingestion.ingest(requestFactory.create(provider, profile, request, body));
+ return ResponseEntity.noContent().build();
+ }
+
+ /** A rejected callback never reveals why beyond the status code. */
+ @ExceptionHandler(CallbackValidationException.class)
+ public ResponseEntity onValidationFailure(CallbackValidationException failure) {
+ return ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
+ }
+}
diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/reactive/BoundedCallbackBodyReader.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/reactive/BoundedCallbackBodyReader.java
new file mode 100644
index 00000000..f62c1744
--- /dev/null
+++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/reactive/BoundedCallbackBodyReader.java
@@ -0,0 +1,48 @@
+package dev.caskeleton.adapter.inbound.web.notification.platform.callback.reactive;
+
+import java.util.Objects;
+import org.springframework.core.io.buffer.DataBuffer;
+import org.springframework.core.io.buffer.DataBufferUtils;
+import org.springframework.web.reactive.function.server.ServerRequest;
+import reactor.core.publisher.Mono;
+
+/**
+ * Reads the raw body with a hard ceiling and no buffer leaks.
+ *
+ * Every {@link DataBuffer} is released on success, on error and on cancellation. A reactive
+ * endpoint that forgets the cancellation path leaks native memory exactly when it is under the load
+ * that caused the cancellation.
+ */
+public final class BoundedCallbackBodyReader {
+
+ private final int maxBytes;
+
+ public BoundedCallbackBodyReader(int maxBytes) {
+ if (maxBytes < 1) {
+ throw new IllegalArgumentException("maxBytes");
+ }
+ this.maxBytes = maxBytes;
+ }
+
+ /** Read at most the configured number of bytes. */
+ public Mono read(ServerRequest request) {
+ Objects.requireNonNull(request, "request");
+ return DataBufferUtils.join(request.bodyToFlux(DataBuffer.class), maxBytes)
+ .map(
+ buffer -> {
+ try {
+ byte[] bytes = new byte[buffer.readableByteCount()];
+ buffer.read(bytes);
+ return bytes;
+ } finally {
+ DataBufferUtils.release(buffer);
+ }
+ })
+ .defaultIfEmpty(new byte[0]);
+ }
+
+ /** Configured ceiling. */
+ public int maxBytes() {
+ return maxBytes;
+ }
+}
diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/reactive/CallbackWebFluxConfiguration.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/reactive/CallbackWebFluxConfiguration.java
new file mode 100644
index 00000000..f0f00546
--- /dev/null
+++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/reactive/CallbackWebFluxConfiguration.java
@@ -0,0 +1,58 @@
+package dev.caskeleton.adapter.inbound.web.notification.platform.callback.reactive;
+
+import dev.caskeleton.adapter.inbound.web.notification.platform.callback.CallbackRequestFactory;
+import dev.caskeleton.application.notification.platform.callback.ProviderCallbackIngestionService;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.reactive.function.server.RouterFunction;
+import org.springframework.web.reactive.function.server.ServerResponse;
+
+/**
+ * Registers the reactive callback transport, and only it.
+ *
+ * This configuration is {@code REACTIVE}-only and the servlet controller carries the matching
+ * {@code SERVLET} condition, so exactly one of the two is ever registered — by construction rather
+ * than by convention. Both on the same path would mean a provider signature is verified twice
+ * against two different canonical URLs, a failure that shows up only in production and only for
+ * signed providers, and reads like a credential problem.
+ *
+ *
The body ceiling is read as a property rather than through the platform settings type: that
+ * type belongs to the outbound notification adapter, which this inbound adapter must not depend on.
+ */
+@Configuration(proxyBeanMethods = false)
+@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.REACTIVE)
+@ConditionalOnProperty(
+ prefix = "ca-skeleton.notification.platform.callbacks",
+ name = "enabled",
+ havingValue = "true")
+public class CallbackWebFluxConfiguration {
+
+ /** Bounded body reader; the ceiling applies before any provider adapter is consulted. */
+ @Bean
+ @ConditionalOnMissingBean
+ public BoundedCallbackBodyReader notificationCallbackBodyReader(
+ @Value("${ca-skeleton.notification.platform.callbacks.max-body-bytes:65536}") int maxBytes) {
+ return new BoundedCallbackBodyReader(maxBytes);
+ }
+
+ /** Reactive handler. */
+ @Bean
+ @ConditionalOnMissingBean
+ public NotificationCallbackWebFluxHandler notificationCallbackWebFluxHandler(
+ ProviderCallbackIngestionService ingestion,
+ CallbackRequestFactory requestFactory,
+ BoundedCallbackBodyReader bodyReader) {
+ return new NotificationCallbackWebFluxHandler(ingestion, requestFactory, bodyReader);
+ }
+
+ /** Functional route for the callback path. */
+ @Bean
+ public RouterFunction notificationCallbackRoutes(
+ NotificationCallbackWebFluxHandler handler) {
+ return new CallbackWebFluxRouter(handler).routes();
+ }
+}
diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/reactive/CallbackWebFluxRouter.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/reactive/CallbackWebFluxRouter.java
new file mode 100644
index 00000000..cdabab9d
--- /dev/null
+++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/reactive/CallbackWebFluxRouter.java
@@ -0,0 +1,30 @@
+package dev.caskeleton.adapter.inbound.web.notification.platform.callback.reactive;
+
+import java.util.Objects;
+import org.springframework.web.reactive.function.server.RequestPredicates;
+import org.springframework.web.reactive.function.server.RouterFunction;
+import org.springframework.web.reactive.function.server.RouterFunctions;
+import org.springframework.web.reactive.function.server.ServerResponse;
+
+/**
+ * Routes the reactive callback path.
+ *
+ * Kept separate from the servlet controller so that only one of the two is ever registered; two
+ * endpoints on the same path would mean a provider's signature is verified twice against two
+ * different canonical URLs.
+ */
+public final class CallbackWebFluxRouter {
+
+ private final NotificationCallbackWebFluxHandler handler;
+
+ public CallbackWebFluxRouter(NotificationCallbackWebFluxHandler handler) {
+ this.handler = Objects.requireNonNull(handler, "handler");
+ }
+
+ /** Router function for the callback path. */
+ public RouterFunction routes() {
+ return RouterFunctions.route(
+ RequestPredicates.POST("/internal/notification/callbacks/{provider}/{profile}"),
+ handler::handle);
+ }
+}
diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/reactive/NotificationCallbackWebFluxHandler.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/reactive/NotificationCallbackWebFluxHandler.java
new file mode 100644
index 00000000..bdddbb36
--- /dev/null
+++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/reactive/NotificationCallbackWebFluxHandler.java
@@ -0,0 +1,85 @@
+package dev.caskeleton.adapter.inbound.web.notification.platform.callback.reactive;
+
+import dev.caskeleton.adapter.inbound.web.notification.platform.callback.CallbackRequestFactory;
+import dev.caskeleton.application.notification.platform.api.error.CallbackValidationException;
+import dev.caskeleton.application.notification.platform.callback.ProviderCallbackIngestionService;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import org.springframework.core.io.buffer.DataBufferLimitException;
+import org.springframework.http.HttpStatus;
+import org.springframework.web.reactive.function.server.ServerRequest;
+import org.springframework.web.reactive.function.server.ServerResponse;
+import reactor.core.publisher.Mono;
+import reactor.core.scheduler.Schedulers;
+
+/**
+ * Reactive callback endpoint.
+ *
+ * Ingestion is blocking — it writes to the database — so it runs on {@code boundedElastic} and
+ * never on the event loop. Running it inline would stall every other connection the loop is
+ * serving.
+ *
+ *
It shares the canonicalisation and the ingestion service with the servlet endpoint, so a
+ * deployment can switch web stacks without changing what a provider signature is checked against.
+ */
+public final class NotificationCallbackWebFluxHandler {
+
+ private final ProviderCallbackIngestionService ingestion;
+ private final CallbackRequestFactory requestFactory;
+ private final BoundedCallbackBodyReader bodyReader;
+
+ public NotificationCallbackWebFluxHandler(
+ ProviderCallbackIngestionService ingestion,
+ CallbackRequestFactory requestFactory,
+ BoundedCallbackBodyReader bodyReader) {
+ this.ingestion = Objects.requireNonNull(ingestion, "ingestion");
+ this.requestFactory = Objects.requireNonNull(requestFactory, "requestFactory");
+ this.bodyReader = Objects.requireNonNull(bodyReader, "bodyReader");
+ }
+
+ /** Handle one callback. */
+ public Mono handle(ServerRequest request) {
+ String provider = request.pathVariable("provider");
+ String profile = request.pathVariable("profile");
+
+ return bodyReader
+ .read(request)
+ .flatMap(
+ body ->
+ Mono.fromCallable(
+ () ->
+ ingestion.ingest(
+ requestFactory.create(
+ provider,
+ profile,
+ request.uri().toString(),
+ request.method().name(),
+ request.headers().contentType().map(Object::toString),
+ headers(request),
+ body)))
+ .subscribeOn(Schedulers.boundedElastic()))
+ .then(ServerResponse.noContent().build())
+ .onErrorResume(
+ DataBufferLimitException.class,
+ failure -> ServerResponse.status(HttpStatus.CONTENT_TOO_LARGE).build())
+ .onErrorResume(
+ CallbackValidationException.class,
+ failure -> ServerResponse.status(HttpStatus.BAD_REQUEST).build());
+ }
+
+ private static Map> headers(ServerRequest request) {
+ Map> headers = new java.util.LinkedHashMap<>();
+ request
+ .headers()
+ .asHttpHeaders()
+ .forEach((name, values) -> headers.put(name, List.copyOf(values)));
+ return Map.copyOf(headers);
+ }
+
+ /** Content type of a request, if declared. */
+ public static Optional contentType(ServerRequest request) {
+ return request.headers().contentType().map(Object::toString);
+ }
+}
diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/NotificationCallbackMvcControllerTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/NotificationCallbackMvcControllerTest.java
new file mode 100644
index 00000000..0e4d3d48
--- /dev/null
+++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/NotificationCallbackMvcControllerTest.java
@@ -0,0 +1,396 @@
+package dev.caskeleton.adapter.inbound.web.notification.platform.callback;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import dev.caskeleton.application.notification.platform.api.DeliveryAttemptId;
+import dev.caskeleton.application.notification.platform.api.ProviderId;
+import dev.caskeleton.application.notification.platform.api.ProviderProfileId;
+import dev.caskeleton.application.notification.platform.api.error.CallbackValidationException;
+import dev.caskeleton.application.notification.platform.api.error.FailureCategory;
+import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode;
+import dev.caskeleton.application.notification.platform.api.error.NotificationFailureDescriptor;
+import dev.caskeleton.application.notification.platform.callback.AppendEventResult;
+import dev.caskeleton.application.notification.platform.callback.CallbackLimits;
+import dev.caskeleton.application.notification.platform.callback.CallbackRequest;
+import dev.caskeleton.application.notification.platform.callback.CallbackVerificationResult;
+import dev.caskeleton.application.notification.platform.callback.NormalizedProviderEvent;
+import dev.caskeleton.application.notification.platform.callback.ProjectionResult;
+import dev.caskeleton.application.notification.platform.callback.ProviderCallbackAdapter;
+import dev.caskeleton.application.notification.platform.callback.ProviderCallbackAdapterRegistry;
+import dev.caskeleton.application.notification.platform.callback.ProviderCallbackIngestionService;
+import dev.caskeleton.application.notification.platform.callback.ProviderEventLedger;
+import dev.caskeleton.application.notification.platform.callback.ProviderEventProjectionService;
+import dev.caskeleton.application.notification.platform.callback.ProviderEventRecord;
+import dev.caskeleton.application.notification.platform.callback.ProviderEventRecordId;
+import dev.caskeleton.application.notification.platform.callback.VerifiedCallback;
+import dev.caskeleton.application.notification.platform.callback.VerifiedProviderEvent;
+import dev.caskeleton.application.notification.platform.observation.NotificationMetricsPort;
+import dev.caskeleton.application.notification.platform.observation.NotificationSecurityAuditPort;
+import java.nio.charset.StandardCharsets;
+import java.time.Clock;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import org.junit.jupiter.api.Test;
+import org.springframework.http.HttpStatus;
+import org.springframework.mock.web.MockHttpServletRequest;
+
+/**
+ * What the servlet transport is responsible for handing the callback pipeline.
+ *
+ * The pipeline itself belongs to application-core and is tested there. What is only testable
+ * here is the translation: the exact received octets, the externally-visible URL, and headers that
+ * survive the servlet container's own casing. Each is a common cause of a valid webhook failing
+ * verification, and none is visible from a unit test of the provider adapter.
+ *
+ *
The capture point is the provider adapter's {@code verify}, which is the first thing in the
+ * pipeline to see the whole request. It rejects, so the test never needs a ledger.
+ */
+class NotificationCallbackMvcControllerTest {
+
+ private static final Clock CLOCK =
+ Clock.fixed(Instant.parse("2026-08-14T00:00:00Z"), ZoneOffset.UTC);
+ private static final String TRUSTED_PROXY = "10.0.0.1";
+
+ private final List verified = new ArrayList<>();
+ private final List rejections = new ArrayList<>();
+
+ private final NotificationCallbackMvcController controller =
+ new NotificationCallbackMvcController(
+ new ProviderCallbackIngestionService(
+ new CapturingRegistry(),
+ new UnusedLedger(),
+ // Never reached: verification always fails in this fixture, and the pipeline appends
+ // only after a valid signature.
+ new ProviderEventProjectionService(
+ new UnusedLedger(),
+ providerId -> java.util.Optional.empty(),
+ new UnusedAttemptResolver(),
+ new UnusedProjectionStore(),
+ (attempt, facts) -> {
+ throw new UnsupportedOperationException();
+ },
+ new UnusedTransactions(),
+ new DiscardingMetrics()),
+ new UnusedPayloadProtection(),
+ new RecordingSecurityAudit(),
+ new DiscardingMetrics(),
+ CLOCK),
+ new CallbackRequestFactory(new ExternalRequestUrlResolver(Set.of(TRUSTED_PROXY)), CLOCK));
+
+ @Test
+ void theExactReceivedOctetsReachTheAdapterUnparsed() {
+ byte[] body =
+ "MessageSid=SM1&MessageStatus=delivered&Signed=a+b%2Fc".getBytes(StandardCharsets.UTF_8);
+
+ assertThatThrownBy(
+ () ->
+ controller.callback(
+ "twilio", "twilio-primary", request("application/x-www-form-urlencoded"), body))
+ .isInstanceOf(CallbackValidationException.class);
+
+ // Byte for byte, including the percent-encoding a form parse would have consumed and re-encoded
+ // differently — which is the single most common cause of a valid webhook failing its signature.
+ assertThat(verified).hasSize(1);
+ assertThat(verified.get(0).body()).isEqualTo(body);
+ assertThat(verified.get(0).contentType()).contains("application/x-www-form-urlencoded");
+ assertThat(verified.get(0).httpMethod()).isEqualTo("POST");
+ }
+
+ @Test
+ void aForwardedHostFromAnUntrustedPeerIsIgnored() {
+ var request = request("application/json");
+ request.setRemoteAddr("203.0.113.9");
+ request.addHeader("X-Forwarded-Proto", "https");
+ request.addHeader("X-Forwarded-Host", "attacker.example.com");
+
+ assertThatThrownBy(
+ () ->
+ controller.callback(
+ "twilio", "twilio-primary", request, "{}".getBytes(StandardCharsets.UTF_8)))
+ .isInstanceOf(CallbackValidationException.class);
+
+ // Honouring the header unconditionally would let any caller choose the URL that gets verified,
+ // which defeats the signature entirely.
+ assertThat(verified.get(0).externalUrl()).doesNotContain("attacker.example.com");
+ }
+
+ @Test
+ void aForwardedHostFromATrustedProxyBecomesTheCanonicalUrl() {
+ var request = request("application/json");
+ request.setRemoteAddr(TRUSTED_PROXY);
+ request.addHeader("X-Forwarded-Proto", "https");
+ request.addHeader("X-Forwarded-Host", "callback.example.com");
+
+ assertThatThrownBy(
+ () ->
+ controller.callback(
+ "twilio", "twilio-primary", request, "{}".getBytes(StandardCharsets.UTF_8)))
+ .isInstanceOf(CallbackValidationException.class);
+
+ assertThat(verified.get(0).externalUrl())
+ .isEqualTo(
+ "https://callback.example.com/internal/notification/callbacks/twilio/twilio-primary");
+ }
+
+ @Test
+ void headersSurviveTheContainersCasingAndStayAddressableEitherWay() {
+ var request = request("application/json");
+ request.addHeader("X-Twilio-Signature", "abc123");
+
+ assertThatThrownBy(
+ () ->
+ controller.callback(
+ "twilio", "twilio-primary", request, "{}".getBytes(StandardCharsets.UTF_8)))
+ .isInstanceOf(CallbackValidationException.class);
+
+ assertThat(verified.get(0).header("x-twilio-signature")).contains("abc123");
+ assertThat(verified.get(0).header("X-TWILIO-SIGNATURE")).contains("abc123");
+ }
+
+ @Test
+ void aBodyOverTheTransportCeilingIsRefusedBeforeAnyAdapterIsConsulted() {
+ byte[] oversized = new byte[NotificationCallbackMvcController.MAX_BODY_BYTES + 1];
+
+ var response =
+ controller.callback("twilio", "twilio-primary", request("application/json"), oversized);
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONTENT_TOO_LARGE);
+ // Nothing downstream sees it, so no signature check ever runs over an attacker-sized payload.
+ assertThat(verified).isEmpty();
+ }
+
+ @Test
+ void aRejectedCallbackRevealsNothingBeyondTheStatusCode() {
+ var response =
+ controller.onValidationFailure(
+ new CallbackValidationException(
+ NotificationFailureDescriptor.preDispatch(
+ NotificationFailureCode.CALLBACK_SIGNATURE_INVALID,
+ FailureCategory.CALLBACK_VALIDATION_FAILURE)));
+
+ // The endpoint is unauthenticated by design — the signature is the authentication — so an error
+ // body is a free oracle for whoever is probing it.
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
+ assertThat(response.getBody()).isNull();
+ }
+
+ @Test
+ void aRejectedSignatureIsRecordedAsASecurityEventRatherThanADeliveryEvent() {
+ assertThatThrownBy(
+ () ->
+ controller.callback(
+ "twilio",
+ "twilio-primary",
+ request("application/json"),
+ "{}".getBytes(StandardCharsets.UTF_8)))
+ .isInstanceOf(CallbackValidationException.class);
+
+ // Writing it to the ledger would let anyone who can reach the endpoint fill a recipient's
+ // delivery history with noise.
+ assertThat(rejections).containsExactly("SIGNATURE_MISMATCH");
+ }
+
+ private static MockHttpServletRequest request(String contentType) {
+ var request =
+ new MockHttpServletRequest(
+ "POST", "/internal/notification/callbacks/twilio/twilio-primary");
+ request.setContentType(contentType);
+ return request;
+ }
+
+ /** Registry whose adapter records the request and then refuses it. */
+ private final class CapturingRegistry implements ProviderCallbackAdapterRegistry {
+
+ @Override
+ public ProviderCallbackAdapter require(ProviderProfileId profileId) {
+ return new ProviderCallbackAdapter() {
+
+ @Override
+ public ProviderId providerId() {
+ return new ProviderId("twilio");
+ }
+
+ @Override
+ public CallbackVerificationResult verify(CallbackRequest request) {
+ verified.add(request);
+ return CallbackVerificationResult.invalid("SIGNATURE_MISMATCH");
+ }
+
+ @Override
+ public List normalize(VerifiedCallback callback) {
+ throw new UnsupportedOperationException("verification always fails in this fixture");
+ }
+ };
+ }
+
+ @Override
+ public CallbackLimits limitsFor(ProviderProfileId profileId) {
+ return new CallbackLimits(
+ 65_536L, Set.of("application/json", "application/x-www-form-urlencoded"));
+ }
+ }
+
+ /** Security audit that keeps the rejection reason. */
+ private final class RecordingSecurityAudit implements NotificationSecurityAuditPort {
+
+ @Override
+ public void callbackSignatureRejected(ProviderProfileId profileId, String reasonCode) {
+ rejections.add(reasonCode);
+ }
+
+ @Override
+ public void callbackRejectedByLimit(ProviderProfileId profileId, String reasonCode) {
+ rejections.add(reasonCode);
+ }
+ }
+
+ /** Metrics are exercised elsewhere; discarding them keeps this test about the transport. */
+ private static final class DiscardingMetrics implements NotificationMetricsPort {
+
+ @Override
+ public void increment(String metricName, Map tags) {
+ // Intentionally empty.
+ }
+
+ @Override
+ public void record(String metricName, Map tags, Duration value) {
+ // Intentionally empty.
+ }
+
+ @Override
+ public void gauge(String metricName, Map tags, double value) {
+ // Intentionally empty.
+ }
+ }
+
+ /** Never reached: attempt correlation happens only for an accepted callback. */
+ private static final class UnusedAttemptResolver
+ implements dev.caskeleton.application.notification.platform.callback
+ .DeliveryAttemptResolverPort {
+
+ @Override
+ public java.util.Optional<
+ dev.caskeleton.application.notification.platform.callback.DeliveryAttemptSnapshot>
+ byAttemptId(DeliveryAttemptId attemptId) {
+ return java.util.Optional.empty();
+ }
+
+ @Override
+ public java.util.Optional<
+ dev.caskeleton.application.notification.platform.callback.DeliveryAttemptSnapshot>
+ byProviderRequestId(ProviderProfileId profileId, String providerRequestIdHash) {
+ return java.util.Optional.empty();
+ }
+ }
+
+ /** Never reached: projection runs only after a signature has been accepted. */
+ private static final class UnusedProjectionStore
+ implements dev.caskeleton.application.notification.platform.callback
+ .DeliveryProjectionStorePort {
+
+ @Override
+ public dev.caskeleton.application.notification.platform.callback.DeliveryProjection load(
+ DeliveryAttemptId attemptId) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public void save(
+ DeliveryAttemptId attemptId,
+ dev.caskeleton.application.notification.platform.callback.DeliveryProjection projection) {
+ throw new UnsupportedOperationException();
+ }
+ }
+
+ /** Never reached: nothing in this fixture gets as far as a transaction. */
+ private static final class UnusedTransactions
+ implements dev.caskeleton.application.transaction.TransactionPort {
+
+ @Override
+ public T inWrite(java.util.function.Supplier action) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public T inRootWrite(java.util.function.Supplier action) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public T inRead(java.util.function.Supplier action) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public T inNew(java.util.function.Supplier action) {
+ throw new UnsupportedOperationException();
+ }
+ }
+
+ /** Never reached: every request in this fixture is rejected before the payload is retained. */
+ private static final class UnusedPayloadProtection
+ implements dev.caskeleton.application.notification.platform.callback
+ .CallbackPayloadProtectionPort {
+
+ @Override
+ public byte[] protectRawPayload(byte[] rawBody) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public String digest(byte[] rawBody) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public String fingerprint(
+ ProviderProfileId profileId, NormalizedProviderEvent event, String rawPayloadDigest) {
+ throw new UnsupportedOperationException();
+ }
+ }
+
+ /** Never reached: every request in this fixture is rejected before the append. */
+ private static final class UnusedLedger implements ProviderEventLedger {
+
+ @Override
+ public AppendEventResult append(VerifiedProviderEvent event) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public AppendEventResult appendAll(List events) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public List pendingProjection(int limit) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public void markApplied(ProviderEventRecordId eventId, ProjectionResult result) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public void markFailed(ProviderEventRecordId eventId, String errorCode) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public List unmatched(int limit) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public List eventsForAttempt(DeliveryAttemptId attemptId) {
+ throw new UnsupportedOperationException();
+ }
+ }
+}
diff --git a/src/adapter/outbound/notification/build.gradle b/src/adapter/outbound/notification/build.gradle
index 555e34ed..01671d8d 100644
--- a/src/adapter/outbound/notification/build.gradle
+++ b/src/adapter/outbound/notification/build.gradle
@@ -6,6 +6,34 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-autoconfigure'
implementation 'org.springframework:spring-web' // Slack webhook client (RestClient)
implementation 'org.slf4j:slf4j-api'
+
+ // Notification Delivery Platform.
+ // - mail: the SMTP provider adapter is built on JavaMailSender/MimeMessageHelper, which is where
+ // multipart/alternative, inline resources and header validation already live. Rebuilding MIME
+ // by hand to avoid one dependency would be the more dangerous choice.
+ // - jackson-databind: provider payloads, callback bodies and the canonical variables payload are
+ // JSON. It stays inside this adapter; application-core never sees a JSON type.
+ // - reactor-core: only the optional Reactor facade uses it. The core async type stays
+ // CompletionStage, so nothing else on this classpath depends on Reactor.
+ implementation 'org.springframework.boot:spring-boot-starter-mail'
+ implementation 'org.springframework.boot:spring-boot-starter-json'
+ implementation 'io.projectreactor:reactor-core'
+ // JSON Schema 2020-12 validation of template variables, using the same validator and version the
+ // messaging adapter already depends on rather than a second implementation of the same spec.
+ // The YAML dataformat is excluded: schemas are supplied as JSON strings, so pulling a YAML
+ // parser onto the runtime classpath would add attack surface for a format nothing reads.
+ // Thymeleaf is the reference HTML renderer, added as the engine only — not the Spring
+ // starter, which would drag a view resolver and a servlet integration onto an outbound
+ // adapter that renders strings and never serves a request.
+ implementation 'org.thymeleaf:thymeleaf'
+
+ implementation('com.networknt:json-schema-validator:3.0.2') {
+ exclude group: 'tools.jackson.dataformat', module: 'jackson-dataformat-yaml'
+ exclude group: 'com.fasterxml.jackson.dataformat', module: 'jackson-dataformat-yaml'
+ }
+
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
+
+ testImplementation 'io.projectreactor:reactor-test'
}
tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8' }
diff --git a/src/adapter/outbound/notification/gradle.lockfile b/src/adapter/outbound/notification/gradle.lockfile
index 518fe132..d59942f4 100644
--- a/src/adapter/outbound/notification/gradle.lockfile
+++ b/src/adapter/outbound/notification/gradle.lockfile
@@ -1,23 +1,24 @@
# This is a Gradle generated file for dependency locking.
# Manual edits can break the build and are not advised.
# This file is expected to be part of source control.
-biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=testCompileClasspath
-ch.qos.logback:logback-classic:1.5.21=testCompileClasspath,testRuntimeClasspath
-ch.qos.logback:logback-core:1.5.21=testCompileClasspath,testRuntimeClasspath
-com.fasterxml.jackson.core:jackson-annotations:2.20=testCompileClasspath,testRuntimeClasspath
+biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,testCompileClasspath
+ch.qos.logback:logback-classic:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
+ch.qos.logback:logback-core:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
+com.ethlo.time:itu:1.14.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
+com.fasterxml.jackson.core:jackson-annotations:2.20=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor
com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs
-com.github.spotbugs:spotbugs-annotations:4.8.6=testCompileClasspath
+com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,testCompileClasspath
com.github.spotbugs:spotbugs:4.10.2=spotbugs
com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs
com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor
com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor
com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor
-com.google.code.findbugs:jsr305:3.0.2=checkstyle,spotbugs,testCompileClasspath
+com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,spotbugs,testCompileClasspath
com.google.code.gson:gson:2.13.2=spotbugs
com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor
-com.google.errorprone:error_prone_annotations:2.38.0=testCompileClasspath
+com.google.errorprone:error_prone_annotations:2.38.0=compileClasspath,testCompileClasspath
com.google.errorprone:error_prone_annotations:2.41.0=spotbugs
com.google.errorprone:error_prone_annotations:2.47.0=checkstyle
com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor
@@ -32,6 +33,7 @@ com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnno
com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor
com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins
com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath
+com.networknt:json-schema-validator:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.puppycrawl.tools:checkstyle:13.5.0=checkstyle
com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath
commons-beanutils:commons-beanutils:1.11.0=checkstyle
@@ -43,8 +45,11 @@ io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnota
io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor
io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
-jakarta.activation:jakarta.activation-api:2.1.4=testCompileClasspath,testRuntimeClasspath
-jakarta.annotation:jakarta.annotation-api:3.0.0=testCompileClasspath,testRuntimeClasspath
+io.projectreactor:reactor-core:3.8.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
+io.projectreactor:reactor-test:3.8.0=testCompileClasspath,testRuntimeClasspath
+jakarta.activation:jakarta.activation-api:2.1.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
+jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
+jakarta.mail:jakarta.mail-api:2.1.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=testCompileClasspath,testRuntimeClasspath
javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor
jaxen:jaxen:2.0.0=spotbugs
@@ -53,6 +58,7 @@ net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath
net.minidev:accessors-smart:2.6.0=testCompileClasspath,testRuntimeClasspath
net.minidev:json-smart:2.6.0=testCompileClasspath,testRuntimeClasspath
net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs
+ognl:ognl:3.3.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.antlr:antlr4-runtime:4.13.2=checkstyle
org.apache.bcel:bcel:6.12.0=spotbugs
org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs
@@ -60,9 +66,9 @@ org.apache.commons:commons-text:1.15.0=spotbugs
org.apache.commons:commons-text:1.3=checkstyle
org.apache.httpcomponents:httpclient:4.5.13=checkstyle
org.apache.httpcomponents:httpcore:4.4.16=checkstyle
-org.apache.logging.log4j:log4j-api:2.25.2=spotbugs,testCompileClasspath,testRuntimeClasspath
+org.apache.logging.log4j:log4j-api:2.25.2=compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
org.apache.logging.log4j:log4j-core:2.25.2=spotbugs
-org.apache.logging.log4j:log4j-to-slf4j:2.25.2=testCompileClasspath,testRuntimeClasspath
+org.apache.logging.log4j:log4j-to-slf4j:2.25.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.maven.doxia:doxia-core:1.12.0=checkstyle
org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle
org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle
@@ -73,14 +79,18 @@ org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=testCompileClasspath,test
org.apache.xbean:xbean-reflect:3.7=checkstyle
org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath
org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath
+org.attoparser:attoparser:2.0.7.RELEASE=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.awaitility:awaitility:4.3.0=testCompileClasspath,testRuntimeClasspath
org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle
org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle
org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle
org.codehaus.plexus:plexus-utils:3.3.0=checkstyle
org.dom4j:dom4j:2.2.0=spotbugs
+org.eclipse.angus:angus-activation:2.0.3=runtimeClasspath,testRuntimeClasspath
+org.eclipse.angus:angus-mail:2.0.5=runtimeClasspath,testRuntimeClasspath
org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath
org.javassist:javassist:3.28.0-GA=checkstyle
+org.javassist:javassist:3.29.0-GA=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath
@@ -95,10 +105,10 @@ org.mockito:mockito-core:5.20.0=mockitoAgent,testCompileClasspath,testRuntimeCla
org.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath
org.objenesis:objenesis:3.3=testRuntimeClasspath
org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath
-org.osgi:org.osgi.annotation.bundle:2.0.0=testCompileClasspath
-org.osgi:org.osgi.annotation.versioning:1.1.2=testCompileClasspath
-org.osgi:org.osgi.resource:1.0.0=testCompileClasspath
-org.osgi:org.osgi.service.serviceloader:1.0.0=testCompileClasspath
+org.osgi:org.osgi.annotation.bundle:2.0.0=compileClasspath,testCompileClasspath
+org.osgi:org.osgi.annotation.versioning:1.1.2=compileClasspath,testCompileClasspath
+org.osgi:org.osgi.resource:1.0.0=compileClasspath,testCompileClasspath
+org.osgi:org.osgi.service.serviceloader:1.0.0=compileClasspath,testCompileClasspath
org.ow2.asm:asm-analysis:9.10.1=spotbugs
org.ow2.asm:asm-commons:9.10.1=spotbugs
org.ow2.asm:asm-tree:9.10.1=spotbugs
@@ -106,28 +116,32 @@ org.ow2.asm:asm-util:9.10.1=spotbugs
org.ow2.asm:asm:9.10.1=spotbugs
org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath
org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor
+org.reactivestreams:reactive-streams:1.0.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.reflections:reflections:0.10.2=checkstyle
org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath
-org.slf4j:jul-to-slf4j:2.0.17=testCompileClasspath,testRuntimeClasspath
+org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath
org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j
org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-configuration-processor:4.0.0=annotationProcessor
org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-http-converter:4.0.0=testCompileClasspath,testRuntimeClasspath
-org.springframework.boot:spring-boot-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath
+org.springframework.boot:spring-boot-jackson:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
+org.springframework.boot:spring-boot-mail:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-restclient:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-servlet:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath
-org.springframework.boot:spring-boot-starter-logging:4.0.0=testCompileClasspath,testRuntimeClasspath
+org.springframework.boot:spring-boot-starter-json:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
+org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
+org.springframework.boot:spring-boot-starter-mail:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath
-org.springframework.boot:spring-boot-starter:4.0.0=testCompileClasspath,testRuntimeClasspath
+org.springframework.boot:spring-boot-starter:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath
@@ -137,16 +151,19 @@ org.springframework.boot:spring-boot-webmvc:4.0.0=testCompileClasspath,testRunti
org.springframework.boot:spring-boot:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-aop:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-beans:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
+org.springframework:spring-context-support:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-context:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-core:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-expression:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-test:7.0.1=testCompileClasspath,testRuntimeClasspath
org.springframework:spring-web:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-webmvc:7.0.1=testCompileClasspath,testRuntimeClasspath
+org.thymeleaf:thymeleaf:3.1.3.RELEASE=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
+org.unbescape:unbescape:1.1.6.RELEASE=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath
-org.yaml:snakeyaml:2.5=testCompileClasspath,testRuntimeClasspath
-tools.jackson.core:jackson-core:3.0.2=testCompileClasspath,testRuntimeClasspath
-tools.jackson.core:jackson-databind:3.0.2=testCompileClasspath,testRuntimeClasspath
-tools.jackson:jackson-bom:3.0.2=testCompileClasspath,testRuntimeClasspath
+org.yaml:snakeyaml:2.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
+tools.jackson.core:jackson-core:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
+tools.jackson.core:jackson-databind:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
+tools.jackson:jackson-bom:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
empty=
diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/admin/AdminAuthorizationGuard.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/admin/AdminAuthorizationGuard.java
new file mode 100644
index 00000000..38949c30
--- /dev/null
+++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/admin/AdminAuthorizationGuard.java
@@ -0,0 +1,41 @@
+package dev.caskeleton.adapter.outbound.notification.platform.admin;
+
+import dev.caskeleton.application.notification.platform.admin.AdminAccessDeniedException;
+import dev.caskeleton.application.notification.platform.admin.AdminActor;
+import dev.caskeleton.application.notification.platform.admin.NotificationAdminAuthority;
+import dev.caskeleton.application.notification.platform.api.TenantId;
+import java.util.Objects;
+import java.util.Optional;
+
+/**
+ * Operator authority check.
+ *
+ * Application authority never grants an operator authority. The two planes are separated so that
+ * a compromised application credential cannot redrive a message or lift a suppression — the actions
+ * whose whole purpose is to override the platform's own safety decisions.
+ */
+public final class AdminAuthorizationGuard {
+
+ /** Require an authority, or refuse. */
+ public void require(AdminActor actor, NotificationAdminAuthority authority) {
+ Objects.requireNonNull(actor, "actor");
+ Objects.requireNonNull(authority, "authority");
+ if (!actor.holds(authority)) {
+ throw new AdminAccessDeniedException(authority);
+ }
+ }
+
+ /**
+ * Require that the actor may act on a tenant.
+ *
+ *
An actor with no tenant is a global operator; one bound to a tenant may only act inside it.
+ */
+ public void requireTenant(AdminActor actor, TenantId tenantId) {
+ Objects.requireNonNull(actor, "actor");
+ Objects.requireNonNull(tenantId, "tenantId");
+ Optional scope = actor.tenantId();
+ if (scope.isPresent() && !scope.get().equals(tenantId)) {
+ throw new AdminAccessDeniedException(NotificationAdminAuthority.SUPPRESS);
+ }
+ }
+}
diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/admin/DuplicateRiskGuard.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/admin/DuplicateRiskGuard.java
new file mode 100644
index 00000000..4a728134
--- /dev/null
+++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/admin/DuplicateRiskGuard.java
@@ -0,0 +1,29 @@
+package dev.caskeleton.adapter.outbound.notification.platform.admin;
+
+import dev.caskeleton.application.notification.platform.admin.DuplicateRiskApprovalRequiredException;
+import dev.caskeleton.application.notification.platform.api.delivery.AttemptConfirmation;
+import dev.caskeleton.application.notification.platform.callback.DeliveryAttemptSnapshot;
+import java.util.Objects;
+
+/**
+ * Blocks an unapproved redrive of an ambiguous attempt.
+ *
+ * The platform cannot tell whether the first submission reached the user, so re-sending is a
+ * decision with a real cost that only a human can accept. Requiring the approval flag makes that
+ * acceptance an explicit, audited act rather than a default.
+ */
+public final class DuplicateRiskGuard {
+
+ /** Verify the operator accepted the duplicate risk when one exists. */
+ public void verify(DeliveryAttemptSnapshot attempt, boolean approved) {
+ Objects.requireNonNull(attempt, "attempt");
+ boolean risky =
+ attempt.confirmation() == AttemptConfirmation.AMBIGUOUS
+ || attempt.submissionOutcome()
+ == dev.caskeleton.application.notification.platform.api.delivery.SubmissionOutcome
+ .CONFIRMED_ACCEPTED;
+ if (risky && !approved) {
+ throw new DuplicateRiskApprovalRequiredException();
+ }
+ }
+}
diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/admin/NotificationAdminServiceImpl.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/admin/NotificationAdminServiceImpl.java
new file mode 100644
index 00000000..5bfe6d37
--- /dev/null
+++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/admin/NotificationAdminServiceImpl.java
@@ -0,0 +1,325 @@
+package dev.caskeleton.adapter.outbound.notification.platform.admin;
+
+import dev.caskeleton.adapter.outbound.notification.platform.dispatch.ProviderRuntimeRegistry;
+import dev.caskeleton.application.notification.platform.admin.AdminActor;
+import dev.caskeleton.application.notification.platform.admin.AdminOperationResult;
+import dev.caskeleton.application.notification.platform.admin.AdminOperationStorePort;
+import dev.caskeleton.application.notification.platform.admin.NotificationAdminAuthority;
+import dev.caskeleton.application.notification.platform.admin.NotificationAdminService;
+import dev.caskeleton.application.notification.platform.admin.ReconcileCommand;
+import dev.caskeleton.application.notification.platform.admin.RedriveCommand;
+import dev.caskeleton.application.notification.platform.admin.SetProviderStateCommand;
+import dev.caskeleton.application.notification.platform.admin.SuppressCommand;
+import dev.caskeleton.application.notification.platform.api.DeliveryAttemptId;
+import dev.caskeleton.application.notification.platform.api.delivery.RecipientDeliveryState;
+import dev.caskeleton.application.notification.platform.callback.DeliveryAttemptSnapshot;
+import dev.caskeleton.application.notification.platform.dispatch.DeliveryAttemptStorePort;
+import dev.caskeleton.application.notification.platform.dispatch.RecipientDeliveryStorePort;
+import dev.caskeleton.application.notification.platform.dispatch.ReconciliationService;
+import dev.caskeleton.application.notification.platform.observation.NotificationAuditEvent;
+import dev.caskeleton.application.notification.platform.observation.NotificationAuditPort;
+import dev.caskeleton.application.notification.platform.policy.SuppressionEntry;
+import dev.caskeleton.application.notification.platform.policy.SuppressionId;
+import dev.caskeleton.application.notification.platform.policy.SuppressionSource;
+import dev.caskeleton.application.notification.platform.policy.SuppressionStorePort;
+import dev.caskeleton.application.notification.platform.provider.ProviderRuntimeState;
+import dev.caskeleton.application.transaction.TransactionPort;
+import java.time.Clock;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.UUID;
+
+/**
+ * N4 operator plane.
+ *
+ *
Four properties hold for every operation: a separate authority, an idempotent operation id, a
+ * recorded reason, and an audit row. The idempotency matters more than it looks — an operator
+ * retrying a redrive after a timeout must not send the message twice, which is exactly the failure
+ * the operation is trying to repair.
+ *
+ *
A dry run reads and reports but writes nothing, so an operator can see the blast radius of a
+ * bulk action before committing to it.
+ */
+public final class NotificationAdminServiceImpl implements NotificationAdminService {
+
+ private final AdminAuthorizationGuard authorization;
+ private final DuplicateRiskGuard duplicateRiskGuard;
+ private final DeliveryAttemptStorePort attempts;
+ private final RecipientDeliveryStorePort recipients;
+ private final ReconciliationService reconciliation;
+ private final SuppressionStorePort suppressions;
+ private final ProviderRuntimeRegistry runtimes;
+ private final AdminOperationStorePort operations;
+ private final NotificationAuditPort audit;
+ private final TransactionPort transactions;
+ private final Clock clock;
+
+ public NotificationAdminServiceImpl(
+ AdminAuthorizationGuard authorization,
+ DuplicateRiskGuard duplicateRiskGuard,
+ DeliveryAttemptStorePort attempts,
+ RecipientDeliveryStorePort recipients,
+ ReconciliationService reconciliation,
+ SuppressionStorePort suppressions,
+ ProviderRuntimeRegistry runtimes,
+ AdminOperationStorePort operations,
+ NotificationAuditPort audit,
+ TransactionPort transactions,
+ Clock clock) {
+ this.authorization = Objects.requireNonNull(authorization, "authorization");
+ this.duplicateRiskGuard = Objects.requireNonNull(duplicateRiskGuard, "duplicateRiskGuard");
+ this.attempts = Objects.requireNonNull(attempts, "attempts");
+ this.recipients = Objects.requireNonNull(recipients, "recipients");
+ this.reconciliation = Objects.requireNonNull(reconciliation, "reconciliation");
+ this.suppressions = Objects.requireNonNull(suppressions, "suppressions");
+ this.runtimes = Objects.requireNonNull(runtimes, "runtimes");
+ this.operations = Objects.requireNonNull(operations, "operations");
+ this.audit = Objects.requireNonNull(audit, "audit");
+ this.transactions = Objects.requireNonNull(transactions, "transactions");
+ this.clock = Objects.requireNonNull(clock, "clock");
+ }
+
+ @Override
+ public AdminOperationResult redrive(RedriveCommand command, AdminActor actor) {
+ Objects.requireNonNull(command, "command");
+ authorization.require(actor, NotificationAdminAuthority.REDRIVE);
+
+ Optional replayed = operations.findByOperationId(command.operationId());
+ if (replayed.isPresent()) {
+ return replayed.get();
+ }
+
+ DeliveryAttemptSnapshot original =
+ attempts
+ .snapshot(command.attemptId())
+ .orElseThrow(() -> new IllegalStateException("delivery attempt is not available"));
+ authorization.requireTenant(actor, original.tenantId());
+ duplicateRiskGuard.verify(original, command.approveDuplicateRisk());
+
+ if (command.dryRun()) {
+ return new AdminOperationResult(
+ command.operationId(),
+ true,
+ 1,
+ Optional.of(original.notificationId()),
+ Optional.of(original.recipientDeliveryId()),
+ Optional.empty(),
+ List.of("DRY_RUN"));
+ }
+
+ return transactions.inWrite(
+ () -> {
+ // The logical identities are preserved and only the attempt is new, so the history stays
+ // one story rather than becoming two unrelated notifications.
+ recipients.transition(
+ original.recipientDeliveryId(),
+ RecipientDeliveryState.READY_TO_DISPATCH,
+ Optional.of(clock.instant()));
+
+ AdminOperationResult result =
+ new AdminOperationResult(
+ command.operationId(),
+ false,
+ 1,
+ Optional.of(original.notificationId()),
+ Optional.of(original.recipientDeliveryId()),
+ Optional.empty(),
+ List.of(command.reason()));
+ audit.record(
+ new NotificationAuditEvent(
+ "ADMIN_REDRIVE",
+ actor.actorRef(),
+ Optional.of(command.reason()),
+ Optional.of(command.operationId()),
+ clock.instant(),
+ Map.of(
+ "provider", original.providerId().value(),
+ "channel", original.channel().name())));
+ return operations.save(result, actor, "ADMIN_REDRIVE");
+ });
+ }
+
+ @Override
+ public AdminOperationResult reconcile(ReconcileCommand command, AdminActor actor) {
+ Objects.requireNonNull(command, "command");
+ authorization.require(actor, NotificationAdminAuthority.RECONCILE);
+
+ Optional replayed = operations.findByOperationId(command.operationId());
+ if (replayed.isPresent()) {
+ return replayed.get();
+ }
+ if (command.dryRun()) {
+ return new AdminOperationResult(
+ command.operationId(),
+ true,
+ command.attemptIds().size(),
+ Optional.empty(),
+ Optional.empty(),
+ Optional.empty(),
+ List.of("DRY_RUN"));
+ }
+
+ List reasons = new ArrayList<>();
+ int reconciled = 0;
+ for (DeliveryAttemptId attemptId : command.attemptIds()) {
+ reconciliation.reconcile(attemptId);
+ reconciled++;
+ }
+ reasons.add(command.reason());
+
+ AdminOperationResult result =
+ new AdminOperationResult(
+ command.operationId(),
+ false,
+ reconciled,
+ Optional.empty(),
+ Optional.empty(),
+ Optional.empty(),
+ List.copyOf(reasons));
+ audit.record(
+ new NotificationAuditEvent(
+ "ADMIN_RECONCILE",
+ actor.actorRef(),
+ Optional.of(command.reason()),
+ Optional.of(command.operationId()),
+ clock.instant(),
+ Map.of()));
+ return operations.save(result, actor, "ADMIN_RECONCILE");
+ }
+
+ @Override
+ public AdminOperationResult suppress(SuppressCommand command, AdminActor actor) {
+ Objects.requireNonNull(command, "command");
+ authorization.require(actor, NotificationAdminAuthority.SUPPRESS);
+ authorization.requireTenant(actor, command.tenantId());
+
+ Optional replayed = operations.findByOperationId(command.operationId());
+ if (replayed.isPresent()) {
+ return replayed.get();
+ }
+ if (command.dryRun()) {
+ return new AdminOperationResult(
+ command.operationId(),
+ true,
+ 1,
+ Optional.empty(),
+ Optional.empty(),
+ Optional.empty(),
+ List.of("DRY_RUN"));
+ }
+
+ return transactions.inWrite(
+ () -> {
+ int affected;
+ if (command.remove()) {
+ // Removal is by fingerprint match rather than by id, because an operator lifting a
+ // suppression knows the target, not the row identifier the platform assigned.
+ affected =
+ suppressions
+ .activeFor(
+ command.tenantId(), command.targetFingerprint(), clock.instant())
+ .stream()
+ .map(entry -> suppressions.remove(command.tenantId(), entry.id()))
+ .filter(Optional::isPresent)
+ .count()
+ > 0
+ ? 1
+ : 0;
+ } else {
+ suppressions.upsert(
+ new SuppressionEntry(
+ new SuppressionId(UUID.randomUUID()),
+ command.tenantId(),
+ command.scope(),
+ command.reason(),
+ command.targetFingerprint(),
+ Optional.empty(),
+ clock.instant(),
+ command.expiresAt(),
+ SuppressionSource.ADMIN));
+ affected = 1;
+ }
+
+ AdminOperationResult result =
+ new AdminOperationResult(
+ command.operationId(),
+ false,
+ affected,
+ Optional.empty(),
+ Optional.empty(),
+ Optional.empty(),
+ List.of(command.reasonText()));
+ audit.record(
+ new NotificationAuditEvent(
+ command.remove() ? "ADMIN_SUPPRESSION_REMOVED" : "ADMIN_SUPPRESSION_ADDED",
+ actor.actorRef(),
+ Optional.of(command.reason().name()),
+ Optional.of(command.operationId()),
+ clock.instant(),
+ Map.of()));
+ return operations.save(
+ result, actor, command.remove() ? "ADMIN_SUPPRESS_REMOVE" : "ADMIN_SUPPRESS_ADD");
+ });
+ }
+
+ @Override
+ public AdminOperationResult setProviderState(SetProviderStateCommand command, AdminActor actor) {
+ Objects.requireNonNull(command, "command");
+ authorization.require(actor, NotificationAdminAuthority.PROVIDER_CONTROL);
+
+ Optional replayed = operations.findByOperationId(command.operationId());
+ if (replayed.isPresent()) {
+ return replayed.get();
+ }
+ if (command.dryRun()) {
+ return new AdminOperationResult(
+ command.operationId(),
+ true,
+ 1,
+ Optional.empty(),
+ Optional.empty(),
+ Optional.empty(),
+ List.of("DRY_RUN"));
+ }
+
+ var runtime = runtimes.current(command.profileId());
+ switch (command.desiredState()) {
+ case DISABLED -> runtime.markDisabled();
+ case DRAINING -> runtime.markDraining();
+ case HEALTHY -> runtime.markHealthy();
+ case DEGRADED -> runtime.markDegraded(command.reason());
+ case THROTTLED -> runtime.markThrottled();
+ case AUTHENTICATION_FAILED -> runtime.markAuthenticationFailed(command.reason());
+ }
+
+ AdminOperationResult result =
+ new AdminOperationResult(
+ command.operationId(),
+ false,
+ 1,
+ Optional.empty(),
+ Optional.empty(),
+ Optional.empty(),
+ List.of(command.reason()));
+ audit.record(
+ new NotificationAuditEvent(
+ "ADMIN_PROVIDER_STATE",
+ actor.actorRef(),
+ Optional.of(command.reason()),
+ Optional.of(command.operationId()),
+ clock.instant(),
+ Map.of(
+ "providerProfile", command.profileId().value(),
+ "status", command.desiredState().name())));
+ return operations.save(result, actor, "ADMIN_PROVIDER_STATE");
+ }
+
+ /** Current state of a provider runtime, for the health endpoint. */
+ public ProviderRuntimeState providerState(
+ dev.caskeleton.application.notification.platform.api.ProviderProfileId profileId) {
+ return runtimes.state(profileId);
+ }
+}
diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/autoconfigure/NotificationPlatformAutoConfiguration.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/autoconfigure/NotificationPlatformAutoConfiguration.java
new file mode 100644
index 00000000..4e1c2642
--- /dev/null
+++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/autoconfigure/NotificationPlatformAutoConfiguration.java
@@ -0,0 +1,105 @@
+package dev.caskeleton.adapter.outbound.notification.platform.autoconfigure;
+
+import dev.caskeleton.adapter.outbound.notification.platform.provider.http.JdkNotificationHttpGateway;
+import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpGateway;
+import dev.caskeleton.adapter.outbound.notification.platform.security.AesGcmContactPointProtector;
+import dev.caskeleton.adapter.outbound.notification.platform.template.JacksonNotificationVariablesCodec;
+import dev.caskeleton.adapter.outbound.notification.platform.template.JsonSchemaVariableValidator;
+import dev.caskeleton.adapter.outbound.notification.platform.template.NotificationTemplateEngine;
+import dev.caskeleton.adapter.outbound.notification.platform.template.PlaceholderTemplateEngine;
+import dev.caskeleton.adapter.outbound.notification.platform.template.Sha256MessageDigestAdapter;
+import dev.caskeleton.adapter.outbound.notification.platform.template.ThymeleafStringTemplateEngine;
+import dev.caskeleton.application.notification.platform.dispatch.MessageDigestPort;
+import dev.caskeleton.application.notification.platform.dispatch.NotificationVariablesCodecPort;
+import dev.caskeleton.application.notification.platform.security.ContactPointProtector;
+import dev.caskeleton.application.notification.platform.security.SecretMaterialProvider;
+import dev.caskeleton.application.notification.platform.template.TemplateVariableValidator;
+import java.time.Duration;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+/**
+ * Notification platform wiring.
+ *
+ * Everything is opt-in and conditional. The platform contributes no beans unless it is enabled,
+ * and the contact point protector only appears once a secret provider exists — because a protector
+ * without keys would fail on the first delivery instead of at startup.
+ */
+@Configuration(proxyBeanMethods = false)
+@EnableConfigurationProperties(NotificationPlatformSettings.class)
+@ConditionalOnProperty(
+ prefix = "ca-skeleton.notification.platform",
+ name = "enabled",
+ havingValue = "true")
+public class NotificationPlatformAutoConfiguration {
+
+ /** Canonical variables codec. */
+ @Bean
+ @ConditionalOnMissingBean
+ public NotificationVariablesCodecPort notificationVariablesCodec() {
+ return new JacksonNotificationVariablesCodec();
+ }
+
+ /** Request fingerprint hashing. */
+ @Bean
+ @ConditionalOnMissingBean
+ public MessageDigestPort notificationMessageDigest() {
+ return new Sha256MessageDigestAdapter();
+ }
+
+ /** JSON Schema 2020-12 variable validation. */
+ @Bean
+ @ConditionalOnMissingBean
+ public TemplateVariableValidator notificationTemplateVariableValidator() {
+ return new JsonSchemaVariableValidator();
+ }
+
+ /**
+ * Template engine, defaulting to the deterministic placeholder substitution.
+ *
+ *
Thymeleaf is the opt-in alternative: it escapes by default, which matters for HTML email
+ * bodies built from application input. The default stays the placeholder engine because it has no
+ * expression evaluator at all, and an unknown engine name fails the boot rather than quietly
+ * falling back — a deployment that thought it had escaping and did not is the worse outcome.
+ */
+ @Bean
+ @ConditionalOnMissingBean
+ public NotificationTemplateEngine notificationTemplateEngine(
+ @Value("${ca-skeleton.notification.platform.template.engine:placeholder}") String engine) {
+ return switch (engine.toLowerCase(java.util.Locale.ROOT)) {
+ case "placeholder" -> new PlaceholderTemplateEngine();
+ case "thymeleaf" -> new ThymeleafStringTemplateEngine();
+ default ->
+ throw new IllegalArgumentException(
+ "ca-skeleton.notification.platform.template.engine must be"
+ + " 'placeholder' or 'thymeleaf', not '"
+ + engine
+ + "'");
+ };
+ }
+
+ /** Contact point protection, only once key material is available. */
+ @Bean
+ @ConditionalOnBean(SecretMaterialProvider.class)
+ @ConditionalOnMissingBean
+ public ContactPointProtector notificationContactPointProtector(SecretMaterialProvider secrets) {
+ return new AesGcmContactPointProtector(secrets);
+ }
+
+ /**
+ * Default provider transport.
+ *
+ *
Replaced in the composition root when the HTTP Client Platform is bound, which is the
+ * supported way to reuse its TLS, circuit-breaker and SSRF policy.
+ */
+ @Bean
+ @ConditionalOnMissingBean
+ public NotificationHttpGateway notificationHttpGateway() {
+ return new JdkNotificationHttpGateway(Duration.ofSeconds(2));
+ }
+}
diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/autoconfigure/NotificationPlatformSettings.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/autoconfigure/NotificationPlatformSettings.java
new file mode 100644
index 00000000..51147faf
--- /dev/null
+++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/autoconfigure/NotificationPlatformSettings.java
@@ -0,0 +1,154 @@
+package dev.caskeleton.adapter.outbound.notification.platform.autoconfigure;
+
+import java.time.Duration;
+import java.util.Map;
+import java.util.Objects;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+/**
+ * Bound notification platform configuration.
+ *
+ *
Validation happens in the constructor, so a misconfiguration fails the boot rather than
+ * surfacing as a delivery incident hours later. Everything is bounded: there is no property whose
+ * value may be "unlimited", because an unbounded queue or payload is a resource failure waiting for
+ * the first burst.
+ */
+@ConfigurationProperties("ca-skeleton.notification.platform")
+public record NotificationPlatformSettings(
+ boolean enabled, Dispatch dispatch, Callbacks callbacks, Map providers) {
+
+ public NotificationPlatformSettings {
+ dispatch = dispatch == null ? Dispatch.defaults() : dispatch;
+ callbacks = callbacks == null ? Callbacks.defaults() : callbacks;
+ providers = providers == null ? Map.of() : Map.copyOf(providers);
+ providers.forEach((id, provider) -> provider.validate(id));
+ }
+
+ /** Dispatch runtime bounds. */
+ public record Dispatch(
+ int claimBatchSize,
+ Duration leaseDuration,
+ Duration pollInterval,
+ int maxGlobalConcurrency,
+ int maxAdditionalAttempts,
+ Duration maxQueueAge,
+ boolean allowAmbiguousFallback) {
+
+ private static final int MAX_CLAIM_BATCH = 1000;
+
+ public Dispatch {
+ Objects.requireNonNull(leaseDuration, "leaseDuration");
+ Objects.requireNonNull(pollInterval, "pollInterval");
+ Objects.requireNonNull(maxQueueAge, "maxQueueAge");
+ if (claimBatchSize < 1 || claimBatchSize > MAX_CLAIM_BATCH) {
+ throw new IllegalArgumentException(
+ "ca-skeleton.notification.platform.dispatch.claim-batch-size must be 1.."
+ + MAX_CLAIM_BATCH);
+ }
+ if (maxGlobalConcurrency < 1) {
+ throw new IllegalArgumentException("max-global-concurrency must be positive");
+ }
+ if (maxAdditionalAttempts < 0) {
+ throw new IllegalArgumentException("max-additional-attempts must not be negative");
+ }
+ if (leaseDuration.isNegative() || leaseDuration.isZero()) {
+ throw new IllegalArgumentException("lease-duration must be positive and finite");
+ }
+ if (leaseDuration.compareTo(pollInterval) <= 0) {
+ throw new IllegalArgumentException("lease-duration must exceed poll-interval");
+ }
+ if (allowAmbiguousFallback) {
+ // Refused outright rather than warned about: automatic fallback after an ambiguous
+ // submission is the configuration that turns an unknown into a guaranteed duplicate.
+ throw new IllegalArgumentException(
+ "allow-ambiguous-fallback is not a supported configuration");
+ }
+ }
+
+ /** Conservative defaults. */
+ public static Dispatch defaults() {
+ return new Dispatch(
+ 100, Duration.ofSeconds(30), Duration.ofMillis(250), 128, 3, Duration.ofHours(24), false);
+ }
+ }
+
+ /** Callback endpoint bounds. */
+ public record Callbacks(boolean enabled, long maxBodyBytes, Duration replaySkew) {
+
+ private static final long MAX_BODY_CEILING = 1_048_576L;
+
+ public Callbacks {
+ Objects.requireNonNull(replaySkew, "replaySkew");
+ if (maxBodyBytes < 1 || maxBodyBytes > MAX_BODY_CEILING) {
+ throw new IllegalArgumentException("max-body-bytes must be 1.." + MAX_BODY_CEILING);
+ }
+ if (replaySkew.isNegative()) {
+ throw new IllegalArgumentException("replay-skew must not be negative");
+ }
+ }
+
+ /** Conservative defaults. */
+ public static Callbacks defaults() {
+ return new Callbacks(false, 65_536L, Duration.ofMinutes(5));
+ }
+ }
+
+ /** One provider profile. */
+ public record Provider(
+ String type,
+ boolean enabled,
+ String environment,
+ String credentialProfile,
+ String topic,
+ String vapidPublicKey,
+ String callbackSigningSecretRef,
+ Duration timeout,
+ int maxConcurrency,
+ int ratePerSecond) {
+
+ /** Fail the boot when a profile cannot possibly work. */
+ public void validate(String profileId) {
+ Objects.requireNonNull(profileId, "profileId");
+ if (!enabled) {
+ return;
+ }
+ require(type != null && !type.isBlank(), profileId, "type is required");
+ require(environment != null && !environment.isBlank(), profileId, "environment is required");
+ require(
+ credentialProfile != null && !credentialProfile.isBlank(),
+ profileId,
+ "credential-profile is required");
+ require(
+ timeout != null && !timeout.isNegative() && !timeout.isZero(),
+ profileId,
+ "timeout must be positive and finite");
+ require(maxConcurrency >= 1, profileId, "max-concurrency must be positive");
+ require(ratePerSecond >= 1, profileId, "rate-limit-per-second must be positive");
+
+ switch (type == null ? "" : type.toUpperCase(java.util.Locale.ROOT)) {
+ case "APNS" ->
+ require(topic != null && !topic.isBlank(), profileId, "APNs profiles require a topic");
+ case "WEB_PUSH" ->
+ require(
+ vapidPublicKey != null && !vapidPublicKey.isBlank(),
+ profileId,
+ "Web Push profiles require a VAPID key");
+ case "TWILIO", "SES" ->
+ require(
+ callbackSigningSecretRef != null && !callbackSigningSecretRef.isBlank(),
+ profileId,
+ "callback-capable profiles require a callback signing secret reference");
+ default -> {
+ // Providers without extra requirements are already covered by the common checks.
+ }
+ }
+ }
+
+ private static void require(boolean condition, String profileId, String message) {
+ if (!condition) {
+ throw new IllegalArgumentException(
+ "notification provider profile '" + profileId + "': " + message);
+ }
+ }
+ }
+}
diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/AttemptPermit.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/AttemptPermit.java
new file mode 100644
index 00000000..d9725875
--- /dev/null
+++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/AttemptPermit.java
@@ -0,0 +1,17 @@
+package dev.caskeleton.adapter.outbound.notification.platform.dispatch;
+
+/**
+ * A held concurrency slot for one provider attempt.
+ *
+ * Closing it is what releases the slot, so every call site uses try-with-resources. The permit
+ * also carries the credential generation the attempt ran under, which is what makes a rotation
+ * auditable after the fact.
+ */
+public interface AttemptPermit extends AutoCloseable {
+
+ /** Credential generation this attempt is bound to. */
+ long generation();
+
+ @Override
+ void close();
+}
diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/CapabilityReconciliationGateway.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/CapabilityReconciliationGateway.java
new file mode 100644
index 00000000..e565164a
--- /dev/null
+++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/CapabilityReconciliationGateway.java
@@ -0,0 +1,52 @@
+package dev.caskeleton.adapter.outbound.notification.platform.dispatch;
+
+import dev.caskeleton.application.notification.platform.api.ProviderProfileId;
+import dev.caskeleton.application.notification.platform.callback.DeliveryAttemptSnapshot;
+import dev.caskeleton.application.notification.platform.dispatch.ReconciliationGatewayPort;
+import dev.caskeleton.application.notification.platform.provider.ReconciliationCapability;
+import dev.caskeleton.application.notification.platform.provider.ReconciliationResult;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+
+/**
+ * Routes a reconciliation to the capability that owns the provider.
+ *
+ *
A profile with no registered capability reports {@code Unsupported} rather than falling back
+ * to a guess. Inventing a final status for a provider that cannot be queried is precisely the
+ * behaviour the ambiguity model exists to prevent.
+ */
+public final class CapabilityReconciliationGateway implements ReconciliationGatewayPort {
+
+ private final Map capabilities;
+ private final ProviderRuntimeRegistry runtimes;
+
+ public CapabilityReconciliationGateway(
+ Map capabilities,
+ ProviderRuntimeRegistry runtimes) {
+ this.capabilities = Map.copyOf(Objects.requireNonNull(capabilities, "capabilities"));
+ this.runtimes = Objects.requireNonNull(runtimes, "runtimes");
+ }
+
+ @Override
+ public boolean supports(ProviderProfileId profileId) {
+ Objects.requireNonNull(profileId, "profileId");
+ return Optional.ofNullable(capabilities.get(profileId))
+ .map(capability -> capability.supports(runtimes.current(profileId).profile()))
+ .orElse(false);
+ }
+
+ @Override
+ public ReconciliationResult reconcile(DeliveryAttemptSnapshot attempt) {
+ Objects.requireNonNull(attempt, "attempt");
+ ReconciliationCapability capability = capabilities.get(attempt.providerProfileId());
+ if (capability == null) {
+ return new ReconciliationResult.Unsupported();
+ }
+ // The permit is taken so a reconciliation backlog cannot become a second load source during the
+ // incident that produced it.
+ try (AttemptPermit permit = runtimes.current(attempt.providerProfileId()).acquireAttempt()) {
+ return capability.reconcile(attempt).toCompletableFuture().join();
+ }
+ }
+}
diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/ConfiguredRoutePlanner.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/ConfiguredRoutePlanner.java
new file mode 100644
index 00000000..3a2b8261
--- /dev/null
+++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/ConfiguredRoutePlanner.java
@@ -0,0 +1,72 @@
+package dev.caskeleton.adapter.outbound.notification.platform.dispatch;
+
+import dev.caskeleton.application.notification.platform.api.ProviderProfileId;
+import dev.caskeleton.application.notification.platform.api.RecipientSpec;
+import dev.caskeleton.application.notification.platform.api.TenantId;
+import dev.caskeleton.application.notification.platform.api.routing.Channel;
+import dev.caskeleton.application.notification.platform.api.routing.DeliveryStrategy;
+import dev.caskeleton.application.notification.platform.api.routing.ExplicitChannel;
+import dev.caskeleton.application.notification.platform.api.routing.OrderedFallback;
+import dev.caskeleton.application.notification.platform.dispatch.NotificationRoutePlannerPort;
+import dev.caskeleton.application.notification.platform.policy.RouteCandidate;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+/**
+ * Turns a strategy into an ordered route plan using the configured channel-to-profile map.
+ *
+ * A channel with no configured provider, or a recipient with no contact point for it, simply
+ * produces no candidate. The routing engine then reports {@code NO_ELIGIBLE_ROUTE} rather than the
+ * dispatcher failing on a null, which is the difference between a diagnosable state and a stack
+ * trace.
+ */
+public final class ConfiguredRoutePlanner implements NotificationRoutePlannerPort {
+
+ private final Map profilesByChannel;
+
+ public ConfiguredRoutePlanner(Map profilesByChannel) {
+ this.profilesByChannel =
+ Map.copyOf(Objects.requireNonNull(profilesByChannel, "profilesByChannel"));
+ }
+
+ @Override
+ public List plan(
+ TenantId tenantId, RecipientSpec recipient, DeliveryStrategy strategy) {
+ Objects.requireNonNull(tenantId, "tenantId");
+ Objects.requireNonNull(recipient, "recipient");
+ Objects.requireNonNull(strategy, "strategy");
+
+ List ordered =
+ switch (strategy) {
+ case ExplicitChannel explicit -> List.of(explicit.channel());
+ case OrderedFallback fallback -> fallback.channels();
+ };
+
+ List routes = new ArrayList<>(ordered.size());
+ int index = 0;
+ for (Channel channel : ordered) {
+ ProviderProfileId profileId = profilesByChannel.get(channel);
+ if (profileId == null) {
+ continue;
+ }
+ var selector =
+ recipient.contactPoints().stream()
+ .filter(candidate -> candidate.channel() == channel)
+ .findFirst();
+ if (selector.isEmpty()) {
+ continue;
+ }
+ boolean blocked =
+ recipient
+ .channelOverride()
+ .map(override -> override.blockedChannels().contains(channel))
+ .orElse(false);
+ routes.add(
+ new RouteCandidate(
+ index++, channel, selector.get().contactPointId(), profileId, !blocked, true));
+ }
+ return List.copyOf(routes);
+ }
+}
diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/CredentialProbe.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/CredentialProbe.java
new file mode 100644
index 00000000..b41729af
--- /dev/null
+++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/CredentialProbe.java
@@ -0,0 +1,9 @@
+package dev.caskeleton.adapter.outbound.notification.platform.dispatch;
+
+/** Verifies a candidate generation before it becomes the current one. */
+@FunctionalInterface
+public interface CredentialProbe {
+
+ /** Return false when the candidate credential is not usable. */
+ boolean isUsable(ProviderRuntime candidate);
+}
diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/CredentialValidationException.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/CredentialValidationException.java
new file mode 100644
index 00000000..20c658fd
--- /dev/null
+++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/CredentialValidationException.java
@@ -0,0 +1,19 @@
+package dev.caskeleton.adapter.outbound.notification.platform.dispatch;
+
+import dev.caskeleton.application.notification.platform.api.error.FailureCategory;
+import dev.caskeleton.application.notification.platform.api.error.NotificationException;
+import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode;
+import dev.caskeleton.application.notification.platform.api.error.NotificationFailureDescriptor;
+
+/** Raised when a candidate credential generation fails its probe before any cutover. */
+public class CredentialValidationException extends NotificationException {
+
+ private static final long serialVersionUID = 1L;
+
+ public CredentialValidationException() {
+ super(
+ NotificationFailureDescriptor.preDispatch(
+ NotificationFailureCode.PROVIDER_CONFIGURATION_INVALID,
+ FailureCategory.AUTHENTICATION));
+ }
+}
diff --git a/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/JacksonRoutingPlanCodec.java b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/JacksonRoutingPlanCodec.java
new file mode 100644
index 00000000..6693cd0c
--- /dev/null
+++ b/src/adapter/outbound/notification/src/main/java/dev/caskeleton/adapter/outbound/notification/platform/dispatch/JacksonRoutingPlanCodec.java
@@ -0,0 +1,61 @@
+package dev.caskeleton.adapter.outbound.notification.platform.dispatch;
+
+import dev.caskeleton.adapter.outbound.notification.platform.template.NotificationJsonMapper;
+import dev.caskeleton.application.notification.platform.api.ContactPointId;
+import dev.caskeleton.application.notification.platform.api.ProviderProfileId;
+import dev.caskeleton.application.notification.platform.api.routing.Channel;
+import dev.caskeleton.application.notification.platform.dispatch.NotificationRoutingPlanCodecPort;
+import dev.caskeleton.application.notification.platform.policy.RouteCandidate;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.UUID;
+import tools.jackson.core.type.TypeReference;
+
+/**
+ * Route plan encoding.
+ *
+ * The plan is frozen at submit time, so this is a snapshot format rather than a view: it stores
+ * exactly what was decided, including which routes were usable then, and never recomputes.
+ */
+public final class JacksonRoutingPlanCodec implements NotificationRoutingPlanCodecPort {
+
+ @Override
+ public String encode(List routes) {
+ Objects.requireNonNull(routes, "routes");
+ List