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

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 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
sentdelivered applied
deliveredsent ignored, event still stored
deliveredcomplaint complaint fact added, delivery preserved
complaintdelivered delivery applied, complaint preserved
acceptedbounced applied
readdisplayed 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.