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>
63 lines
2.4 KiB
Markdown
63 lines
2.4 KiB
Markdown
# 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.
|