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

# Despacho de carril dual y workers

> Cuando esto se entregue, un tenant podrá enviar un millón de correos y los códigos de acceso de otro tenant seguirán llegando en segundos.

> Traducción. Autoritativo: [`fs-msg-0005-dual-rail-dispatch.md`](/modules/messaging/features/fs-msg-0005-dual-rail-dispatch).

## Contexto

Esta feature existe por un hallazgo concreto: **el rate limit de Resend es por team, aplicado a todas
las API keys** — todos los tenants comparten un presupuesto. Sin aislamiento, un blast de marketing
deja sin OTPs, invitaciones ni comprobantes a todos los demás. Una plataforma donde la campaña de un
cliente rompe el login de otro no es multi-tenant en ningún sentido que importe.

La respuesta de ADR-015 son dos carriles por canal con prioridad estricta, más token buckets en dos
niveles: uno **global** por proveedor que refleja su límite real, y uno **por tenant** para equidad.
El global es física; el por tenant es justicia, y se necesitan ambos.

## Alcance *(normativo)*

* Dos colas por canal: `notif.{canal}.transactional` y `notif.{canal}.marketing`.
* Harness de worker sobre la infraestructura compartida de idempotencia y reintentos (FS-CORE-0007).
* Token buckets en Upstash: global por proveedor, y por tenant.
* Workers de marketing que ceden cuando la profundidad transaccional cruza el umbral.
* Despacho por lotes para email, usando el endpoint batch del proveedor.
* Contrapresión y profundidad de cola observable por carril.

## Fuera de alcance *(normativo)*

* Los adaptadores — FS-MSG-0006. El despacho decide cuándo y a qué velocidad; los adaptadores entregan.
* Decidir si enviar — FS-MSG-0001.
* `QueuePort` y el harness genérico de consumidores, que son de `core` y TS-002.

## Comportamiento *(normativo)*

1. Cada canal tiene **exactamente dos carriles**. La categoría mapea al carril: `transactional` y
   `product` al transaccional, `marketing` al de marketing.
2. **El transaccional siempre gana.** Cuando su profundidad cruza el umbral, los workers de marketing
   **ceden** — terminan el job en curso y dejan de tomar hasta que la profundidad se recupere. Ceder,
   no drenar: un worker de marketing que muere pierde su progreso.
3. Dos token buckets, ambos consultados: uno **global** por proveedor que refleja su límite real, y
   uno **por tenant** para uso justo.
4. Ante un `429` del proveedor, **backoff, nunca descartar**. Un mensaje de marketing descartado es
   una campaña perdida; uno transaccional descartado es un cliente que no puede entrar.
5. Los blasts de email usan **exclusivamente el endpoint batch** del proveedor (100 por request en
   Resend). Enviar un blast de a un request agota el bucket global para todos.
6. **Los contactos nunca se sincronizan a la feature de audiencias del proveedor.** Nuestro almacén es
   la fuente de verdad; una copia allá es un segundo registro de consentimiento que no gobernamos.
7. Todo worker es idempotente por `core.processed_jobs`, y los reintentos agotados caen en
   `core.dead_letters` con el id del envío adjunto.
8. La profundidad de cola por carril es una métrica publicada con alerta. Un carril transaccional que
   crece es un incidente, no una estadística.
9. PROHIBIDO: un job de marketing en el carril transaccional · saltarse los buckets por un envío
   "urgente" · descartar un mensaje ante un 429.

## Datos *(normativo)*

| Tabla                          | Invariantes clave                                                                                                                                              |
| ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `messaging.rate_limit_buckets` | solo configuración: (`scope` global\|tenant, `provider`, `tenant_id` nullable); `capacity`, `refill_per_second`; los contadores vivos están en Upstash, no acá |

## API *(normativo)*

| Endpoint                          | Clase      | Permiso                 | Presupuesto |
| --------------------------------- | ---------- | ----------------------- | ----------- |
| `GET /v1/messaging/queues/health` | Management | `messaging.queues.read` | p95 \<1 s   |

De cara a Ops. Un tenant no ajusta nuestros rate limits.

## Eventos *(normativo)*

Ninguno. El despacho es mecanismo; los eventos de envío (FS-MSG-0003) son el registro.

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

1. **El perfil k6 `blast-vs-otp`**: un blast a máximo throughput mientras los envíos transaccionales
   de un segundo tenant mantienen su presupuesto de latencia. Es el criterio que justifica toda la
   feature.
2. Los workers de marketing ceden cuando la profundidad transaccional cruza el umbral, y retoman
   después.
3. Un 429 del proveedor causa backoff y cero mensajes descartados.
4. Los blasts de email usan el endpoint batch; no existe un camino de un mensaje por request.
5. Un tenant que agota su bucket no consume la cuota de otro.
6. Un job duplicado produce un envío.
7. Los reintentos agotados caen en dead letters con el id del envío.
8. **Negativo:** ningún camino de código escribe contactos en la feature de audiencias del proveedor.

## Ejecución

Pipeline asíncrono. Workers en `backend/workers`, con deployment propio por el argumento de
aislamiento de ADR-015. Buckets en Upstash, compartiendo las primitivas del rate limiting de la API.

## Preguntas abiertas

| # | Pregunta                                                                                           | Decide | Para             |
| - | -------------------------------------------------------------------------------------------------- | ------ | ---------------- |
| 1 | Umbral de profundidad transaccional al que cede marketing — ¿número fijo o función del throughput? | Daniel | antes de aprobar |
| 2 | ¿Pedimos a Resend un aumento de límite antes de G1, y a qué número?                                | Daniel | antes de G1      |

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