---
url: /contracts/FOUNDATION_HTTP_AND_OBSERVABILITY.md
---
# Foundation: contrato HTTP y observabilidad

Estado: **especificación acordada para implementar y verificar**, no declaración de funcionalidad terminada.

Seguimiento: issue #13. Este documento formaliza la ampliación de Slice C a **Contrato HTTP y observabilidad**, dividida en C1–C6, y el alcance de validación de Slice D.

## 1. Autoridad, alcance y estado

Este documento es propietario del contrato transversal HTTP, paginación y observabilidad de Foundation. Complementa [ARCHITECTURE.md](../reference/ARCHITECTURE.md), [API\_AND\_SCHEMA\_DISCIPLINE.md](API_AND_SCHEMA_DISCIPLINE.md), [ROADMAP.md](../reference/ROADMAP.md) y [NESTJS\_STACK\_GUIDE.md](../reference/NESTJS_STACK_GUIDE.md).

Consolida las decisiones de `data`/`meta`, decimales exactos, fechas, errores seguros e idempotencia del documento migrado [API\_AND\_MODULE\_CONTRACTS.md](API_AND_MODULE_CONTRACTS.md). Ese documento conserva su contexto histórico; para los detalles HTTP y de observabilidad aquí especificados, esta es la referencia actual. No modifica los contratos canónicos PostgreSQL ni las reglas de negocio.

Distinciones obligatorias:

* HTTP, Problem Details, OpenAPI, W3C Trace Context y OpenTelemetry son las referencias externas elegidas.
* `data`, `meta.pageInfo`, `X-Request-Id`, los códigos de error y los límites descritos aquí son convenciones de Ninaku; no requisitos universales de esas referencias.
* Documentar una decisión no instala una dependencia ni implementa endpoints.
* Un ejemplo de Sales es un contrato ilustrativo futuro, no evidencia de que Sales ya exista.
* El objetivo es permitir cambios internos sin romper consumidores ni rehacer mecanismos transversales; no prometer que nunca habrá refactors.

Al adoptar esta especificación, Slice A está integrada mediante PR #14/#16. B, C1–C6 y D requieren su propia implementación y evidencia; consultar el issue #13 para el estado actualizado. El gate de producción sigue en #7. Este cambio documental no habilita despliegues, proveedores, backups ni gastos nuevos.

## 2. Los cuatro slices

| Slice | Responsabilidad | Resultado verificable |
| --- | --- | --- |
| A — Arranque y conexión | Configuración tipada, pool PostgreSQL con `ninaku_runtime`, verificación del rol y health checks | Arranque controlado, rechazo de configuración insegura y conexión comprobable |
| B — Contexto y transacciones | Contexto por petición con AsyncLocalStorage, transacciones por caso de uso y contexto autorizado para RLS | No se mezclan tenants/peticiones; commit/rollback y aislamiento se prueban con PostgreSQL real |
| C — Contrato HTTP y observabilidad | Requests, éxitos, errores, colecciones, validación, logs, trazas, métricas básicas y OpenAPI | Los módulos reutilizan contratos y mecanismos verificables |
| D — Validación operativa y rendimiento | k6, pruebas bajo carga y medición del costo de la instrumentación | Resultados reproducibles, límites y regresiones medibles |

