Files
clean-architecture-backend-…/docs/notification/delivery-evidence.md
T
DongHyeonkaandClaude Opus 5 701ba67456 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>
2026-08-14 13:57:27 +09:00

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.