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>
2.4 KiB
Delivery evidence model
The shape
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
DELIVEREDand 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
sentroutinely arrives afterdelivered. 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
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.