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.
|
||||
Reference in New Issue
Block a user