Son cuatro capacidades principales, **no exactamente cuatro PRs**. C se entrega en PRs pequeños C1–C6, cada uno con implementación y pruebas, siguiendo [el flujo staging-first](../operations/RAILWAY_OPERATIONS.md#branch-promotion-flow). No se declara C completa al aprobar solo su documentación.

B conserva la propagación del contexto autorizado mediante `SET LOCAL` —o su equivalente parametrizado local a la transacción— de `app.actor_identity_id`, `app.identity_id`, `app.membership_id` y `app.organization_id`, seguida de `identity.assert_tenant_context()`, según los contratos existentes. AsyncLocalStorage transporta contexto; no autentica ni autoriza. Ningún header de trazabilidad sustituye esas verificaciones.

## 3. C1 — Convenciones HTTP

### Requests

Los comandos reciben su payload natural, sin `request.data` ni un envoltorio universal de acciones. Se definen body, path, query y headers por operación de negocio, no por tabla.

Ejemplo de contrato futuro:

```http
POST /api/v1/sales/orders
Content-Type: application/json
Idempotency-Key: 01994820-17a0-784e-8b2c-4ec8d344e9a6
```

```json
{
  "customerId": "81c7dd6c-406c-4280-a839-64ca0d219a43",
  "items": [
    {
      "productId": "d5765916-adfe-4c39-b0a7-b07f4a41055b",
      "quantity": "2.000"
    }
  ]
}
```

### Éxito JSON

Un solo envoltorio `data`, con `meta` opcional para información transversal útil. Sin `success: true`, mensajes rutinarios de éxito, duplicación del estado HTTP ni `Result<Result<T>>`. No se devuelven entidades de persistencia.

```http
HTTP/1.1 201 Created
Content-Type: application/json
Location: /api/v1/sales/orders/84e7252c-c8bc-4cda-8e77-945818caa260
X-Request-Id: 01994821-16a0-784e-8b2c-4ec8d344e9a6
```

```json
{
  "data": {
    "orderId": "84e7252c-c8bc-4cda-8e77-945818caa260",
    "status": "draft",
    "version": 1,
    "total": { "amount": "24.50", "currency": "USD" }
  }
}
```

La aplicación devuelve `X-Request-Id` en las respuestas que controla, también en errores cuando sea posible. El frontend puede usarlo para soporte sin exigir `meta.traceId` en cada éxito. La política de CORS debe exponer los headers que el cliente necesita leer; la lista exacta se prueba en C1.

El servidor genera o valida explícitamente los identificadores de correlación en su frontera de confianza. No copia headers arbitrarios a logs ni los usa como identidad autorizada.

C1 resuelve dos puntos que esta sección deja abiertos: el servidor ignora cualquier `X-Request-Id` entrante y genera siempre el suyo, porque `requestId` identifica un intento HTTP del servidor (sección 7.2), no una entrada de confianza del cliente; y el header `x-trace-id` que B exponía en la respuesta deja de presentarse — el trace id sigue resolviéndose y almacenándose para la correlación de OpenTelemetry en C5, pero `X-Request-Id` es ahora el único header de correlación de la aplicación. La política de orígenes permitidos por entorno (ver NESTJS\_STACK\_GUIDE.md sección 22) ya está implementada mediante `CORS_ALLOWED_ORIGINS` y expone `ETag`/`X-Request-Id` a los orígenes permitidos; ver la sección 3.1 para el mecanismo completo.

### Representación de datos

| Dato | Contrato |
| --- | --- |
| Nombres JSON | `camelCase` |
| Identificadores | Strings con formato declarado en el esquema |
| Importes y cantidades decimales exactas | Strings con precisión/escala documentadas; moneda o unidad cuando corresponda |
| Contadores, versiones y límites enteros | Números enteros dentro de los rangos documentados |
| Instantes | ISO 8601 en UTC |
| Fechas de negocio | Fecha sin conversión implícita a instante; zona horaria explícita cuando interviene en el caso de uso |
| Campo ausente y `null` | Semántica declarada por contrato; no son equivalentes automáticamente |
| Enums | Valores documentados; cambios revisados por compatibilidad |

No convertir decimales exactos silenciosamente a `number`. Las restricciones del DTO no reemplazan las reglas del dominio.

### Estados, headers y excepciones

| HTTP | Uso de Ninaku |
| --- | --- |
| 200 | Consulta o comando completado con representación |
| 201 | Recurso creado; `Location` cuando corresponde |
| 202 | Aceptación durable asíncrona, no finalización; contrato de consulta del estado de operación |
| 204 | Éxito sin cuerpo |
| 400 | JSON inválido o incumplimiento del contrato de entrada |
| 401 / 403 | Falta autenticación / operación no autorizada |
| 404 | Recurso no disponible en el contexto autorizado, sin revelar otro tenant |
| 409 | Conflicto de negocio, concurrencia o idempotencia definido por el caso de uso |
| 412 / 428 | Precondición obsoleta / precondición requerida ausente |
| 413 | Cuerpo de la solicitud excede el tamaño máximo aceptado por el endpoint |
| 415 | `Content-Type` de la solicitud no soportado por el endpoint |
| 422 | Solicitud bien formada pero semánticamente inválida (RFC 9110 §15.5.21); la validación de entrada de C3 sigue usando 400, no 422 |
| 429 / 503 | Límite temporal / indisponibilidad transitoria; `Retry-After` cuando existe un valor conocido |
| 500 | Fallo inesperado, con detalle público seguro |

Un módulo no inventa otra clasificación: cualquier estado adicional necesita propósito y esquema documentados. Nunca se responde 200 para ocultar un error.

Las respuestas 204/304, HEAD, archivos, streams y health checks conservan su contrato propio. Un interceptor no envuelve todo indiscriminadamente. Las excepciones se declaran y prueban, no se deducen examinando si un objeto tiene una propiedad `data`. Los errores siguen C4 salvo protocolos operativos expresamente documentados.

Se documentan `Authorization`, `Content-Type`, `Accept`, `Accept-Language`, `Idempotency-Key`, `If-Match`/ETag y headers de correlación según la operación. C fija el contrato de idempotencia y precondiciones; no afirma que todos los comandos futuros ya estén deduplicados. Una escritura solo admite reintentos según la política del comando y su evidencia durable existente. `retryable: true` por sí solo nunca autoriza repetir un cobro.

### 3.1 Mecanismo implementado y decisiones que la especificación dejaba abiertas

`src/foundation/http` implementa peticiones condicionales (`ETag`, `If-None-Match`, `If-Match`, 304, 412) y cierra las excepciones de envoltorio que esta sección declaraba sin implementación verificable.

* **Quién calcula el validador**: el handler, no un cálculo central sobre el cuerpo serializado. Un handler que quiere participar devuelve `HttpConditionalResponse(data, etag)` (`src/foundation/http/conditional-requests/http-conditional-response.ts`), análogo a `HttpResponseWithMeta`; `etag` es el identificador opaco que el propio módulo considera representativo del estado actual (por ejemplo un `version` o un hash de negocio), no un hash genérico del JSON de salida. Se descarta calcular el validador centralmente (hashear la respuesta en el interceptor) porque produciría solo un validador fuerte que cambia con cualquier diferencia incidental de bytes (espacios, orden de claves, un campo no relacionado) y porque exigiría serializar el cuerpo completo en cada petición solo para descartar el resultado. La contrapartida documentada de la opción elegida es la misma: el handler sigue produciendo su representación completa antes de que el framework decida si el cliente ya la tiene (ver más abajo); evitar ese costo exigiría una vía de "solo verificación" específica de cada módulo, fuera del alcance de Foundation.
* **Default seguro**: un handler que no devuelve `HttpConditionalResponse` no recibe cabecera `ETag` ni ningún comportamiento condicional, incluso si el cliente envía `If-None-Match`. Esto exigió un cambio adicional no anticipado por la especificación: Express calcula y envía por defecto un `ETag` débil basado en un hash del cuerpo en cualquier respuesta JSON, lo que reintroducía silenciosamente el mecanismo descartado y rompía el default seguro (un handler sin opt-in podía responder 304 si el cliente adivinaba o reenviaba ese hash). `DisableAutomaticEntityTagService` (`http.module.ts`) desactiva ese cálculo automático una vez en el arranque a través de `HttpAdapterHost`, de modo que la única fuente de `ETag` es un handler que optó explícitamente.
* **`If-None-Match`**: la interceptor solo fija la cabecera `ETag` cuando el handler participa; la evaluación de `If-None-Match` (coincidencia exacta, comodín `*`, lista separada por comas y equivalencia débil/fuerte) no se reimplementa en Foundation. Se delega en el chequeo de "freshness" ya incorporado en `res.send()` de Express (paquete `fresh`), que aplica exactamente esa semántica y solo para `GET`/`HEAD` con estado 2xx o 304. Comparación elegida para esta cabecera: **débil** (`fresh` normaliza el prefijo `W/` en ambos lados), que es la correcta para `If-None-Match` según RFC 9110 §13.2.2. Probado con coincidencia exacta, sin coincidencia, ausencia de cabecera, `*`, lista de varios validadores y un validador débil del cliente contra el validador fuerte del servidor (`test/e2e/conditional-requests.e2e-spec.ts`).
* **`If-Match`**: `assertIfMatchSatisfied` (`src/foundation/http/conditional-requests/conditional-request.ts`) es una función pura que el handler llama con la cabecera entrante y el validador actual antes de escribir; sin cabecera, no hace nada (default seguro simétrico al de lectura). Comparación elegida: **fuerte** (`src/foundation/http/conditional-requests/entity-tag.ts`, función `matchesStrong`), correcta para `If-Match` según RFC 9110 §13.2.1 — un validador débil nunca satisface la precondición aunque el valor opaco coincida. Un fallo lanza `PreconditionFailedException` (`@nestjs/common`), que ya atraviesa el registro de errores existente (`HTTP_STATUS_ERROR_CODE[412]` en `problem-details.builder.ts`) sin ningún cambio en `errors/`. Probado con coincidencia, fallo real (no solo el camino feliz), ausencia de cabecera y un validador débil que falla la comparación fuerte pese a compartir el valor opaco con el validador actual.
* **Charset del validador**: `formatEntityTag`/`parseEntityTag` (`src/foundation/http/conditional-requests/entity-tag.ts`) restringen el `tag` opaco a la gramática `etagc` de RFC 7232 §3.11 (sin comillas dobles, barras invertidas ni caracteres de control). Un módulo que intente emitir un tag fuera de ese conjunto falla en voz alta en `formatEntityTag` en vez de producir una cabecera `ETag` malformada o ambigua; un valor entrante fuera de ese conjunto se descarta en `parseEntityTag` igual que cualquier otro valor mal formado. El tag recomendado es un entero de versión, un UUID, texto base64url o un digest hexadecimal: formas que ya cumplen ese charset por construcción.
* **La respuesta 304**: no lleva cuerpo, no se envuelve en `{ data }` y no se traduce a Problem Details, verificado con aserciones HTTP reales sobre estado, cuerpo vacío y ausencia de ambas formas.
* **Excepciones de envoltorio (204, HEAD, streams)**: al probar estos casos end-to-end no se encontró una brecha real en el interceptor. Antes y después de este cambio, 204 nunca lleva cuerpo — Express descarta `Content-Type`/`Content-Length`/cuerpo cuando el estado es 204 o 304 sin importar qué reciba `res.json()`, incluso si un handler decorado con `@HttpCode(204)` devuelve un objeto por error —; HEAD nunca escribe cuerpo — Express omite la escritura para ese método aunque el handler devuelva la misma representación que en GET —; y un `StreamableFile` que falla nunca se traduce a `{ data }` ni a Problem Details — un error antes de enviar cabeceras produce un 400 con el mensaje crudo, y un error después de empezar a transmitir trunca la respuesta sin cerrarla como error HTTP-, todo ya provisto por el manejo de `StreamableFile` de Nest/Express. `test/e2e/http-envelope.e2e-spec.ts` fija esos cuatro comportamientos con handlers que fuerzan el caso adverso (cuerpo devuelto por error en una ruta 204, stream que falla antes de cualquier byte y stream que falla a mitad de transferencia), y añade el mismo chequeo de regresión ya existente para `/health/live` a `/health/ready`.
* **Un handler sin representación responde 204, no `{ data: undefined }`**: la regla es `HttpEnvelopeInterceptor` (`src/foundation/http/envelope/http-envelope.interceptor.ts`) fija así: una respuesta JSON siempre exige `data` (nunca `{ data: undefined }`, que un `JSON.stringify` real convierte en `{}` sin que el cliente pueda distinguirlo de "ocurrió una omisión"), y la ausencia real de representación responde `204` sin envoltorio, nunca un `200` con cuerpo vacío u omitido. Antes de esta regla, un handler como `@Get() foo(): void {}` —sin `@HttpCode` ni `@SkipHttpEnvelope`— quedaba sin cubrir: `wrap()` lo envolvía igual que cualquier otro resultado (`{ data: result }`), y como `result` era `undefined`, el objeto viajaba como `{ data: undefined }`; `JSON.stringify` descarta las propiedades con valor `undefined`, así que el cliente recibía en realidad `200` con el cuerpo `{}`, no un fallo, pero tampoco el `204` documentado en la sección 3 ni ningún `data`. El interceptor distingue ese caso por identidad estricta con `undefined` (`result === undefined`), fija `response.statusCode = 204` y no envuelve nada — un `null` explícito sigue siendo una representación real y se envuelve como `{ data: null }`, porque la sección "Representación de datos" ya distingue campo ausente de `null` explícito y esa distinción no debe colapsar en el nivel del sobre completo. `http-envelope.interceptor.spec.ts` fija ambas mitades de la regla a nivel de interceptor (un `undefined` produce `204` sin cuerpo; un `null` produce `{ data: null }` sin tocar el estado); `test/e2e/http-envelope.e2e-spec.ts` la fija de punta a punta contra un handler `no-representation` sin ningún decorador de envoltorio o estado.
* **Política CORS y confianza de proxy**: `configureHttpApplication` (`src/bootstrap/configure-http-application.ts`) resuelve el punto que esta sección dejaba abierto. `CORS_ALLOWED_ORIGINS` (`src/foundation/config/schema/environment-variables.schema.ts`) es una lista de orígenes absolutos separados por comas, sin comodín; cada entrada debe cumplir `new URL(entrada).origin === entrada` (sin path, query ni fragment) o el arranque falla nombrando la entrada inválida. Por defecto, sin configurar o vacía, la lista es cero orígenes permitidos **en todo entorno, incluido desarrollo**: es una decisión deliberada de fail-closed, no un valor provisional — esta API maneja datos de tenant y un default permisivo dejaría un entorno nuevo aceptando lecturas cross-origin hasta que alguien recuerde restringirlo. `enableCors` expone exactamente `ETag` y `X-Request-Id` (`exposedHeaders`, los dos headers de respuesta propios que un navegador necesita leer vía `fetch`/`XHR`) y permite exactamente `Content-Type`, `If-Match` e `If-None-Match` como headers de petición (`allowedHeaders`); no se permite `Authorization` ni se expone ningún otro header porque nada en este código los consume todavía. `credentials` es `false`: no existe autenticación por cookies ni por sesión HTTP en este código (`DatabaseSession` es una sesión de base de datos, no de HTTP), así que habilitar credenciales CORS ahora solo ampliaría la superficie de ataque sin ningún beneficio correspondiente. `app.set('trust proxy', 1)` confía exactamente un salto de cabeceras `X-Forwarded-*`, correspondiente al único borde de Railway que termina TLS delante del contenedor (`docs/operations/RAILWAY_OPERATIONS.md`); no se usa `trust proxy: true` porque eso confiaría en una cadena arbitraria de cabeceras reenviadas. Esta fase deliberadamente no agregó todavía un prefijo global de API; el prefijo `/api` y el versionamiento URI se agregaron en el cambio encadenado siguiente, descrito en la sección 3.2. Probado end to end en `test/e2e/http-application-policy.e2e-spec.ts`: origen no permitido sin `Access-Control-Allow-Origin`, origen permitido reflejado, preflight con los headers correctos, `ETag`/`X-Request-Id` en `Access-Control-Expose-Headers`, ausencia de `Access-Control-Allow-Credentials`, el ajuste `trust proxy` de la instancia Express, y una ruta sin relación (`/health/live`) sin cambios de comportamiento.

### 3.2 Prefijo global de API y versionamiento URI

Mecanismo: `configureHttpApplication` (`src/bootstrap/configure-http-application.ts`) llama a `app.setGlobalPrefix('api', { exclude: [...] })` y a `app.enableVersioning({ type: VersioningType.URI, defaultVersion: '1' })`. Toda ruta de negocio queda expuesta bajo `/api/v{n}`; un método sin `@Version()` explícito hereda `defaultVersion` y responde ya en `/api/v1/...` sin que su módulo declare nada. Un método que en el futuro necesite cambiar de forma incompatible agrega `@Version('2')` junto al método existente (no al controlador completo, salvo que el controlador entero cambie): las dos versiones conviven en la misma tabla de rutas bajo `/api/v1/...` y `/api/v2/...`; ninguna reemplaza a la otra y un endpoint sin cambios entre versiones no se duplica.

**Por qué versionamiento en vez de un prefijo estático**: la razón declarada para este trabajo es permitir varias versiones de API coexistiendo en el futuro, no solo anteponer texto a la ruta. Un prefijo global fijo (`app.setGlobalPrefix('api/v1')`, sin `enableVersioning`) habría producido el mismo `/api/v1/...` de hoy, pero el día que exista una v2 real no hay forma de servirla junto a v1 sin cambiar ese prefijo global — lo que rompe toda v1 existente — o sin montar un segundo `NestApplication`/adaptador en paralelo, que Nest no necesita y este documento no adopta. `app.enableVersioning({ type: VersioningType.URI })` resuelve el problema en el mecanismo del framework: cada método declara su propia versión (o hereda `defaultVersion`), y Nest construye ambas rutas en la misma tabla de enrutamiento.

**Salud queda fuera de ambos mecanismos, deliberadamente por partida doble**: `HealthController` se declara `@Controller({ path: 'health', version: VERSION_NEUTRAL })` y sus dos rutas se listan en el `exclude` de `setGlobalPrefix` (`{ path: 'health/live', method: RequestMethod.GET }`, `{ path: 'health/ready', method: RequestMethod.GET }`). Los dos mecanismos son necesarios, no alternativos: en `RoutePathFactory.create()` (`@nestjs/core`) el segmento de versión (`/v1/...`) se inserta *antes* de que `setGlobalPrefix` decida si excluye la ruta del prefijo, y esa exclusión nunca revierte un segmento de versión ya insertado — solo evita anteponer `api`. Sin `VERSION_NEUTRAL`, salud pasaría a `/v1/health/live` aunque estuviera excluida del prefijo `api`; sin la exclusión de `setGlobalPrefix`, salud pasaría a `/api/health/live`. Ambos juntos son la única combinación que deja exactamente `/health/live` y `/health/ready`, sin prefijo ni versión, igual que antes de este cambio — la misma forma que exige el healthcheck de Railway (`.railway/railway.ts`, `healthcheck: '/health/ready'`). La razón de fondo es la misma que ya excluye salud del envoltorio `{ data }` (sección 3): es infraestructura para un probe, no superficie de API para un cliente de negocio.

**Consecuencia verificada, no asumida**: el documento OpenAPI publicado (`docs/generated/openapi.json`) describía, antes de este cambio, únicamente `/health/live` y `/health/ready` — ninguna ruta de negocio existe todavía — y las dos quedan excluidas del prefijo y de la versión. `npm run openapi:generate` produce por lo tanto un documento byte a byte idéntico al ya versionado; `npm run openapi:check` pasa sin detectar drift y `npm run openapi:compat` no encuentra ningún cambio `breaking` que evaluar contra `origin/staging`. D13 (`DECISIONS.md`) no aplica a este cambio porque no hay ninguna ruptura de contrato que aceptar ni ningún motivo para subir el major de `OPENAPI_DOCUMENT_VERSION`. Este trabajo agrega el mecanismo que la próxima ruta de negocio usará por defecto; no reescribe `docs/generated/openapi.json`.

Prueba: `test/e2e/api-versioning.e2e-spec.ts` monta `AppModule` junto a un fixture de prueba (`test/fixtures/versioning`, nunca registrado en `AppModule`) para probar de punta a punta: una ruta sin `@Version()` explícito resuelve en `/api/v1/...`; la misma ruta sin prefijo, con el prefijo pero sin versión, o con una versión que nadie declaró, responde `404` en vez de resolver por accidente; dos métodos del mismo controlador con `@Version('1')`/`@Version('2')` sobre el mismo path conviven en `/api/v1/...` y `/api/v2/...` sin que uno reemplace al otro; y `/health/live`/`/health/ready` siguen respondiendo exactamente en esas rutas, sin prefijo ni versión, con el mecanismo ya activo (`test/e2e/http-application-policy.e2e-spec.ts` fija la misma regresión).

## 4. C2 — Colecciones, cursores y consultas

### Forma única

Se usa `meta.pageInfo`, no un segundo formato `pageInfo` en la raíz. Esto conserva el envoltorio `data` + `meta`.

```http
GET /api/v1/sales/orders?limit=50&status=draft&sort=-createdAt
```

```json
{
  "data": [
    {
      "orderId": "84e7252c-c8bc-4cda-8e77-945818caa260",
      "status": "draft"
    }
  ],
  "meta": {
    "pageInfo": {
      "hasNextPage": true,
      "nextCursor": "opaque-cursor"
    }
  }
}
```

```http
GET /api/v1/sales/orders?limit=50&status=draft&sort=-createdAt&after=opaque-cursor
```

El ejemplo es abreviado; no representa el contenido real de una página completa. Al finalizar: `hasNextPage: false` y `nextCursor: null`. Una colección vacía devuelve `data: []` con ese estado final.

### Reglas de paginación

* Límite predeterminado 50 y máximo 100; una excepción necesita justificación, contrato y medición por endpoint.
* Cursor opaco y versionado, protegido contra alteraciones. El cliente lo guarda y lo devuelve, no lo interpreta.
* Validación del cursor contra endpoint/consulta, filtros, orden y contexto autorizado. El cursor no concede permisos; cada página se autoriza nuevamente.
* Orden determinista con desempate único. Por ejemplo, cuando el módulo lo permite: `created_at DESC, id DESC`, y cursor basado en ambos valores.
* Los filtros y sorts permitidos se declaran por endpoint. `sort=createdAt`/`sort=-createdAt` expresan dirección; no se aceptan columnas, SQL o expresiones arbitrarias.
* Cambiar filtros u orden comienza otro recorrido. Un cursor incompatible no se reutiliza silenciosamente.
* Cursor inválido, alterado, incompatible o vencido produce un error público explícito; no reinicia la primera página sin avisar.
* `totalCount` no es obligatorio en cada listado. Un conteo costoso necesita una necesidad de producto y consistencia declarada.
* No se introduce un motor universal de filtros ni un repositorio genérico que consulta cualquier tabla.

Antes de cerrar C2 se documentan el mecanismo de protección elegido, rotación de claves cuando aplique, tamaño máximo, política de expiración y códigos de error. Estos parámetros no se inventan como garantías implementadas en esta especificación. Codificar en base64 no equivale a proteger integridad o confidencialidad; el cursor no debe contener secretos.

### Consistencia y ownership

La paginación operacional es una vista viva salvo contrato contrario. Un orden único no crea un snapshot entre peticiones: actualizaciones, eliminaciones y cambios de pertenencia a los filtros pueden alterar lo que se observa. Los campos de orden mutables requieren política explícita del módulo.

Los reportes que requieren un corte consistente definen snapshot/`asOf` y su mecanismo, sin prometer esa consistencia para todos los listados. Un cursor de listado no se presenta como protocolo de sincronización offline.

Foundation proporciona contratos, validación y utilidades del cursor. Cada módulo conserva la consulta SQL, los índices, las reglas de autorización y la consistencia. C2 prueba las utilidades con datos de prueba; el primer listado real debe aportar evidencia sobre su consulta y sus índices.

### 4.1 Mecanismo implementado y decisiones que la especificación dejaba abiertas

`src/foundation/pagination` implementa el mecanismo compartido, agrupado por responsabilidad: `cursor/` (`cursor-codec.ts`, codificación/verificación HMAC pura; `cursor-codec.service.ts`, envoltorio inyectable sobre `CURSOR_SIGNING_KEY`; `cursor.exceptions.ts`, traducción a códigos C4; `cursor-binding.ts`, huella de a qué es válido un cursor), `keyset/` (`keyset-pagination.ts`, predicado de keyset y recorte de página) y, en la raíz, `collection-query.schema.ts` (validación C3 de `limit`/`after`/`sort`). `CollectionsModule` expone `CursorCodecService` igual que `TransactionsModule`/`TenancyModule`.

Decisiones que C2 resuelve al implementar (la especificación las dejaba abiertas):

* **Mecanismo de protección**: HMAC-SHA256 (`node:crypto`, sin dependencia nueva) sobre un token con cuatro segmentos separados por `.`: `v1.<keyId>.<payload-base64url>.<firma-base64url>`. El payload (JSON antes de codificar) contiene `exp`, `binding`, `sortValues` e `id`; nunca la clave de firma ni datos de negocio adicionales. Codificar en base64url no sustituye la firma: la firma cubre versión, `keyId` y payload completos, así que cualquier alteración de cualquiera de los tres se detecta como `collection.cursor_tampered`. El cursor está **firmado, no cifrado**: HMAC garantiza que el payload no fue alterado, pero cualquiera puede decodificar el base64url y leer `binding`, `sortValues` e `id` en texto claro. Es opaco para el cliente por convención de contrato, no confidencial por construcción. Un módulo no debe colocar valores sensibles en `sortValues` (por ejemplo un correo, un monto o un identificador que no deba exponerse) confiando en que nadie puede leerlos.
* **Formato e id de clave**: `keyId` son los primeros 16 caracteres hexadecimales de `SHA-256(CURSOR_SIGNING_KEY)`, derivados en el momento, no configurados por separado. Un cursor cuyo `keyId` no coincide con el de la clave activa se rechaza como `collection.cursor_invalid` (no se distingue de una clave desconocida, porque el servidor solo mantiene la clave activa).
* **Rotación**: fijar un nuevo valor de `CURSOR_SIGNING_KEY` y desplegar. Como el `keyId` se deriva de la clave, todos los cursores previos cambian de `keyId` automáticamente y la siguiente petición que los reutilice falla con `collection.cursor_invalid`; el cliente reinicia la paginación desde la primera página. No existe una ventana de verificación con dos claves simultáneas en C2: el radio de impacto de una rotación queda acotado por la expiración corta del cursor (ver debajo), no por infraestructura de rotación adicional. Una necesidad futura de rotación sin cortes requeriría una decisión explícita y documentada aparte.
* **Expiración**: 900 segundos (15 minutos) por defecto, ajustable por llamada a través de `EncodeCursorInput.ttlSeconds` cuando un caso de uso lo justifique, hasta un máximo de 86400 segundos (24 horas, `CURSOR_MAX_TTL_SECONDS`). `encodeCursor` exige un entero positivo dentro de ese rango y lanza una excepción inmediatamente si no lo es (`Infinity`, `NaN`, cero, negativo, no entero o por encima del máximo), en vez de aceptarlo y producir silenciosamente un cursor que nunca expira o que ya nació vencido. `EncodeCursorInput.ttlSeconds` es un parámetro interno (nunca proviene directamente de un cliente HTTP), pero la validación falla igual de fuerte que en una frontera pública. La paginación es una vista viva (sección "Consistencia y ownership"); la expiración limita cuánto tiempo un cursor sigue siendo válido, no ofrece ni promete un snapshot.
* **Tamaño máximo**: 2048 bytes por token (`CURSOR_MAX_TOKEN_LENGTH`), verificado antes de intentar decodificar. Un cursor real con un único valor de orden e id UUID mide varios cientos de bytes; el límite es un margen defensivo contra abuso, no una expectativa de uso normal. `encodeCursor` verifica el mismo límite y el mismo esquema (`CursorPayloadSchema`, que exige `binding`/`id`/cada `sortValues` no vacíos) antes de firmar, no solo `decodeCursor` al verificar: Foundation nunca debe poder emitir un cursor que ella misma rechazaría en la siguiente petición. Decodificar tolera la entrada del cliente (un JSON no parseable o un payload inválido son entrada incorrecta, no una excepción del servidor); codificar es estricto porque el payload lo construye Foundation misma, así que cualquier violación ahí es un bug propio y debe fallar en voz alta.
* **Códigos de error**: cuatro códigos estables, cada uno un `400 Bad Request` con `errors: [{ location: 'query', field: 'after', code, message }]`, sin ningún detalle interno del HMAC o del payload:

  | Código | Motivo |
  | --- | --- |
  | `collection.cursor_invalid` | Token mal formado, versión no soportada o `keyId` no reconocido |
  | `collection.cursor_tampered` | La firma no coincide con el payload |
  | `collection.cursor_expired` | `exp` ya pasó |
  | `collection.cursor_incompatible` | El binding del cursor no coincide con el de la petición actual |

  Estos códigos se producen mediante `HttpException` con `code` propio (el mecanismo que la sección 6.1 ya documenta para C4). Los cuatro están registrados en `ERROR_CODE`/`ERROR_CODE_STATUS_REGISTRY` (estado 400, `retryable: false`) y en `PROBLEM_TYPE_REGISTRY`, pero se excluyen deliberadamente del mapa inverso estado→código (`HTTP_STATUS_ERROR_CODE`, derivado por `buildHttpStatusErrorCode()` en `problem-details.builder.ts`): así `validation.failed` sigue siendo el código de respaldo para cualquier `HttpException` con estado 400 no mapeado explícitamente, y solo el `code` propio de cada excepción distingue el motivo concreto del cursor.
* **Binding**: `computeCursorBinding()` recibe un registro plano de valores (identidad de ruta/colección, filtros validados, selección de orden y contexto de tenant autorizado) y devuelve `SHA-256` de su forma canónica (claves ordenadas, valores codificados con `JSON.stringify` para no confundir `null` con la cadena `"null"`). El binding no es secreto — viaja dentro del payload firmado — pero nunca se expone en texto claro para no publicar la forma interna del contrato; su único uso es comparar igualdad. Cambiar filtro, orden o contexto de tenant cambia el binding, así que el cursor se rechaza como `collection.cursor_incompatible` en vez de aplicarse silenciosamente a la nueva consulta. Los valores numéricos deben ser finitos: `NaN` e `Infinity` se rechazan en voz alta, porque `JSON.stringify` colapsa ambos a `null` y haría que un filtro numérico inválido comparta binding con un filtro `null` real.
* **Alcance del helper de keyset**: `buildKeysetPredicate()`/`sliceKeysetPage()` cubren la forma que la sección "Reglas de paginación" ilustra: una columna de orden más un id de desempate, ambos con la misma dirección, columnas NOT NULL. No es un motor de filtros: los nombres de columna los declara el módulo (verificados contra un patrón de identificador seguro), no el cliente. Un orden compuesto por más de una columna de negocio, o un desempate sobre una columna nullable, queda fuera de C2 y requiere una extensión explícita cuando un módulo real lo necesite. `buildKeysetPredicate()` exige `paramIndexStart >= 1` (los parámetros de PostgreSQL empiezan en `$1`, nunca `$0`) y `sliceKeysetPage()` exige un `limit` entero positivo; ambas primitivas fallan en voz alta con el mismo criterio que ya protege los nombres de columna, en vez de generar un placeholder inválido o recortar la última fila silenciosamente.
* **Validación de consulta**: `createLimitSchema()` (`limit`, entero 1-100, por defecto 50), `AfterCursorSchema` (`after`, cadena no vacía acotada a 2048 caracteres) y `createSortQuerySchema(camposPermitidos)` (`sort`, solo `campo`/`-campo` de una lista declarada por endpoint) son primitivas C3 componibles; cada endpoint las combina con sus propios filtros bajo `.strict()`, igual que `identifier.schema.ts`/`decimal.schema.ts` se combinan en un esquema de negocio. Ningún esquema de C2 acepta nombres de columna, SQL o expresiones arbitrarias.
* **Evidencia**: `src/foundation/pagination/**/*.spec.ts` prueba el códec, los códigos de error y la validación de consulta de forma aislada; `keyset-pagination.integration-spec.ts` pagina `reference.nutrients` (schema `reference`, legible por `ninaku_runtime` según `database/access/runtime_grants.sql`) con PostgreSQL real; `test/e2e/collections.e2e-spec.ts` prueba el contrato HTTP completo — múltiples páginas, límite por defecto y límites, colección vacía, y cursor inválido/alterado/vencido/incompatible-por-filtro/incompatible-por-tenant — sobre un endpoint de fixture (`test/fixtures/collections`) que nunca se registra en `AppModule`.

## 5. C3 — Validación de entrada

Usar Zod y el mecanismo Standard Schema previsto en la guía del stack. Validar body, path params, query params y headers relevantes.

Cada contrato define:

* campos requeridos/opcionales y significado de `null`;
* conversiones admitidas, sin coerción accidental;
* tratamiento de campos desconocidos;
* límites de tamaño, longitud, rangos, arrays y profundidad cuando corresponda;
* formatos de identificadores, decimales, fechas y enums;
* traducción uniforme a los errores de C4.

La validación responde «¿la entrada cumple su contrato?». El dominio responde «¿esta acción está permitida en este estado?». No se mueve lógica de Sales, Inventory o Payments a un pipe.

Las pruebas cubren entrada inválida en todas las fronteras, no solo el body. Los mensajes no incluyen automáticamente el valor rechazado cuando puede ser sensible.

### 5.1 Mecanismo implementado y decisiones que la especificación dejaba abiertas

`src/foundation/validation` implementa el mecanismo compartido. Cada ruta declara su esquema con `bodySchema()`, `querySchema()`, `pathSchema()` o `headerSchema()` sobre `@Body()`/`@Query()`/`@Param()`/un decorador de parámetro dedicado, y Nest 12 valida mediante su `StandardSchemaValidationPipe` nativo.

Decisiones que C3 resuelve al implementar (la especificación las dejaba abiertas):

* El decorador nativo `@Headers()` de Nest 12.0.1 no admite adjuntar un esquema ni pipes en línea, a diferencia de `@Body()`/`@Query()`/`@Param()`. `headerSchema()` añade un decorador de parámetro propio (`RequestHeadersParam`, vía `createParamDecorator`) que reutiliza exactamente el mismo `StandardSchemaValidationPipe` y el mismo mapeo de errores, habilitando `validateCustomDecorators` solo para ese pipe.
* Política de campos desconocidos por frontera: `body`, `query` y `path` usan `.strict()` por defecto recomendado, y cada esquema lo declara explícitamente. Los headers no pueden ser estrictos: una petición real siempre transporta headers ajenos al contrato de la operación (`Host`, `User-Agent`, `Accept`, `Connection`, ...), así que el esquema de headers valida únicamente la clave nombrada por el contrato (por ejemplo `idempotency-key`) y deja pasar el resto sin interpretarlos.
* Identificadores aceptan cualquier UUID bien formado (RFC 4122, versiones 1-8), no solo UUIDv7: la frontera valida forma, no procedencia; la generación UUIDv7 sigue siendo responsabilidad del dominio/base de datos.
* Cadenas decimales exactas exigen la escala declarada de forma obligatoria (por ejemplo escala 2 acepta `"24.50"` pero rechaza `"24.5"` y `"24"`), nunca se coacciona el valor a `number`.
* Los instantes exigen sufijo `Z` (UTC explícito, sin convertir un offset no UTC); las fechas de negocio son fechas sin componente horario. Ambos son contratos distintos y no intercambiables.
* El tipo público `StandardSchemaV1.Issue` solo declara `message`/`path`, pero Zod no elimina su propio código de incidencia al exponerlo a través de `~standard.validate`; C3 lee ese código en tiempo de ejecución (con una guarda sobre `unknown`, nunca `any`) para producir un código de campo estable `validation.<motivo>`. Un emisor de otro validador Standard Schema sin ese campo cae al código genérico `validation.invalid`, nunca a un código inventado.
* Un fallo de nivel raíz (sin segmento de propiedad, `path: []`) se reporta con `field: ''`; no existe otra convención previa en el catálogo de C4 para ese caso.
* La validación de cursores es responsabilidad de C2; ningún esquema de C3 interpreta `after`/`cursor`.

## 6. C4 — Errores y traducción segura

Errores con `application/problem+json`, sin envolverlos en `data`. Problem Details RFC 9457 es la referencia; `code`, `traceId`, `retryable` y `errors` son extensiones de Ninaku.

```http
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json
```

```json
{
  "type": "/problems/validation-error",
  "title": "Solicitud inválida",
  "status": 400,
  "detail": "Revisa los campos indicados.",
  "code": "validation.failed",
  "traceId": "4bf92f3577b34da6a3ce929d0e0e4736",
  "retryable": false,
  "errors": [
    {
      "location": "body",
      "field": "items[0].quantity",
      "code": "validation.positive_required",
      "message": "La cantidad debe ser mayor que cero."
    }
  ]
}
```

Reglas:

* `errors`, cuando existe, es siempre una lista del mismo esquema, nunca alternativamente un objeto por campo. `location` identifica body, query, path o header según el contrato.
* `code` es estable y sirve para lógica y traducción del cliente. No analizar `title`, `detail` o `message` como códigos.
* `status` coincide con el estado HTTP real. `type` identifica una clase documentada de problema.
* `traceId` corresponde al contexto real de la ejecución cuando está disponible; no se inventan identificadores para aparentar instrumentación.
* Validación, autenticación, autorización, rutas inexistentes y fallos inesperados controlados por la aplicación pasan por el contrato común. No prometer reformatear respuestas que genera un proxy externo antes de llegar a Nest.
* Sin SQL, nombres internos de constraints, stack traces, secretos, detalles de conexión o datos personales en la respuesta pública.
* Traducir PostgreSQL mediante códigos/constraints conocidos y mapeos explícitos. No buscar texto en mensajes ni convertir cualquier constraint desconocido a un conflicto de negocio.
* Los módulos producen errores tipados; Domain/Application no construyen respuestas HTTP.
* Reintentos y respuestas 202 no sustituyen el contrato durable de idempotencia, ni convierten un timeout de proveedor en prueba de que no hubo efecto externo.

C4 incluye registro/catálogo de códigos, estados y esquemas. No agrega códigos especulativos para todas las verticales futuras.

### 6.1 Catálogo implementado y decisiones que la especificación dejaba abiertas

El filtro global (`HttpAdapterHost`) y el registro de códigos viven en `src/foundation/errors`. El catálogo actual cubre exactamente los estados de error de la tabla de la sección 3:

| Código | Estado | `retryable` |
| --- | --- | --- |
| `validation.failed` | 400 | false |
| `auth.unauthenticated` | 401 | false |
| `auth.unauthorized` | 403 | false |
| `resource.not_found` | 404 | false |
| `conflict.business` | 409 | false |
| `precondition.failed` | 412 | false |
| `payload.too_large` | 413 | false |
| `media_type.unsupported` | 415 | false |
| `content.unprocessable` | 422 | false |
| `precondition.required` | 428 | false |
| `rate_limit.exceeded` | 429 | true |
| `service.unavailable` | 503 | true |
| `tenant.context_rejected` | 403 | false |
| `error.unexpected` | 500 | false |

Un `HttpException` de Nest con un estado no documentado se sanea siempre como `error.unexpected`, conservando el estado HTTP real. Un fallo que no sea `HttpException` también se sanea a `error.unexpected` salvo el caso explícito de errores de `body-parser`/`raw-body` descrito abajo; en ambos saneos el mensaje es genérico, sin stack ni detalle interno.

Decisiones que C4 resuelve al implementar (la especificación las dejaba abiertas):

* `TenantContextRejectedError` (rechazo `42501` de `identity.assert_tenant_context()`) se traduce a `403` con el código `tenant.context_rejected`, no a `404`: no es "recurso no disponible en el contexto autorizado" sino un contexto de tenant inválido detectado por RLS, y nunca expone el mensaje interno de PostgreSQL.
* La tabla de traducción PostgreSQL por SQLSTATE hoy contiene únicamente `42501` (mismo código que `TenantContextRejectedError`, para el caso en que un `pg` error crudo con ese SQLSTATE llegue directo al filtro). Cualquier otro SQLSTATE o constraint desconocido cae al camino saneado de `error.unexpected`, nunca a un conflicto de negocio inventado.
* Un `HttpException` puede aportar su propio `code`/`detail`/`errors` en el cuerpo de la excepción; el filtro los reenvía sin modificar (sujeto al saneo de `detail` en estados 5xx). `retryable` nunca se lee del cuerpo de la excepción: siempre es el valor fijo de `ERROR_CODE_STATUS_REGISTRY` para ese código, así que una excepción que incluya su propia propiedad `retryable` la ve ignorada.
* 413 (`payload.too_large`), 415 (`media_type.unsupported`) y 422 (`content.unprocessable`) participan en el mismo mapa de respaldo estado→código (`HTTP_STATUS_ERROR_CODE`, derivado por `buildHttpStatusErrorCode()`) que el resto de la tabla de la sección 3: un `HttpException` con uno de esos tres estados y sin `code` propio ya no cae en `error.unexpected`, y conserva su `detail`/`errors` como cualquier otro estado documentado. La validación de entrada (C3) sigue usando exclusivamente `400`/`validation.failed`; `content.unprocessable` cubre el contrato RFC 9110 §15.5.21 de "bien formado pero semánticamente inválido" para el `HttpException` que lo necesite, sin mover ningún camino de validación existente a 422.
* **Un cuerpo de solicitud real demasiado grande, o con un charset/encoding no soportado, produce el estado documentado, no un 500 genérico.** Express usa `body-parser`/`raw-body` (vía `http-errors`) para esos rechazos, y esos objetos nunca son `instanceof HttpException` de Nest, así que sin más caían en el saneo genérico de `error.unexpected` con estado 500 aunque el estado real ya fuera 4xx — el cliente recibía "fallo del servidor" por haber enviado una solicitud inválida, y `recordServerFailureResponse` lo registraba en `error` junto a los fallos reales. `buildProblemDetails` reconoce ahora la forma de esos errores con una guarda de tipo sobre `unknown` (nunca un cast): un objeto con `type` igual a `entity.too.large`, `entity.parse.failed`, `encoding.unsupported` o `charset.unsupported` (los únicos tipos que `body-parser`/`raw-body` producen) y un `status`/`statusCode` numérico se resuelve a través del mismo `HTTP_STATUS_ERROR_CODE` que un `HttpException`. El `detail`/`message` del error original nunca se reenvía — esos objetos pueden llevar el charset o el contenido rechazado del cliente incrustado en su mensaje — solo se usa el estado y el mensaje por defecto del catálogo. Cubre 413 (`entity.too.large`), 415 (`charset.unsupported`, `encoding.unsupported`) y 400 (`entity.parse.failed`, para un fallo de parseo que no sea el `SyntaxError` de JSON que Nest ya convierte en `BadRequestException` antes de llegar aquí). Probado extremo a extremo (`test/e2e/problem-details.e2e-spec.ts`) con una solicitud real que excede el límite de tamaño y con un `Content-Type` con un charset inválido, no solo con un `HttpException` construido a mano.
* Las rutas que conservan contrato propio (health checks, sección 3) se declaran con `@UseFilters(ProtocolExceptionFilter)`, no con inferencia automática: el filtro de protocolo reenvía la respuesta original de la excepción sin traducirla a Problem Details.

## 7. C5 — Logs, trazas, métricas y auditoría

### 7.1 Un límite transversal, no un framework propio

Inicialmente usar el logger JSON de Nest detrás de una interfaz pequeña de Ninaku que centralice formato, contexto, campos permitidos y política de errores. No implementar un logger desde cero ni permitir una configuración por módulo.

Pino/`nestjs-pino` sigue siendo una alternativa evaluable: adoptarlo solo mediante una decisión documentada, compatibilidad verificada y las mismas pruebas de seguridad/rendimiento. Cambiar el adaptador no debe requerir reescribir módulos.

La estrategia de trazas adoptada es **OpenTelemetry con W3C Trace Context**, con exportación configurable y sin SDKs APM solapados. Elegir instrumentaciones compatibles es parte de C5; aprobar este documento no instala paquetes ni activa un proveedor comercial. `@nestjs/observe` no es un segundo SDK obligatorio.

### 7.2 Identificadores distintos

| Identificador | Finalidad |
| --- | --- |
| `requestId` | Un intento HTTP; disponible mediante `X-Request-Id` en respuestas controladas |
| `traceId` | Correlacionar segmentos instrumentados de una ejecución |
| `commandId` | Intención estable de negocio que puede sobrevivir reintentos/offline |

No usar el mismo identificador para todas las finalidades. Un retry puede conservar `commandId` y tener otra petición/traza. La política de enlace y propagación se documenta para workers e integraciones cuando existan.

El trace ID sigue W3C/OpenTelemetry; los ejemplos usan 32 caracteres hexadecimales para la traza y 16 para el span. En JSON técnico se serializan como `trace_id`/`span_id`; en Problem Details la extensión pública conserva `traceId`. Es un mapeo explícito, no dos trazas distintas.

Solo incluir campos de traza/span cuando exista contexto válido. No inventar spans en logs de arranque. Un ID en un log no equivale a instrumentar una traza. Los valores de `traceparent` o baggage no conceden permisos ni establecen el tenant; aplicar una política de confianza y propagación sin copiar datos sensibles.

### 7.3 Formato y campos

JSON estructurado, un registro por línea. Ejemplo del formato de aplicación; no es un payload OTLP:

```json
{
  "timestamp": "2026-09-15T12:30:45.123Z",
  "level": "info",
  "event": "http.request.completed",
  "service": "ninaku-api",
  "serviceVersion": "commit-sha",
  "environment": "staging",
  "requestId": "01994821-16a0-784e-8b2c-4ec8d344e9a6",
  "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
  "span_id": "00f067aa0ba902b7",
  "module": "sales",
  "operation": "CreateOrder",
  "http": {
    "method": "POST",
    "route": "/api/v1/sales/orders",
    "statusCode": 201,
    "durationMs": 48
  }
}
```

Los campos comunes son timestamp, level, event, service, serviceVersion y environment. Contexto HTTP, módulo, operación, error y organización solo cuando estén disponibles y permitidos. Para rutas usar plantillas, no URLs con query strings o identificadores sensibles.

La estructura del error técnico usa campos permitidos como `error.code` y `error.category`; stacks internos, cuando sean necesarios, se sanitizan y restringen. No serializar indiscriminadamente objetos `Error`, causas o respuestas de SDKs.

### 7.4 Cuatro responsabilidades

| Señal | Propósito | Destino/ownership |
| --- | --- | --- |
| Logs técnicos | Diagnóstico de peticiones, fallos e hitos | Salida del proceso y recolección central, fuera de la base transaccional |
| Trazas | Segmentos HTTP, espera de pool, SQL y llamadas externas instrumentadas | Exportación OpenTelemetry configurable |
| Métricas | Tráfico, latencia, errores y saturación | Backend de métricas configurado, con dimensiones acotadas |
| Auditoría | Evidencia de negocio y seguridad | Mecanismo durable con propietario, autorización y retención definidos |

No crear una tabla universal de logs. Reutilizar y revisar el mecanismo de auditoría existente antes de proponer persistencia nueva.

Una auditoría exigida por un caso de uso no depende solo de stdout ni de un servicio de logs. Su persistencia forma parte de la transacción o del mecanismo durable especificado. Un fallo de auditoría obligatoria necesita política del caso de uso; no se confunde con pérdida tolerada de telemetría técnica.

C define esa frontera y sus mecanismos compartidos, no la auditoría de todas las acciones de módulos aún inexistentes.

### 7.5 Datos permitidos y protección

No registrar requests, responses, usuarios, headers o parámetros SQL completos por defecto. Construir registros con campos permitidos y usar redacción adicional como defensa, no como sustituto de la minimización.

Excluir contraseñas, PIN/OTP, tokens, cookies, API keys, cadenas de conexión, credenciales de pago y material privado de certificados. No capturar por defecto cédulas, teléfonos, correos o XML fiscales completos.

Aplicar la política también a mensajes/cadenas de error, causas, URLs, respuestas de proveedores, instrumentación automática y exportadores. Limitar tamaño y estructura; no interpolar entrada no confiable en líneas de log. Probar el registro ya serializado/exportado, no únicamente una función de redacción aislada.

Los identificadores de organización, actor y recurso requieren finalidad y permisos de consulta. No convertir request IDs, trace IDs, order IDs o tenant IDs en etiquetas de métricas o índices de cardinalidad ilimitada.

### 7.6 Severidad, duplicados y volumen

| Nivel | Uso |
| --- | --- |
| debug | Diagnóstico temporal y controlado; apagado en producción por defecto |
| info | Resultado normal e hitos útiles |
| warn | Degradación o situación que necesita seguimiento |
| error | Fallo técnico inesperado o de operación |
| fatal | El proceso no puede continuar con seguridad |

Los rechazos esperados de validación/negocio no producen automáticamente stack traces ni alertas urgentes. Definir un responsable para registrar un error inesperado; no repetir el mismo stack en repositorio, servicio, controller y filtro.

Registrar el resumen HTTP una vez y utilizar spans/eventos cuando añadan contexto real. Suprimir o muestrear health checks exitosos. La auditoría obligatoria no se muestrea.

Un `404` no es automáticamente `warn`. Cuando ninguna ruta de Express coincidió con la petición (`route` resuelve a `unmatched`), nadie del sistema se equivocó: alguien pidió algo que nunca existió, y eso es el caso más común en un host público apenas publicado, donde cualquier rastreador de Internet genera ese mismo `404` sin que haga falta ninguna acción. Ese caso se registra a `info`. Un `404` sobre una ruta que sí coincidió (por ejemplo un futuro `/organizations/:id` cuyo id no existe) sí se queda en `warn`: la API conoce la ruta y el recurso falta, lo que puede señalar un cliente roto o una referencia colgante. El resto de la clasificación no cambia: `5xx` sigue en `error`, cualquier otro `4xx` sigue en `warn`, y todo lo demás sigue en `info`. `warn` se reserva para lo que una persona puede necesitar revisar; diluirlo con ruido que no requiere acción es la forma más rápida de que deje de significar algo.

Definir presupuesto de bytes por registro, volumen, niveles, muestreo, retención por entorno/clase de dato y permisos de acceso. No inventar aquí plazos legales o cifras de capacidad. Las alertas deben ser accionables y tener responsable, no dispararse por cada mensaje rojo.

### 7.7 Exportación y fallos

```text
Logs JSON → stdout/stderr → recolección/almacenamiento central
Trazas/métricas → exportación OpenTelemetry → destino configurado
Auditoría → persistencia durable del mecanismo propietario
```

La exportación técnica no forma parte de la transacción de venta. Definir buffers/colas acotados, timeouts, reintentos limitados, drenado al apagar y política de descarte. Medir fallos y pérdidas; no prometer entrega infalible ni crecimiento ilimitado de memoria cuando el destino cae.

Un Collector es una opción de despliegue, no una infraestructura adicional obligatoria por defecto. Evitar dependencias de un proveedor dentro de Domain/Application.

C5 exige una prueba en staging de búsqueda por `requestId` y correlación con una ejecución instrumentada, junto con la política de acceso/retención/costo. Ver JSON en consola no basta. El proveedor, límites concretos y configuración de almacenamiento se eligen y documentan durante esa entrega; no se marcan resueltos por este PR documental.

### 7.8 Mecanismo implementado y decisiones que la especificación dejaba abiertas

`src/foundation/logging` implementa el límite de logging: `log-record.ts` construye el registro JSON permitido (`timestamp` ISO 8601, `level`, `event`, `service`, `serviceVersion`, `environment` y, condicionalmente, `requestId`/`trace_id`/`span_id`/`module`/`operation`/`organizationId`/`http`/`error`/`message`), `redaction.ts` aplica la política de datos permitidos y `known-secrets.registry.service.ts` reúne los valores secretos configurados (contraseña de base de datos, `CURSOR_SIGNING_KEY`, cabeceras del exportador OTLP) para que la redacción los reconozca aunque aparezcan incrustados en un mensaje libre. `NinakuLoggerService` implementa `LoggerService` de Nest (se conecta con `app.useLogger()`, sustituyendo también los mensajes internos del framework) y añade `event(level, nombre, campos)` para las llamadas estructuradas propias. `src/foundation/telemetry` implementa el arranque de OpenTelemetry (`tracing-pipeline.ts`, `metrics-pipeline.ts`, `node-instrumentation.ts`, `telemetry-bootstrap.ts`), la exportación con redacción (`redacting-span-exporter.ts`) y la instrumentación HTTP (`http-telemetry.middleware.ts`, `http-metrics.service.ts`).

Decisiones que C5 resuelve al implementar (la especificación las dejaba abiertas):

* **Por qué no el `ConsoleLogger` JSON de Nest tal cual**: el modo `json` de `ConsoleLogger` emite `timestamp` como epoch numérico y `level` con los nombres internos de Nest (`log`, `verbose`), no el `timestamp` ISO 8601 ni los cinco niveles (`debug`/`info`/`warn`/`error`/`fatal`) que exige la sección 7.6. `NinakuLoggerService` sigue implementando el mismo contrato `LoggerService` que Nest expone (mismo punto de sustitución que `nestjs-pino` usaría) pero construye el registro JSON él mismo con `process.stdout`/`process.stderr` directamente; cambiar de adaptador en el futuro no requiere reescribir módulos, igual que exige la sección 7.1.
* **Redacción en dos capas**: por nombre de campo (`password`, `secret`, `token`, `authorization`, `signingKey`, `otp`, `pin`, etc. — coincidencia insensible a mayúsculas sobre la clave, redacción total sin mirar el valor) y por contenido (sustitución de cualquier valor secreto conocido dondequiera que aparezca dentro de una cadena, más patrones `Bearer <token>`, `Basic <credenciales>` y `usuario:contraseña@` en URLs). La redacción por contenido es la defensa que cubre mensajes de error, causas y salidas de proveedores que no pasan por un campo con nombre reconocible. Cada cadena se acota a 2000 caracteres y el registro completo a 8 KiB; si excede el presupuesto, se sustituye por un registro mínimo (`timestamp`, `level`, `event`, identidad de correlación, `truncated: true`) en vez de truncar campos a mitad de palabra. Cuando `JSON.stringify` del registro completo lanza una excepción (una estructura no serializable, no el presupuesto de tamaño), `log-record.ts` distingue ese caso del anterior: emite el mismo conjunto mínimo de campos seguros pero con `serializationFailed: true` en vez de `truncated: true`, para que un consumidor no confunda "se recortó por tamaño" con "no se pudo serializar en absoluto". Si incluso ese registro mínimo fallara al serializarse, la línea de último recurso sigue siendo `{"level":"error","event":"log.serialization_failed"}`: un catch puede decidir que un error no se propague, pero nunca que deje de conocerse.
* **Una sola política de nombres sensibles, no dos que puedan desacordar**: `logging/redaction/redaction.ts` y `telemetry/pipelines/node-instrumentation.ts` mantenían cada uno su propio vocabulario de nombres sensibles y ya habían empezado a desacordar (`sig`/`Signature`/`AWSAccessKeyId`/`X-Goog-Signature`/`X-Amz-Signature`/`X-Amz-Credential`/`X-Amz-Security-Token` solo en instrumentación; `authorization`/`cookie`/`dsn`/`connectionstring`/`credential`/`certificate`/`privatekey`/`signingkey` solo en redacción). `logging/redaction/sensitive-field-names.ts` es ahora la única fuente: declara las palabras sueltas (`SENSITIVE_FIELD_NAME_WORDS`), las compuestas de dos palabras (`SENSITIVE_FIELD_NAME_COMPOUND_WORDS`, por ejemplo `api`+`key`) y las adicionales exclusivas de query params (`ADDITIONAL_SENSITIVE_QUERY_PARAM_NAMES`: `cursor`, `code`). `redaction.ts` la usa para `isSecretFieldName`; `node-instrumentation.ts` construye `REDACTED_HTTP_QUERY_PARAM_NAMES` concatenando ese mismo vocabulario con sus propias adiciones de firma de nube (`sig`, `Signature`, `AWSAccessKeyId`, `X-Goog-Signature`, `X-Amz-Signature`, `X-Amz-Credential`, `X-Amz-Security-Token`), que siguen siendo suyas porque son nombres de parámetro exactos de un proveedor, no palabras genéricas de campo.
* **Coincidencia por token completo, no por subcadena libre**: la política anterior probaba el patrón como subcadena insensible a mayúsculas contra la clave cruda, así que un campo inocente como `shipping` o `footprint` se redactaba por completo solo por contener las letras `pin`/`otp` en algún punto de su ortografía. `isSensitiveFieldName` tokeniza el nombre de campo por límites de mayúscula/minúscula y separadores (`_`, `-`, espacio) y compara tokens completos contra el vocabulario compartido, incluyendo el caso pegado sin separador (`apikey`) y el separado en dos tokens (`api_key`/`apiKey`). Ningún campo de este árbol colisiona hoy, así que este cambio es prevención: la sobre-redacción degrada el diagnóstico en vez de filtrar datos, pero es igual de indeseable a largo plazo.
* **`traceId`/`trace_id`/`span_id` reales, no fabricados**: `TraceContextMiddleware` prioriza el span activo de OpenTelemetry (`trace.getActiveSpan()`) cuando `@opentelemetry/instrumentation-http` ya inició un span real para la petición; solo si no hay contexto OTel válido cae al mecanismo previo de B (generar o extraer de `traceparent`). El filtro de C4 sigue leyendo `TraceContextService.getTraceId()` sin cambios; ahora ese valor es el trace id real cuando la instrumentación está activa. `resolveActiveSpanContext()` (`correlation/trace/active-span-context.ts`) es la única implementación de esa preferencia; tanto el middleware como `TraceContextService` la importan en vez de duplicarla. `TraceContextService.getTraceId()`/`getSpanId()` ya no se limitan a devolver la instantánea que el middleware capturó una sola vez al entrar la petición: en cada llamada vuelven a preferir el span activo de OpenTelemetry cuando hay uno válido, y solo caen a esa instantánea si no lo hay. Sin esta preferencia, un log emitido dentro de un span hijo de `PgInstrumentation` (una consulta SQL) habría reportado el `span_id` del span HTTP padre en vez del span de la consulta en curso; hoy nada del árbol crea spans manuales, así que el caso no era observable, pero el mecanismo queda correcto antes de que algo lo necesite.
* **Exportador deshabilitado por defecto, sin variable de Railway**: `OTEL_EXPORTER_OTLP_ENDPOINT` (URL completa, opcional) es la única señal de activación. Sin ella, `createTracingPipeline`/`createMetricsPipeline` registran cero `SpanProcessor`/`MetricReader`: se siguen generando `traceId`/`spanId` W3C válidos (para correlación en logs) pero no se intenta ninguna llamada de red. Con la variable presente, se añade `/v1/traces` o `/v1/metrics` a la URL configurada y se usa `@opentelemetry/exporter-trace-otlp-http`/`@opentelemetry/exporter-metrics-otlp-http`. Ninguna variable se declaró en `.railway/railway.ts` en este trabajo, según lo pedido.
* **Propagación W3C sin baggage**: `NodeTracerProvider.register({ propagator: new W3CTraceContextPropagator() })` registra solo Trace Context, deliberadamente sin `W3CBaggagePropagator` (que Node registraría por defecto si no se pasara `propagator`): baggage permite portar pares clave/valor arbitrarios entre servicios, y no hay ningún caso de uso que lo requiera todavía; añadirlo sin necesidad sería otra superficie de fuga de datos que la sección 7.5 pide evitar.
* **Exportación acotada**: `BatchSpanProcessor`/`PeriodicExportingMetricReader` usan `OTEL_BSP_MAX_QUEUE_SIZE` (2048), `OTEL_BSP_MAX_EXPORT_BATCH_SIZE` (512) y `OTEL_BSP_SCHEDULE_DELAY_MS` (5000 ms) por defecto; superado el tamaño de cola, el SDK de OpenTelemetry descarta spans nuevos en vez de crecer sin límite. `OTEL_EXPORT_TIMEOUT_MS` (10000 ms) acota cada intento de exportación y `OTEL_SHUTDOWN_TIMEOUT_MS` (12000 ms) acota el drenado al apagar (`bounded-shutdown.ts`): si el exportador configurado nunca resuelve, `shutdown()` igual resuelve dentro de ese plazo. El esquema de entorno rechaza en el arranque cualquier combinación donde `OTEL_SHUTDOWN_TIMEOUT_MS` sea menor que `OTEL_EXPORT_TIMEOUT_MS` (mismo mecanismo `superRefine` que ya valida `OTEL_BSP_MAX_EXPORT_BATCH_SIZE` contra `OTEL_BSP_MAX_QUEUE_SIZE`): si el plazo de apagado fuera más corto, un intento de exportación en curso se cortaría antes de que su propio timeout tuviera oportunidad de completarlo, perdiendo telemetría en cada apagado por defecto en vez de solo cuando el destino falla. `withBoundedTimeout` ya no resuelve en silencio: devuelve `completed`/`timed-out`/`failed`, y cada pipeline (`tracing-pipeline.ts`, `metrics-pipeline.ts`) reporta con `console.error` cuando el resultado no es `completed`, nombrando el pipeline y el resultado — el mismo `console.error` deliberado que usa `reportShutdownFailure` en `src/bootstrap/graceful-shutdown.ts`, porque a esa altura del apagado el logger de la aplicación puede ya estar cerrado. `RedactingSpanExporter` envuelve cualquier exportador (real o de prueba) y redacta atributos de span, atributos de evento, el mensaje de estado (`status.message`; el código de estado es un enum y no se redacta) y los atributos de cada link antes de reenviarlos, sin mantener buffers propios; cuando el análisis de una query string lanza una excepción, el resultado es el marcador de redacción completo, nunca el valor original sin redactar.
* **Instrumentación HTTP y PostgreSQL mínima y explícita**: `@opentelemetry/instrumentation-http` se usa con su configuración por defecto (no captura cabeceras como atributos de span salvo que se declare `headersToSpanAttributes`, que este trabajo no declara). `@opentelemetry/instrumentation-pg` se configura con `enhancedDatabaseReporting: false` (nunca adjunta los valores de los parámetros de la consulta, solo el texto SQL) y `requireParentSpan: true` (solo instrumenta consultas dentro de una petición ya trazada, para no generar spans huérfanos de tareas internas del pool). El arranque de la instrumentación ocurre en `main.ts`, que llama a `startTelemetry()` antes de llamar a `createApplication()` (`src/bootstrap/create-application.ts`), y es esta última función la que importa `AppModule` de forma dinámica (por tanto antes de la primera carga de `pg`): un `import()` dinámico después de `startTelemetry()` es necesario porque un `import` estático de nivel superior, en cualquier módulo alcanzable estáticamente desde `main.ts`, se evaluaría antes de que el proceso llegue a esa línea. `tooling/repository/check-main-imports.ts` recorre esa cadena de imports estáticos y falla el build si alguno de esos módulos importa `AppModule` estáticamente o alcanza `http`/`pg` como especificador desnudo.
* **Métricas mínimas y de cardinalidad acotada**: `HttpMetricsService` expone únicamente `http.server.request.count` (contador) y `http.server.request.duration` (histograma **en segundos**, la unidad que exige la convención semántica estable de OpenTelemetry para ese nombre de instrumento; el registro JSON de logs sigue reportando `durationMs` para lectura humana, solo la métrica cambió de unidad), con las etiquetas `http.request.method`, `http.route` (plantilla de Express, o `unmatched`, nunca la URL cruda con query string), `http.response.status_code` y `ninaku.http.outcome` (`completed`/`aborted`, ver más abajo; dos valores fijos, sin riesgo de cardinalidad). Ningún `requestId`, `trace_id`, `organizationId` u otro identificador de alta cardinalidad se usa como etiqueta. Los buckets explícitos por defecto del SDK (`0, 5, 10, ..., 10000`) están calibrados para milisegundos; sin ajustarlos, toda petición normal (menor a 5 segundos) colapsaría en el primer bucket una vez la unidad pasa a segundos. `createMetricsPipeline` registra un `View` sobre `http.server.request.duration` con los límites recomendados por la convención semántica ya expresados en segundos (`0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, 0.75, 1, 2.5, 5, 7.5, 10`). No se introdujeron métricas de saturación del pool ni de negocio en C5: quedan para cuando exista el caso de uso o el módulo real que las necesite. La forma `{ method, route, statusCode, durationMs }` que comparten el registro de log HTTP y `HttpTelemetryFields` vive en `correlation/http-request-fields.ts` (`HttpRequestFields`), no en `logging/`: no tiene nada de específico de logs, y antes obligaba a `telemetry/http/http-metrics.service.ts` a importar de `logging/` solo para tomar prestada una forma que ninguno de los dos módulos posee en exclusiva.
* **Nivel mínimo por entorno**: `LOG_LEVEL` (opcional; `debug`/`info`/`warn`/`error`/`fatal`) sobrescribe el valor por defecto, que es `info` en `production` y `debug` en cualquier otro `NODE_ENV`, cumpliendo que depuración esté apagada en producción por defecto sin impedir activarla explícitamente.
* **Suprimir, no muestrear, los health checks exitosos**: `HttpTelemetryMiddleware` omite el registro `http.request.completed` cuando la ruta empieza por `/health`, el estado es menor que 400 y la petición terminó normalmente; un health check fallido sí se registra (a `warn`/`error` según el estado), porque señala degradación real, y uno abortado por el cliente también se registra siempre, incluso sobre `/health`, porque una desconexión a mitad de un health check no es el caso rutinario que la supresión existe para silenciar. Las métricas de tráfico igual se registran para toda petición, exitosa, fallida o abortada.
* **Peticiones abortadas antes de completarse**: `HttpTelemetryMiddleware` escuchaba únicamente `response.on('finish')`, así que un cliente que se desconecta antes de que la respuesta termine (frecuente en clientes móviles con reconexión) no dejaba línea de log ni punto de métrica. El middleware ahora también escucha `response.on('close')` (que Node emite tanto al completar la respuesta como al cortarse la conexión antes de completarla) y usa un indicador local para registrar como máximo una vez por petición, sin importar el orden o la cantidad de veces que disparen los dos eventos. `ninaku.http.outcome` distingue ambos casos con exactamente dos valores (`completed`/`aborted`); `http.response.status_code` en una petición abortada refleja lo que el objeto `Response` tenía en ese instante (a menudo el `200` por defecto de Express si nunca se llamó a `res.status()`), por lo que un consumidor no debe interpretar el código de estado como significativo cuando `outcome` es `aborted`.
* **`X-Request-Id`, trace id y log lines**: `test/e2e/logging-correlation.e2e-spec.ts` prueba que cada respuesta se corresponde con exactamente una línea `http.request.completed` con el mismo `requestId`, y que peticiones concurrentes no mezclan `requestId` ni `trace_id`; `test/e2e/telemetry-correlation.e2e-spec.ts` prueba que una petición a `/health/ready` produce un span HTTP y un span de PostgreSQL anidado bajo el mismo `traceId` (evidencia de instrumentación real, no solo un campo `traceId` decorativo).

Configuración añadida (`src/foundation/config/schema/environment-variables.schema.ts`, expuesta por `AppConfigService.logging`/`.telemetry`/`.serviceIdentity`):

| Variable | Tipo/validación | Por defecto |
| --- | --- | --- |
| `SERVICE_VERSION` | cadena opcional, recortada | `RAILWAY_GIT_COMMIT_SHA` (primeros 12 caracteres) si está presente, si no `"unknown"` |
| `RAILWAY_GIT_COMMIT_SHA` | cadena opcional, recortada; no aparece en la configuración ya validada, solo alimenta la resolución de `SERVICE_VERSION` | inyectada por Railway cuando el despliegue viene de un trigger de GitHub |
| `LOG_LEVEL` | `debug`|`info`|`warn`|`error`|`fatal`, opcional | según `NODE_ENV` (ver arriba) |
| `OTEL_EXPORTER_OTLP_ENDPOINT` | URL opcional (cadena vacía tratada como ausente) | deshabilitado |
| `OTEL_EXPORTER_OTLP_HEADERS` | cadena `clave=valor` separada por comas, opcional | `{}` |
| `OTEL_EXPORT_TIMEOUT_MS` | entero positivo | `10000` |
| `OTEL_BSP_MAX_QUEUE_SIZE` | entero positivo | `2048` |
| `OTEL_BSP_MAX_EXPORT_BATCH_SIZE` | entero positivo | `512` |
| `OTEL_BSP_SCHEDULE_DELAY_MS` | entero positivo | `5000` |
| `OTEL_SHUTDOWN_TIMEOUT_MS` | entero positivo, debe ser `>= OTEL_EXPORT_TIMEOUT_MS` | `12000` |
| `OTEL_TRACES_SAMPLER_RATIO` | número entre `0` y `1` | `1` |
| `OTEL_METRICS_EXPORT_INTERVAL_MS` | entero positivo | `10000` |

El propietario ya eligió el destino de exportación: **Grafana Cloud**, contra su gateway OTLP en la región `prod-sa-east-1`. El servicio `api` de staging lo tiene configurado mediante `OTEL_EXPORTER_OTLP_ENDPOINT` (la URL base termina en `/otlp`, porque el código le añade `/v1/traces` y `/v1/metrics`) y `OTEL_EXPORTER_OTLP_HEADERS`.

Advertencia operativa: `OTEL_EXPORTER_OTLP_HEADERS` debe llevar el par completo `Authorization=Basic <base64>`, no solo el valor base64. `parseOtlpHeaders` (`telemetry-configuration.ts`) exige que cada entrada separada por comas tenga la forma `nombre=valor`; una entrada sin `=`, sin nombre o con el valor vacío hace fallar el arranque con un error que nombra `OTEL_EXPORTER_OTLP_HEADERS`, en vez de producir cabeceras vacías y dejar el exportador enviando peticiones sin autenticar hasta que el destino las rechace con un `401` silencioso. El valor de la cabecera está registrado como secreto conocido y se redacta de logs, spans y salida exportada.

Evidencia verificada en staging: las trazas llegan con spans hijos reales, no decorativos. Una traza de una petición muestra el span de servidor HTTP junto con los segmentos `pg.query` y `pg-pool.connect` anidados bajo el mismo `traceId`, que es justamente lo que la matriz de aceptación de la sección 8 exige como segmentos útiles.

**Muestreo configurable, sin cambiar el comportamiento por defecto**: `OTEL_TRACES_SAMPLER_RATIO` (0 a 1, por defecto `1`) alimenta `TraceIdRatioBasedSampler` en `tracing-pipeline.ts`; con el valor por defecto, `createTracingPipeline` sigue usando `AlwaysOnSampler` explícitamente (equivalente a un ratio de `1`, sin depender de que el hash del trace id coincida) para que ningún despliegue existente cambie de comportamiento hasta que alguien reduzca el ratio deliberadamente. La decisión de qué ratio usar en producción — y su relación con el volumen y costo de Grafana Cloud — sigue sin tomarse por este trabajo; solo el mecanismo para tomarla sin un cambio de código queda resuelto.

**Cadencia de métricas independiente del batching de trazas**: `metrics-pipeline.ts` calculaba antes su intervalo de exportación como `max(OTEL_BSP_SCHEDULE_DELAY_MS, OTEL_EXPORT_TIMEOUT_MS)`, una variable explícitamente scoped al `BatchSpanProcessor` de trazas en su propio nombre. Ajustar el batching de trazas estiraba en silencio la cadencia de métricas. `OTEL_METRICS_EXPORT_INTERVAL_MS` (por defecto `10000`, el mismo valor efectivo que el cálculo anterior producía con los valores por defecto de trazas) le da a métricas su propia variable; `PeriodicExportingMetricReader` ya no lee nada de la configuración de `BatchSpanProcessor`.

Sigue abierto, sin resolver por este trabajo: la retención, los permisos de acceso y el presupuesto de volumen/costo de logs y trazas por entorno. La cuenta de Grafana Cloud está en plan de prueba (trial); estos puntos deben revisarse en cuanto exista volumen real, no antes.

**Resolución de `SERVICE_VERSION`**: el esquema de entorno (`environment-variables.schema.ts`) implementa una cadena de resolución con tres pasos, evaluada por el propio esquema Zod antes de exponer la configuración validada: un `SERVICE_VERSION` explícito gana siempre; si no está definido, se usa `RAILWAY_GIT_COMMIT_SHA` truncado a sus primeros 12 caracteres; si tampoco existe, el valor es `"unknown"`. `RAILWAY_GIT_COMMIT_SHA` se lee de `process.env` como cualquier otra variable (nunca se asume presente; no aparece en las variables del servicio en Railway del mismo modo que tampoco aparece `PORT`, aunque ambas existen en tiempo de ejecución) y nunca se expone como campo propio en la configuración ya validada, porque solo alimenta esta resolución. El resultado final es siempre una cadena no vacía y obligatoria: nada aguas abajo necesita manejar `undefined`. Railway solo inyecta `RAILWAY_GIT_COMMIT_SHA` cuando el despliegue se originó en un trigger de GitHub; un redeploy manual o disparado desde la CLI de Railway no la define, así que un build puede seguir reportando `"unknown"` sin que eso sea un error de esta resolución. Los 12 caracteres son suficientes para distinguir cualquier commit real de este repositorio sin necesidad del SHA completo de 40 caracteres en cada línea de log o atributo de span. Cobertura: `environment-variables.schema.spec.ts` prueba las tres ramas, incluyendo que un `SERVICE_VERSION` explícito gana sobre `RAILWAY_GIT_COMMIT_SHA` cuando ambos están definidos.

## 8. C6 — OpenAPI y verificación automática

Usar `@nestjs/swagger` para producir el contrato de las rutas realmente implementadas y los esquemas comunes. No generar CRUD ni endpoints ficticios para llenar Swagger.

Documentar requests, respuestas, headers, códigos, ejemplos, cursores, filtros, sorts, límites, autenticación/precondiciones e idempotencia cuando corresponda. Incluir las excepciones operativas al wrapper.

CI debe generar/validar OpenAPI y verificar compatibilidad del contrato. Un esquema/decorador no demuestra por sí solo que el runtime responde igual: se necesitan pruebas HTTP contra las formas documentadas.

Los clientes no deben depender de mensajes traducidos ni de propiedades internas. Los cambios de nombres, tipos, obligatoriedad, enums, paginación o semántica necesitan evaluación explícita de compatibilidad. No declarar que cualquier adición es siempre compatible.

### Matriz mínima de aceptación

| Prueba | Resultado exigido |
| --- | --- |
| Éxitos | Envoltorio único, tipos correctos, headers y excepciones sin doble wrapping |
| Errores | Estructura y content type comunes para fallos controlados; status coherente |
| Cursores | Rechazo de alteración, expiración e incompatibilidad; límite, fin y colección vacía coherentes |
| Autorización paginada | Un cursor no permite acceder a otro tenant ni conservar permisos revocados |
| Concurrencia de contexto | No se mezclan requestId, tenant o contexto de trazas entre peticiones |
| Protección de datos | Secretos de prueba no aparecen en errores públicos, logs, trazas ni salida exportada |
| Correlación | El identificador de respuesta localiza logs y ejecución instrumentada |
| Instrumentación | No se duplican logs/spans; existen segmentos útiles, no solo IDs decorativos |
| Destino caído | API estable con memoria/colas acotadas, pérdida técnica controlada y shutdown verificado |
| OpenAPI | Esquemas, ejemplos y respuestas reales concuerdan; cambios incompatibles detectados |

Cuando una familia de endpoints aún no existe, usar fixtures de prueba no expuestos en producción para probar el mecanismo, y exigir la misma prueba al implementar la primera ruta real. No anunciar autenticación, auditoría de negocio o integraciones futuras como terminadas por probar un fixture.

### 8.1 Mecanismo implementado y decisiones que la especificación dejaba abiertas

`tooling/openapi` implementa la generación, verificación y detección de compatibilidad; `src/foundation/health/health.controller.ts` lleva los únicos decoradores `@nestjs/swagger` de una ruta real. `zod-json-schema.ts` envuelve `z.toJSONSchema()` de Zod 4; `shared-components.ts` deriva `PageInfo`, `ProblemDetails` y `ProblemFieldError` de sus esquemas Zod y los parámetros `limit`/`after` de `collection-query.schema.ts` (C2), y describe `Envelope` y el header `X-Request-Id` a mano por las razones que siguen. `openapi-document.ts` combina `SwaggerModule.createDocument()` con esos componentes compartidos. `schema-compatibility.ts`, `response-compatibility.ts`, `parameter-compatibility.ts` y `openapi-compatibility.ts` implementan el clasificador de compatibilidad. `generate-openapi-document.ts`/`check-openapi-document.ts` son los scripts `openapi:generate`/`openapi:check`; `docs/generated/openapi.json` es el artefacto versionado.

Decisiones que C6 resuelve al implementar (la especificación las dejaba abiertas):

* **Destino de conversión de Zod a JSON Schema**: `target: 'openapi-3.0'`, `unrepresentable: 'throw'`, `io: 'output'`. `openapi-3.0` es el único destino que produce el dialecto que `@nestjs/swagger` ya emite (`nullable: true` en vez de `type: [T, 'null']`, `exclusiveMinimum` booleano en vez de numérico); mezclar dialectos habría producido un documento inconsistente. `unrepresentable: 'throw'` cumple el mandato de "fallar en voz alta": un esquema Zod sin equivalente (por ejemplo `z.bigint()` o `z.date()`) rompe la generación en vez de emitir `{}` silenciosamente. `io: 'output'` documenta el valor ya validado/coaccionado (por ejemplo `limit` como `integer`, no como el string crudo de la query string), que es lo que un cliente necesita conocer para construir una petición válida.
* **`PageInfo`, `ProblemDetails` y `ProblemFieldError` dejan de ser interfaces escritas a mano**: ahora son `z.infer<>` de un esquema Zod `.strict()` en el mismo archivo (`src/foundation/pagination/page-info.ts`, `src/foundation/errors/problem-details/problem-details.ts`, `src/foundation/errors/problem-details/problem-field-error.ts`). La regla "nunca duplicados escritos a mano" exige una única fuente; convertir el tipo existente a Zod evita mantener dos formas del mismo contrato (la interfaz TypeScript y un esquema de documentación aparte).
* **`Envelope` es la única excepción deliberada**: `HttpResponseWithMeta<TData, TMeta>` es una clase genérica sobre datos arbitrarios; no existe ni puede existir un esquema Zod único que la describa sin fijar `TData`, porque cada ruta real tendría su propio `data`. `tooling/openapi/shared-components.ts` describe a mano únicamente la forma del sobre (`data` presente, `meta.pageInfo` opcional referenciando `PageInfo`), no duplica ningún esquema Zod existente porque no hay ninguno del que derivar.
* **Los componentes compartidos se documentan aunque ninguna ruta real los use todavía**: `/health/live` y `/health/ready` son excepciones documentadas al sobre y a Problem Details (usan `ProtocolExceptionFilter`, sección 3), así que hoy ninguna ruta real referencia `Envelope` o `ProblemDetails`. Se agregan igualmente a `components.schemas`/`components.parameters`/`components.headers` después de `SwaggerModule.createDocument()` (`openapi-document.ts`), porque la sección 8 pide documentar el mecanismo compartido, no solo lo que una ruta ya usa; un esquema de una ruta real con el mismo nombre gana sobre el compartido si alguna vez colisionan (probado en `tooling/openapi/openapi-document.spec.ts`).
* **`X-Request-Id` sí se documenta en una respuesta real**: a diferencia de `Envelope`/`ProblemDetails`, el middleware de C1 pone ese header en toda respuesta, incluida `/health/live` y `/health/ready`; `health.controller.ts` declara `@ApiOkResponse`/`@ApiServiceUnavailableResponse` con ese header escritos encima de `@HealthCheck()` en el código fuente; como los decoradores se aplican de abajo hacia arriba, se ejecutan después de que Terminus registra su propio `schema`/`description`, así que `@nestjs/swagger` fusiona el header con esa entrada en vez de sobrescribirla.
* **Los cuerpos 200/503 de salud no derivan de Zod**: `@nestjs/terminus` detecta `@nestjs/swagger` en tiempo de decoración (`HealthCheck({ swaggerDocumentation: true })` es el valor por defecto) y adjunta su propio `getHealthCheckSchema()`; ese resultado nunca pasa por una validación Zod en el código de Ninaku, así que no hay ningún esquema Zod que "duplicar a mano". Añadir `@nestjs/swagger` como dependencia fue suficiente para activar esa documentación en los dos `@HealthCheck()` ya existentes, sin tocar su lógica.
* **Nunca se llama a `SwaggerModule.setup()`**: solo se usa `SwaggerModule.createDocument()`. No existe una ruta que sirva el documento ni una UI de Swagger en este trabajo, cumpliendo que C6 es un artefacto y una puerta de CI, no un sitio de documentación público.
* **La generación necesita el `dist/` compilado, nunca una base de datos real**: Node 24 con *type stripping* nativo no transforma decoradores `experimentalDecorators`, así que `node tooling/openapi/generate-openapi-document.ts` no puede importar `src/app/app.module.ts` directamente (los decoradores de Nest revientan como error de sintaxis). `openapi:generate`/`openapi:check` ejecutan primero `nest build` y luego importan `dist/app/app.module.js` ya compilado. Para no requerir Postgres en ese paso, `DatabaseRuntimeRoleGuardService` (que sí consulta la base de datos real en `onModuleInit()`) se sustituye vía `Test.createTestingModule(...).overrideProvider(...)` por un valor inerte; `DatabasePoolService` no necesita sustituirse porque su constructor solo crea el `pg.Pool`, sin conectar. `openapi-generation-environment.ts` rellena `DATABASE_*`/`CURSOR_SIGNING_KEY` con valores ficticios únicamente si el entorno no los define ya, para que ambos scripts funcionen sin configuración local ni en CI.
* **La versión del documento es del contrato, no de la aplicación**: `OPENAPI_DOCUMENT_VERSION` (`tooling/openapi/openapi-document.ts`) es `1.0.0`, independiente de `package.json#version` (`0.0.1`, que versiona el paquete npm interno). Cambiar la versión del contrato es una decisión explícita sobre compatibilidad, no un efecto secundario de publicar el paquete.
* **`openapi:check` es una comparación exacta, no una re-generación silenciosa**: regenera el documento y lo compara byte a byte (tras `serializeOpenApiDocument()`, que ordena claves recursivamente para que el orden de inserción de `Object.keys()` nunca produzca una diferencia espuria) contra `docs/generated/openapi.json`; si difieren, falla indicando `npm run openapi:generate`. No reescribe el archivo por sí solo.
* **El clasificador de compatibilidad cubre request body, nullable, uniones y headers de respuesta, no solo parámetros y cuerpos de respuesta**: `classifyOpenApiChanges()` recorre `compareParameters`, `compareRequestBody` (`request-body-compatibility.ts`) y `compareResponses` por cada operación. `compareRequestBody` resuelve `$ref` contra `components.requestBodies`, clasifica el propio `requestBody` (ausente → presente, presente → ausente, `required` pasando de `false`/ausente a `true`) y reutiliza `compareSchema` sobre cada `content[mediaType].schema`, así que quitar o volver obligatoria una propiedad del cuerpo de una petición se clasifica igual que en una respuesta. `compareSchema` añade `compareNullable` (un `nullable: true` que desaparece es `breaking`, que aparece es `additive`) y `compareSchemaMembers` sobre `oneOf`/`anyOf`/`allOf` (identifica cada miembro por su `$ref` o, si es un esquema inline, por su forma resuelta serializada; un miembro que desaparece es `breaking`, uno nuevo es `additive`). `compareResponses` añade `compareResponseHeaders`, que resuelve `$ref` contra `components.headers` y clasifica headers removidos, agregados (`breaking` si nace `required: true`, `additive` si no) y su paso de opcional a obligatorio, además de difundir su `schema` por `compareSchema`. Categorías nuevas: `nullable_removed`/`nullable_added`, `union_member_removed`/`union_member_added`, `request_body_removed`/`request_body_added`/`request_body_became_required`, `header_removed`/`header_added`/`header_became_required`, sumadas a las ya existentes (`path_*`, `operation_*`, `response_code_*`, `property_*`, `type_*`, `enum_value_*`, `parameter_*`, `media_type_*`). El límite conocido se mantiene acotado: una propiedad nueva y ya obligatoria en el mismo cambio (en un request body o en una respuesta) se sigue reportando como `property_added` (aditivo), porque el clasificador no distingue semántica de request/response para una propiedad individual; declarar un campo nuevo y obligatorio en una petición real sigue exigiendo evaluación humana explícita. Lo que sí es siempre `breaking` es que el **cuerpo completo** o un **header completo** pasen de opcional a obligatorio (`request_body_became_required`/`header_became_required`), porque ahí sí hay una única interpretación posible.
* **CI verifica drift y compatibilidad por separado**: `npm run openapi:check` (drift byte a byte contra `docs/generated/openapi.json`) sigue en el job `pre-foundation-integrity` de `.github/workflows/repository.yml`, junto a `lint:no-ternary` y `pre-foundation:check`; al ejecutar `nest build` internamente no necesita un paso de build ni una base de datos separados en ese job. La verificación de compatibilidad del contrato (que section 8 exige y que antes no existía) vive en un job separado, `openapi-compatibility`, con su propio `npm run openapi:compat` (`tooling/openapi/check-openapi-compatibility.ts`); no necesita `nest build` ni base de datos porque compara dos documentos ya serializados, nunca los regenera.
* **La base de la comparación de compatibilidad es un `git show`, no un segundo `docs/generated/openapi.json` en disco**: `check-openapi-compatibility.ts` resuelve la referencia base con `resolveBaseRef(argv[2], process.env.OPENAPI_COMPAT_BASE_REF)` (el argumento de línea de comandos gana si no está vacío, luego la variable de entorno, luego `origin/staging` por defecto) y ejecuta `git show <ref>:docs/generated/openapi.json` (`loadBaselineDocument`) para obtener el documento base sin necesitar un segundo checkout ni un archivo aparte. En el job `openapi-compatibility`, `OPENAPI_COMPAT_BASE_REF` se fija a `origin/${{ github.base_ref }}` en un `pull_request` (la rama destino real de la PR) y a `origin/staging` en cualquier otro evento (`push` a `main`/`staging`, `workflow_dispatch`); el checkout de ese job pide `fetch-depth: 0` porque el checkout por defecto de `actions/checkout@v7` (usado sin opciones en `pre-foundation-integrity`) es superficial y no trae las ramas remotas que `git show origin/<rama>:...` necesita resolver.
* **Documento base ausente vs. evaluación fallida son resultados distintos, nunca el mismo mensaje**: si `git show` falla porque la ruta no existe en una referencia que sí resuelve (`fatal: path '...' does not exist in '...'` o `exists on disk, but not in`), `loadBaselineDocument` lo trata como `missing` — exactamente lo que pasa hoy contra `origin/staging`, que todavía no tiene `docs/generated/openapi.json` porque esta cadena de cambios lo introduce por primera vez — y el script termina en éxito (`exit 0`) imprimiendo que no hay base contra la cual comparar. Cualquier otra falla (la referencia no resuelve, el JSON base o el actual no parsean) se clasifica como `error`, termina en `exit 1` y nunca se confunde con un `pass`: son tres mensajes distintos (`missing`, `error`, el resumen de cambios) para tres causas distintas.
* **Bump del major del contrato es la forma explícita de aceptar un cambio incompatible**: `evaluateOpenApiCompatibility()` (`openapi-compatibility-policy.ts`) filtra los cambios de `classifyOpenApiChanges()` en `breakingChanges`/`additiveChanges`. Sin cambios `breaking`, el resultado es `compatible` y pasa siempre, haya o no cambios aditivos. Con al menos un cambio `breaking`, compara el componente major de `info.version` del documento actual contra el del documento base (`OPENAPI_DOCUMENT_VERSION` en `tooling/openapi/openapi-document.ts`): si el major subió, el resultado es `breaking_accepted` y pasa (`exit 0`) imprimiendo qué cambios se aceptaron y por qué; si no subió, es `breaking_blocked` y falla (`exit 1`) listando cada cambio `breaking` con su categoría y ruta. Esto es exactamente la decisión que la sección 8.1 original dejaba pendiente ("cambiar la versión del contrato es una decisión explícita sobre compatibilidad"): ahora CI la hace cumplir en vez de solo documentarla.

Matriz mínima de aceptación (sección 8) y su evidencia:

| Prueba | Evidencia | Nota |
| --- | --- | --- |
| Éxitos | `src/foundation/http/envelope/http-envelope.interceptor.spec.ts`, `test/e2e/http-envelope.e2e-spec.ts` (C1); `test/e2e/openapi-contract.e2e-spec.ts` (C6) | C6 prueba que la respuesta real de `/health/live`/`/health/ready` concuerda con su esquema documentado; no repite la prueba del sobre `{ data }`, que sigue siendo de C1. |
| Errores | `test/e2e/problem-details.e2e-spec.ts`, `src/foundation/errors/problem-details/problem-details.section6-conformance.spec.ts` (C4) | `ProblemDetails`/`ProblemFieldError` se documentan como componentes Zod-derivados (`tooling/openapi/shared-components.spec.ts`), pero ninguna ruta real los produce hoy porque salud usa `ProtocolExceptionFilter`. Sin evidencia runtime C6 adicional hasta que exista una ruta real que use el wrapper de errores. |
| Cursores | `src/foundation/pagination/cursor/cursor-codec.spec.ts`, `src/foundation/pagination/cursor/cursor.exceptions.spec.ts`, `test/e2e/collections.e2e-spec.ts` (C2) | C6 documenta `limit`/`after` como parámetros compartidos derivados de los mismos esquemas Zod que C2 ya prueba (`tooling/openapi/shared-components.spec.ts`); no duplica la prueba de rechazo/expiración del cursor. |
| Autorización paginada | `test/e2e/collections.e2e-spec.ts` (C2) | Sin relación directa con C6; no se agrega evidencia nueva. |
| Concurrencia de contexto | `test/e2e/logging-correlation.e2e-spec.ts`, `test/e2e/telemetry-correlation.e2e-spec.ts` (C5) | Sin relación directa con C6; no se agrega evidencia nueva. |
| Protección de datos | `src/foundation/logging/redaction/redaction.spec.ts`, `src/foundation/logging/redaction/known-secrets.registry.service.spec.ts` (C5) | Sin relación directa con C6; no se agrega evidencia nueva. |
| Correlación | `test/e2e/logging-correlation.e2e-spec.ts` (C5); `test/e2e/openapi-contract.e2e-spec.ts` (C6) | C6 añade la prueba de que `X-Request-Id` está documentado en el componente compartido y presente en la respuesta real de salud. |
| Instrumentación | `test/e2e/telemetry-correlation.e2e-spec.ts` (C5) | Sin relación directa con C6; no se agrega evidencia nueva. |
| Destino caído | `src/foundation/telemetry/telemetry-bootstrap.spec.ts`, `src/foundation/telemetry/pipelines/redacting-span-exporter.spec.ts` (C5) | Sin relación directa con C6; no se agrega evidencia nueva. |
| OpenAPI | `tooling/openapi/*.spec.ts` (derivación de esquemas y clasificador de compatibilidad); `test/e2e/openapi-contract.e2e-spec.ts` (esquemas y respuestas reales concuerdan, fixture de C2 ausente); `docs/generated/openapi.json` + `openapi:check` (drift detectado en CI); `openapi:compat` (compatibilidad de contrato evaluada contra un baseline en CI, sección 8.1) | Fila propia de C6; combina drift, compatibilidad de contrato y una prueba HTTP contra `/health`, no una cobertura completa de extremo a extremo: no ejercita autenticación, autorización de negocio ni datos reales, que siguen bajo los módulos que los implementen. |

## 9. Slice D — Validación operativa y rendimiento

D valida la base A/B/C disponible con k6 y evidencia reproducible en staging. Debe registrar commit, configuración del entorno, dataset, carga/concurrencia, duración, thresholds, resultados y limitaciones.

Medir latencias, errores, saturación/espera del pool, memoria y costo/volumen de telemetría. Comparar la instrumentación con una referencia controlada cuando se evalúe su overhead. Los umbrales se fijan antes de ejecutar y se versionan; no se presentan cifras sin medición como capacidad garantizada.

Una prueba de health checks solo demuestra el comportamiento de esas rutas, no capacidad para cientos de restaurantes. Las pruebas de Foundation incluyen caminos técnicos acotados y se amplían con ventas/pagos/inventario reales al existir esos módulos. No cargar producción ni datos de clientes para demostrar Foundation.

Resultado: informe y scripts reproducibles, no una calificación arbitraria. Las regresiones de correctness/aislamiento no se aceptan para mejorar throughput.

## 10. Cierre, entregas y responsabilidades futuras

C1–C6 se cierran con especificación, implementación reutilizable, pruebas y evidencia. El issue #13 conserva el seguimiento; esta documentación no marca los checkboxes de implementación.

Antes del primer módulo real, C debe tener contrato único HTTP/paginación/errores, logging con protección de datos, correlación real y OpenAPI verificable. D debe aportar una línea base reproducible. La elección de proveedor y los parámetros que este documento deja para C2/C5 deben resolverse en esas entregas, sin trasladar decisiones transversales a veinte módulos.

Cada módulo futuro aporta reglas de negocio, consultas e índices, autorización y auditoría propias. Reutiliza la frontera HTTP, contexto, errores, paginación y logging; no conoce detalles internos del proveedor de observabilidad.

No se agregan tablas, repositorios universales, wrappers distintos por vertical ni dependencias especulativas para que Foundation parezca completa. Idempotencia durable, outbox, ejecución de jobs y políticas específicas de dominio siguen sus contratos y pruebas propias; describir sus headers/eventos no los implementa.

## Referencias de diseño

Estas referencias identifican los estándares y guías elegidos; las convenciones particulares de Ninaku están declaradas arriba.

* [Semántica HTTP — RFC 9110](https://www.rfc-editor.org/rfc/rfc9110.html)
* [Problem Details — RFC 9457](https://www.rfc-editor.org/rfc/rfc9457.html)
* [OpenAPI Specification](https://spec.openapis.org/oas/latest.html)
* [W3C Trace Context](https://www.w3.org/TR/trace-context/)
* [OpenTelemetry — modelo de logs](https://opentelemetry.io/docs/specs/otel/logs/data-model/)
* [OpenTelemetry — datos sensibles](https://opentelemetry.io/docs/security/handling-sensitive-data/)
* [OpenTelemetry — resiliencia de exportación](https://opentelemetry.io/docs/collector/resiliency/)
* [Nest — logging](https://docs.nestjs.com/techniques/logger)
* [OWASP — logging](https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html)
* [Google AIP-158 — paginación](https://google.aip.dev/158)
* [Google AIP-180 — compatibilidad](https://google.aip.dev/180)
