# 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.