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

# Envíos e historial de estados

> Cuando esto se entregue, "¿le llegó al cliente el correo de sus puntos?" será una consulta, y la respuesta encadena hasta la compra que lo causó.

> Traducción. Autoritativo: [`fs-msg-0003-sends-and-status.md`](/modules/messaging/features/fs-msg-0003-sends-and-status).

## Contexto

DEC-E7 pide trazabilidad end to end: desde el evento de dominio, por la regla que disparó, el job de
notificación, el envío, su estado en el proveedor, y hacia afuera al webhook del tenant — todo unido
por un `correlation_id`. Esa es la diferencia entre una respuesta de soporte y un encogimiento de
hombros.

La consecuencia de diseño es que un envío es **append-only con historial de estados separado**.
Sobreescribir una columna de estado perdería la línea de tiempo, y la línea de tiempo es lo que
responde la pregunta. También hace de la tabla de estados la de mayor volumen del módulo, y por eso
se particiona desde el inicio.

## Alcance *(normativo)*

* `messaging.sends`: una fila por mensaje por destinatario por canal.
* `messaging.send_status_history`: append-only, particionada mensualmente.
* `messaging.send_statuses`: paramétrica, `queued → sent → delivered → opened → clicked`, más
  `bounced`, `complained`, `failed`.
* Ingesta de estados por webhooks del proveedor.
* `correlation_id` y `origin_event_id` en cada envío.
* Eventos de dominio por transición, disponibles como webhooks salientes.

## Fuera de alcance *(normativo)*

* Decidir enviar — FS-MSG-0001. Una fila de envío existe solo tras pasar la cascada.
* Despacho y rate limiting — FS-MSG-0005.
* Analítica y agregación. Esto es el log de eventos; la reportería lo lee.

## Comportamiento *(normativo)*

1. Una fila de envío se crea **solo después de que `resolveChannels` pasó todas las compuertas**. Un
   mensaje bloqueado no produce fila — produce una razón registrada (FS-MSG-0001).
2. Todo envío lleva `correlation_id` y `origin_event_id`, heredados del evento que lo causó. Un envío
   sin ninguno de los dos es un defecto: no se puede explicar después.
3. El historial es **append-only**. Los estados llegan desordenados —`delivered` después de `opened`
   pasa con clientes de correo agresivos— y el historial guarda lo que llegó, en orden de llegada, con
   los timestamps del proveedor.
4. El estado actual se **deriva** del historial, nunca se guarda como columna mutable.
5. Los webhooks del proveedor se **verifican por firma** y son idempotentes: el mismo evento entregado
   dos veces escribe una fila.
6. Un `bounced` duro o un `complained` **escriben una supresión automáticamente** (FS-MSG-0004), en la
   misma transacción. Un rebote que no suprime volverá a rebotar.
7. `send_status_history` se particiona mensualmente y sigue la retención del plan, exportando antes de
   soltar la partición. Los conteos agregados se conservan siempre.
8. El contenido del mensaje se guarda en el envío y se **borra con el titular al suprimirse**; la fila
   sobrevive como contador anonimizado.
9. Toda consulta contra el historial lleva el predicado de partición.

## Datos *(normativo)*

| Tabla                           | Invariantes clave                                                                                                                                                                                                                 |
| ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `messaging.sends`               | `tenant_id`; `contact_id`; `channel_code`; `template_id` + versión; `campaign_id` nullable; `correlation_id` y `origin_event_id` obligatorios; `category`; contenido renderizado, borrable al suprimir; particionada mensualmente |
| `messaging.send_status_history` | append-only; FK envío; `status_code`; `provider_event_id` único por proveedor; `occurred_at` del proveedor, `received_at` nuestro; particionada mensualmente                                                                      |
| `messaging.send_statuses`       | paramétrica; semillas `is_system` según alcance; no extensible por tenant                                                                                                                                                         |

## API *(normativo)*

| Endpoint                                 | Clase      | Permiso                | Presupuesto                            |
| ---------------------------------------- | ---------- | ---------------------- | -------------------------------------- |
| `GET /v1/messaging/sends`                | Management | `messaging.sends.read` | p95 \<1 s, rango de tiempo obligatorio |
| `GET /v1/messaging/sends/{id}`           | Management | `messaging.sends.read` | p95 \<1 s                              |
| `POST /v1/messaging/webhooks/{provider}` | Runtime    | firma del proveedor    | p95 \<300 ms                           |

El rango de tiempo del listado es obligatorio: es la clave de partición.

## Eventos *(normativo)*

`messaging.message.sent|delivered|opened|clicked|bounced|complained`, todos disponibles como webhooks
salientes. Llevan `contact_id`, `send_id` y el `correlation_id` heredado, nunca contenido.

## Criterios de aceptación *(normativo)*

1. Una fila de envío existe solo cuando la cascada pasó; un mensaje bloqueado produce una razón y
   ninguna fila.
2. Todo envío tiene `correlation_id` y `origin_event_id` no nulos.
3. Los estados que llegan desordenados se conservan todos, y el estado actual derivado es correcto.
4. El mismo webhook entregado dos veces escribe una fila de historial.
5. Un webhook con firma inválida se rechaza sin escritura.
6. Un rebote duro escribe una supresión en la misma transacción que el estado.
7. Una consulta rastrea desde un `origin_event_id` hasta todo envío resultante y su estado final.
8. La supresión borra el contenido y deja la fila como contador anonimizado.
9. **Negativo:** no existe columna mutable de estado actual en `sends`.

## Ejecución

Pipeline asíncrono. Las filas de envío las escribe el worker de despacho; los webhooks del proveedor
aterrizan en `backend/api` y encolan su procesamiento.

## Preguntas abiertas

| # | Pregunta                                                                                                                                                                     | Decide | Para             |
| - | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | ---------------- |
| 1 | ¿El tracking de aperturas y clics viene activo por defecto? Exige píxel y reescritura de enlaces, y a algunos destinatarios y reguladores no les gusta. (pregunta 2 del PRD) | Daniel | antes de aprobar |
| 2 | ¿Cuánto se retiene el contenido renderizado — el tier del plan, o menos?                                                                                                     | Daniel | antes de aprobar |

## Changelog

| Versión | Fecha      | Cambio           | Por qué | Autor                  |
| ------- | ---------- | ---------------- | ------- | ---------------------- |
| 0.1.0   | 2026-08-17 | Borrador inicial | —       | daniel + claude-opus-5 |

## Registro de entrega

*Aún no implementado.*
