Skip to main content

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 one correlation_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, plus bounced, complained, failed.
  • Provider status ingestion through provider webhooks.
  • correlation_id and origin_event_id on 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)

  1. A send row is created only after resolveChannels has passed every gate. A blocked message produces no send row — it produces a recorded reason (FS-MSG-0001).
  2. Every send carries correlation_id and origin_event_id, inherited from the event that caused it. A send with neither is a defect: it cannot be explained afterwards.
  3. Status history is append-only. Statuses arrive out of order — delivered after opened happens with aggressive mail clients — and the history keeps what arrived, in arrival order, with provider timestamps.
  4. The current status is derived from the history, never stored as a mutable column.
  5. Provider webhooks are verified by signature and idempotent: the same provider event delivered twice writes one row.
  6. A bounced (hard) or complained status automatically writes a suppression (FS-MSG-0004), in the same transaction. A bounce that does not suppress will bounce again.
  7. send_status_history is partitioned monthly and follows the plan’s retention, exported before the partition drops. Aggregate counts are kept forever.
  8. Message content is stored on the send and is deleted with the subject on erasure; the send row survives as an anonymized counter.
  9. 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)

  1. A send row exists only when the cascade passed; a blocked message produces a reason and no row.
  2. Every send has a non-null correlation_id and origin_event_id.
  3. Statuses arriving out of order are all retained, and the derived current status is correct.
  4. The same provider webhook delivered twice writes one history row.
  5. A provider webhook with an invalid signature is rejected without any write.
  6. A hard bounce writes a suppression in the same transaction as the status.
  7. One query traces from an origin_event_id to every resulting send and its final status.
  8. Erasure deletes content and leaves the send row as an anonymized counter.
  9. Negative: no mutable current-status column exists on sends.

Execution

Asynchronous pipeline. Send rows are written by the dispatch worker; provider webhooks land in backend/api and enqueue their processing, since a provider retrying is cheaper than us blocking.

Open questions

Changelog

Delivery record

Not implemented yet.