feat(notification): implement the notification delivery platform
Maps the 31-module plan onto the registry's 19 leaves as packages; the two edges the registry forbids (provider->httpclient, inbox->messaging) are replaced by application-owned ports. See docs/notification/module-mapping.md. Acceptance is not delivery: ProviderSubmissionResult refuses to carry a delivery outcome, and AMBIGUOUS is a first-class terminal state that blocks automatic retry and fallback until reconciliation resolves it. Providers: SES (SigV4 + SNS callback), Twilio (X-Twilio-Signature + reconciliation), FCM (FID-primary batch), APNs, Web Push (RFC 8030/8291/8292), SMTP and webhook. Contact points are AES-256-GCM encrypted with a separate HMAC lookup fingerprint; nothing raw reaches a log, metric tag or exception. Dispatch commits the attempt row, calls the provider with no transaction open, then records the outcome; the durable queue uses FOR UPDATE SKIP LOCKED. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
3b5aee50e3
commit
701ba67456
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user