Context
DEC-E7 asks for end-to-end traceability: from the originating domain event, through the rule that fired, the notification job, the send, its provider status, and out to the tenant’s webhook — all joined by onecorrelation_id. That is the difference between a support answer and a shrug.
The design consequence is that a send is append-only with a separate status history. Overwriting
a status column would lose the timeline, and the timeline is what answers the question. It also
means the status table is the highest-volume table in the module, which is why it is partitioned
from the start rather than as a later optimisation.
Scope (normative)
messaging.sends: one row per message per recipient per channel.messaging.send_status_history: append-only, partitioned monthly.messaging.send_statuses: parametric,queued → sent → delivered → opened → clicked, plusbounced,complained,failed.- Provider status ingestion through provider webhooks.
correlation_idandorigin_event_idon every send.- Domain events per status transition, available as outgoing webhooks.
Non-scope (normative)
- Deciding to send — FS-MSG-0001. A send row exists only after the cascade has passed.
- Dispatch and rate limiting — FS-MSG-0005.
- Analytics and aggregation. This is the event log; reporting reads it.
Behaviour (normative)
- A send row is created only after
resolveChannelshas passed every gate. A blocked message produces no send row — it produces a recorded reason (FS-MSG-0001). - Every send carries
correlation_idandorigin_event_id, inherited from the event that caused it. A send with neither is a defect: it cannot be explained afterwards. - Status history is append-only. Statuses arrive out of order —
deliveredafteropenedhappens with aggressive mail clients — and the history keeps what arrived, in arrival order, with provider timestamps. - The current status is derived from the history, never stored as a mutable column.
- Provider webhooks are verified by signature and idempotent: the same provider event delivered twice writes one row.
- A
bounced(hard) orcomplainedstatus automatically writes a suppression (FS-MSG-0004), in the same transaction. A bounce that does not suppress will bounce again. send_status_historyis partitioned monthly and follows the plan’s retention, exported before the partition drops. Aggregate counts are kept forever.- Message content is stored on the send and is deleted with the subject on erasure; the send row survives as an anonymized counter.
- Every query against the history carries the partition-key predicate.
Data (normative)
API (normative)
The time range on the list is required, not defaulted — it is the partition key.
Events (normative)
messaging.message.sent|delivered|opened|clicked|bounced|complained, all available as outgoing
webhooks. They carry contact_id, send_id and the inherited correlation_id, never content.
Acceptance criteria (normative)
- A send row exists only when the cascade passed; a blocked message produces a reason and no row.
- Every send has a non-null
correlation_idandorigin_event_id. - Statuses arriving out of order are all retained, and the derived current status is correct.
- The same provider webhook delivered twice writes one history row.
- A provider webhook with an invalid signature is rejected without any write.
- A hard bounce writes a suppression in the same transaction as the status.
- One query traces from an
origin_event_idto every resulting send and its final status. - Erasure deletes content and leaves the send row as an anonymized counter.
- Negative: no mutable current-status column exists on
sends.
Execution
Asynchronous pipeline. Send rows are written by the dispatch worker; provider webhooks land inbackend/api and enqueue their processing, since a provider retrying is cheaper than us blocking.