> ## Documentation Index
> Fetch the complete documentation index at: https://internal.softcrum.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Sends and Status History

> After this ships, "did the customer get the email about their points?" is one query, and the answer chains all the way back to the purchase that caused it.

## 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)*

| Table                           | Key invariants                                                                                                                                                                                                                        |
| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `messaging.sends`               | `tenant_id`; `contact_id`; `channel_code`; `template_id` + version; `campaign_id` nullable; `correlation_id` and `origin_event_id` mandatory; `category`; rendered content, deletable on erasure; partitioned monthly on `created_at` |
| `messaging.send_status_history` | append-only; FK send; `status_code`; `provider_event_id` unique per provider; `occurred_at` from the provider, `received_at` ours; partitioned monthly                                                                                |
| `messaging.send_statuses`       | parametric; `is_system` seeds as scoped; not tenant-extensible                                                                                                                                                                        |

## API *(normative)*

| Endpoint                                 | Class      | Permission             | Budget                         |
| ---------------------------------------- | ---------- | ---------------------- | ------------------------------ |
| `GET /v1/messaging/sends`                | Management | `messaging.sends.read` | p95 \<1 s, time range required |
| `GET /v1/messaging/sends/{id}`           | Management | `messaging.sends.read` | p95 \<1 s                      |
| `POST /v1/messaging/webhooks/{provider}` | Runtime    | provider signature     | p95 \<300 ms                   |

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

| # | Question                                                                                                                                                          | Decides | By              |
| - | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | --------------- |
| 1 | Are open and click tracking on by default? They need a pixel and link rewriting, both of which some recipients and some regulators dislike. (PRD open question 2) | Daniel  | before approval |
| 2 | How long is rendered content retained — the plan's tier, or shorter?                                                                                              | Daniel  | before approval |

## Changelog

| Version | Date       | Change        | Why | Author                 |
| ------- | ---------- | ------------- | --- | ---------------------- |
| 0.1.0   | 2026-08-17 | Initial draft | —   | daniel + claude-opus-5 |

## Delivery record

*Not implemented yet.*
