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

# Infraestructura de jobs — processed jobs y dead letters

> Cuando esto se entregue, ningún worker de la plataforma podrá aplicar dos veces un efecto, y ninguna falla podrá desaparecer sin registro.

> Traducción. Autoritativo: [`fs-core-0007-job-infrastructure.md`](/modules/core/features/fs-core-0007-job-infrastructure).

## Contexto

Toda cola es at-least-once. Un worker va a procesar el mismo job dos veces —tras un timeout, un
deploy, un reintento de proveedor— y en un sistema que acredita puntos, eso es dinero. La
idempotencia no es un atributo de calidad aquí; es la condición de corrección, y por eso DEC-C6 hizo
`processed_jobs` obligatorio para todo consumidor en vez de una elección por worker.

La segunda mitad es la garantía de evidencia de DEC-C5: toda falla y todo agotamiento de reintentos
queda **persistido**, no solo registrado en logs. Una línea de log rota; una fila de `dead_letters`
es algo que un operador puede encontrar, entender y reprocesar. Los éxitos a volumen son métricas,
no filas — la asimetría es deliberada.

## Alcance *(normativo)*

* `core.processed_jobs`: la valla de deduplicación, escrita dentro de la transacción de trabajo.
* `core.dead_letters`: fallas con payload, intentos, último error y estado de revisión.
* El harness de consumidor: valla de idempotencia, reintentos con backoff y jitter, escritor de dead
  letters.
* Limpieza por TTL de `processed_jobs`.
* Alertas a BetterStack ante dead letters nuevos.
* Replay desde Softcrum Ops, republicando con el mismo `job_id`.

## Fuera de alcance *(normativo)*

* `QueuePort` y sus adaptadores — TS-002.
* Los carriles de notificación — son de `messaging`, aunque usan este harness.
* Una UI de replay — `frontend/ops`.

## Comportamiento *(normativo)*

1. Un consumidor verifica e inserta en `processed_jobs` **dentro de la misma transacción que su
   trabajo**. Fuera de ella, una caída entre ambos deja el job marcado como hecho sin nada hecho.
2. Un `job_id` duplicado hace ack y salta. En silencio, con una métrica — un duplicado es operación
   normal, no un error.
3. Política de reintentos: **5 intentos, backoff exponencial con jitter**, base 30 s, tope 1 h.
   Sobreescribirla por cola exige justificación en el spec de ese módulo.
4. Los reintentos agotados y las fallas duras escriben una fila de `dead_letters` con el payload
   completo, el número de intentos, el último error y el `correlation_id`, y alertan a BetterStack.
5. **Los mensajes envenenados se saltan los reintentos por completo.** Un payload inválido de schema
   nunca se volverá válido; reintentarlo cinco veces son cinco minutos y cinco alertas idénticas
   desperdiciados.
6. El replay republica con el **mismo `job_id`**, de modo que `processed_jobs` garantiza que no haya
   doble efecto. Esa propiedad es lo que hace al replay seguro para usarlo en masa.
7. Las filas de `processed_jobs` expiran por TTL; el TTL debe exceder con margen la ventana máxima de
   reintentos, o un reintento tardío tras la expiración volvería a aplicar el trabajo.
8. Un dead letter pasa `pending_review → replayed | discarded`, y descartar exige una razón.
9. Los éxitos de alto volumen son **métricas, no filas**. Escribir una fila por job exitoso haría de
   esta tabla la más grande de la plataforma sin beneficio.

## Datos *(normativo)*

| Tabla                 | Invariantes clave                                                                                                                                                                                                 |
| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `core.processed_jobs` | PK (`job_id`, `consumer`); `tenant_id`; `processed_at`; `expires_at` para la limpieza por TTL                                                                                                                     |
| `core.dead_letters`   | `job_id`, `queue`, `payload` JSONB, `attempts`, `last_error`, `status` pending\_review\|replayed\|discarded, `discard_reason` nullable, `correlation_id`, `created_at`; append-only salvo la transición de estado |

## API *(normativo)*

| Endpoint                                  | Clase      | Permiso                     | Presupuesto |
| ----------------------------------------- | ---------- | --------------------------- | ----------- |
| `GET /v1/core/dead-letters`               | Management | `core.dead_letters.read`    | p95 \<1 s   |
| `POST /v1/core/dead-letters/{id}/replay`  | Management | `core.dead_letters.replay`  | p95 \<1 s   |
| `POST /v1/core/dead-letters/{id}/discard` | Management | `core.dead_letters.discard` | p95 \<1 s   |

En la práctica son de Ops: un tenant no administra nuestras colas.

## Eventos *(normativo)*

Ninguno en el outbox. Los dead letters alertan por observabilidad, no por eventos de dominio.

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

1. El mismo `job_id` procesado dos veces en paralelo produce exactamente un efecto.
2. Una caída entre el trabajo y la valla no deja ninguno de los dos.
3. Una falla transitoria reintenta 5 veces con intervalos crecientes y luego escribe un dead letter.
4. Un payload inválido de schema va directo a dead letters con cero reintentos.
5. El replay de un dead letter con el mismo `job_id` no produce un segundo efecto.
6. La limpieza por TTL borra filas expiradas y nunca una dentro de su ventana de reintentos.
7. Descartar sin razón se rechaza.
8. **Negativo:** ningún job exitoso escribe una fila más allá de su valla de `processed_jobs`.

## Ejecución

Un solo slice, comando síncrono. Schema en TS-001; el harness en TS-002 junto a `QueuePort`.
Runbook: [`../../../runbooks/dead-letter-replay.md`](/es/runbooks/dead-letter-replay).

## Preguntas abiertas

| # | Pregunta                                                                                       | Decide | Para             |
| - | ---------------------------------------------------------------------------------------------- | ------ | ---------------- |
| 1 | TTL de `processed_jobs` — ¿7 días, o más para cubrir una caída larga de proveedor?             | Daniel | antes de aprobar |
| 2 | ¿Un dead letter con más de N días sin revisar debería escalar, como las conversiones marcadas? | 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.*
