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