Appearance
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, API_AND_SCHEMA_DISCIPLINE.md, ROADMAP.md y 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. 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
| Distinción | Qué significa |
|---|---|
| Referencia externa vs. convención propia | 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 vs. implementar | Documentar una decisión no instala una dependencia ni implementa endpoints |
| Ejemplo vs. evidencia | Un ejemplo de Sales es un contrato ilustrativo futuro, no evidencia de que Sales ya exista |
| Objetivo vs. promesa | El objetivo es permitir cambios internos sin romper consumidores ni rehacer mecanismos transversales; no prometer que nunca habrá refactors |
Estado por slice
| Slice | Estado |
|---|---|
| A | Integrada mediante PR #14/#16 |
| B, C1–C6, D | Requieren su propia implementación y evidencia; el estado actualizado vive en el issue #13 |
| Gate de producción | Sigue en el issue #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. 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
El camino de una petición
Orden real de los mecanismos transversales registrados sobre '/{*path}' en src/app/app.module.ts:
La validación es opt-in por parámetro, no un pipe global: no existe ninguna llamada a useGlobalPipes ni un APP_PIPE.
Tampoco existe ningún guard (CanActivate) en este código. La autenticación y la autorización llegarán con los módulos que las necesiten.
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-4ec8d344e9a6json
{
"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.
Las cuatro formas que produce el interceptor, según lo que devuelva el handler:
json
{
"data": {
"orderId": "84e7252c-c8bc-4cda-8e77-945818caa260",
"status": "draft",
"version": 1,
"total": { "amount": "24.50", "currency": "USD" }
}
}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-4ec8d344e9a6http
HTTP/1.1 204 No Content
X-Request-Id: 01994821-16a0-784e-8b2c-4ec8d344e9a6http
HTTP/1.1 304 Not Modified
ETag: "42"
X-Request-Id: 01994821-16a0-784e-8b2c-4ec8d344e9a6La 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. 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.
Correlación: los puntos que C1 cierra
| Punto abierto | Decisión de C1 |
|---|---|
¿El servidor acepta un X-Request-Id entrante? | No. Lo ignora y genera siempre el suyo, porque requestId identifica un intento HTTP del servidor (sección 7.2), no una entrada de confianza del cliente |
¿Sigue expuesto el header x-trace-id de B? | No. Deja de presentarse; el trace id se sigue resolviendo y almacenando para la correlación de OpenTelemetry en C5, pero X-Request-Id es ahora el único header de correlación de la aplicación |
| ¿Hasta dónde llega el middleware de correlación? | Solo hasta /api/** y las rutas listadas en el exclude del prefijo global. AppModule.configure() registra los middlewares con forRoutes('/{*path}') y setGlobalPrefix('api') reescribe ese patrón a /api/{*path}; Nest añade las rutas excluidas, por eso los dos health probes siguen cubiertos |
Esa frontera es deliberada, no un descuido. Una petición fuera de /api que tampoco esté excluida —por ejemplo /does-not-exist— responde 404 sin X-Request-Id, sin línea de log correlacionada y sin span; /api/v1/does-not-exist sí los lleva.
El propietario revisó el comportamiento y decidió conservarlo: un escáner que prueba /wp-admin no debe producir una línea de log correlacionada ni una traza. Menos ruido y menos superficie.
Se revisa solo ante evidencia de que el tráfico no atribuido necesita rastrearse. Quien encuentre un 404 sin request id no debe tratarlo como un defecto.
test/e2e/request-id.e2e-spec.ts fija esta frontera contra la configuración real: construye la aplicación con createE2eApplication(), que aplica configureHttpApplication, y afirma el request id sobre /api/v1/does-not-exist, la forma prefijada que produce un cliente real.
Esa aserción se movió a la ruta prefijada en vez de relajarse, y el middleware quedó sin tocar. test/e2e/http-application-policy.e2e-spec.ts fija el alcance por ambos lados: el middleware corre sobre una ruta excluida del prefijo y sobre una ruta prefijada y versionada.
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 HTTP
| 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.
Excepciones al envoltorio y headers declarados
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.
Peticiones condicionales
| Decisión | Resolución |
|---|---|
| Quién calcula el validador | El handler, devolviendo HttpConditionalResponse(data, etag) (src/foundation/http/conditional-requests/http-conditional-response.ts), análogo a HttpResponseWithMeta |
Qué es el etag | El identificador opaco que el módulo considera representativo del estado actual (un version, un hash de negocio), no un hash genérico del JSON de salida |
Comparación de If-None-Match | Débil, correcta según RFC 9110 §13.2.2 |
Comparación de If-Match | Fuerte (matchesStrong, src/foundation/http/conditional-requests/entity-tag.ts), correcta según RFC 9110 §13.2.1 |
| Charset del validador | Gramática etagc de RFC 7232 §3.11 |
Por qué el handler y no un cálculo central. Hashear la respuesta en el interceptor produciría solo un validador fuerte que cambia con cualquier diferencia incidental de bytes: espacios, orden de claves, un campo no relacionado. Además 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. 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. El interceptor solo fija la cabecera ETag cuando el handler participa. La evaluación —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, normalizando el prefijo W/ en ambos lados.
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, un default seguro simétrico al de lectura. 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 y streams
Al probar estos casos end-to-end no se encontró una brecha real en el interceptor. Antes y después de este cambio:
| Caso | Comportamiento real | Origen |
|---|---|---|
| 204 | Nunca lleva cuerpo, aunque un handler decorado con @HttpCode(204) devuelva un objeto por error | Express descarta Content-Type/Content-Length/cuerpo cuando el estado es 204 o 304, sin importar qué reciba res.json() |
| HEAD | Nunca escribe cuerpo, aunque el handler devuelva la misma representación que en GET | Express omite la escritura para ese método |
StreamableFile que falla antes de enviar cabeceras | 400 con el mensaje crudo | Manejo de StreamableFile de Nest/Express |
StreamableFile que falla a mitad de transmisión | Trunca la respuesta sin cerrarla como error HTTP | Manejo de StreamableFile de Nest/Express |
Un StreamableFile que falla nunca se traduce a { data } ni a Problem Details.
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. Añade además el mismo chequeo de regresión ya existente para /health/live a /health/ready.
Un handler sin representación responde 204, no { data: undefined }
HttpEnvelopeInterceptor (src/foundation/http/envelope/http-envelope.interceptor.ts) fija dos mitades de una misma regla:
| Resultado del handler | Respuesta |
|---|---|
undefined, por identidad estricta (result === undefined) | Fija response.statusCode = 204 y no envuelve nada; nunca un 200 con cuerpo vacío u omitido |
null explícito | { data: null }, sin tocar el estado |
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".
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.
Un null explícito sigue siendo una representación real 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 a nivel de interceptor; 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.
| Ajuste | Valor | Razón |
|---|---|---|
CORS_ALLOWED_ORIGINS (src/foundation/config/schema/environment-variables.schema.ts) | 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 |
| Valor por defecto, sin configurar o vacía | Cero orígenes permitidos en todo entorno, incluido desarrollo | Decisión deliberada de fail-closed, no un valor provisional |
enableCors → exposedHeaders | Exactamente ETag y X-Request-Id | Los dos headers de respuesta propios que un navegador necesita leer vía fetch/XHR |
enableCors → allowedHeaders | Exactamente Content-Type, If-Match e If-None-Match | No se permite Authorization ni se expone ningún otro header porque nada en este código los consume todavía |
credentials | false | No existe autenticación por cookies ni por sesión HTTP (DatabaseSession es una sesión de base de datos, no de HTTP) |
app.set('trust proxy', 1) | Exactamente un salto de cabeceras X-Forwarded-* | Corresponde al único borde de Railway que termina TLS delante del contenedor (docs/operations/RAILWAY_OPERATIONS.md) |
El default fail-closed es deliberado porque esta API maneja datos de tenant: un default permisivo dejaría un entorno nuevo aceptando lecturas cross-origin hasta que alguien recuerde restringirlo.
Habilitar credenciales CORS ahora solo ampliaría la superficie de ataque sin ningún beneficio correspondiente. No se usa trust proxy: true porque eso confiaría en una cadena arbitraria de cabeceras reenviadas.
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, y ETag/X-Request-Id en Access-Control-Expose-Headers.
La misma prueba cubre la 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.
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.
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, por partida doble
HealthController se declara @Controller({ path: 'health', version: VERSION_NEUTRAL }) y sus dos rutas se listan en el exclude de setGlobalPrefix, como { path: 'health/live', method: RequestMethod.GET } y { path: 'health/ready', method: RequestMethod.GET }.
| Si faltara | Resultado |
|---|---|
VERSION_NEUTRAL | Salud pasaría a /v1/health/live aunque estuviera excluida del prefijo api |
La exclusión de setGlobalPrefix | Salud pasaría a /api/health/live |
| Ambos juntos | Exactamente /health/live y /health/ready, sin prefijo ni versión, igual que antes de este cambio |
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.
Esa es 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) describe ú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
404en 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; /health/livey/health/readysiguen respondiendo exactamente en esas rutas, sin prefijo ni versión (test/e2e/http-application-policy.e2e-spec.tsfija la misma regresión).
Las pruebas end to end montan esta configuración, no una forma inventada
test/support/e2e-application.ts expone createE2eApplication, la forma en que un spec de test/e2e/ construye la aplicación. El helper llama a configureHttpApplication antes de app.init(), así que cada spec ejerce el prefijo global, el versionamiento URI y la exclusión de salud reales.
El autor de un spec obtiene la forma de producción por defecto, sin tener que saber que la trampa existe.
Antes de este helper, 11 de los 13 specs llamaban a createNestApplication() directamente y ejercían un servidor sin prefijo ni versión: una forma que no existe en producción, y en la que un fallo de middleware como forRoutes('*') convertido en /api/* no puede reproducirse siquiera.
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=-createdAtjson
{
"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-cursorEl 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=-createdAtexpresan 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.
totalCountno 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.
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:
| Grupo | Archivo | 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 |
| raíz | collection-query.schema.ts | Validación C3 de limit/after/sort |
CollectionsModule expone CursorCodecService igual que TransactionsModule/TenancyModule.
Formato del token
v1.<keyId>.<payload-base64url>.<firma-base64url> — cuatro segmentos separados por ..
| Segmento | Contenido |
|---|---|
| Versión | La constante literal v1 |
keyId | Los primeros 16 caracteres hexadecimales de SHA-256(CURSOR_SIGNING_KEY), derivados en el momento, no configurados por separado |
| Payload | JSON con exp, binding, sortValues e id, codificado en base64url; nunca la clave de firma ni datos de negocio adicionales |
| Firma | HMAC-SHA256 (node:crypto, sin dependencia nueva) sobre versión, keyId y payload, verificada con timingSafeEqual |
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 —un correo, un monto, un identificador que no deba exponerse— confiando en que nadie puede leerlos.
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.
Cómo se valida un cursor entrante
Parámetros resueltos
| Parámetro | Valor | Constante |
|---|---|---|
| Expiración por defecto | 900 segundos (15 minutos) | CURSOR_DEFAULT_TTL_SECONDS |
| Expiración máxima | 86400 segundos (24 horas) | CURSOR_MAX_TTL_SECONDS |
| Tamaño máximo del token | 2048 bytes, verificado antes de intentar decodificar | CURSOR_MAX_TOKEN_LENGTH |
| Límite de página | Entero 1–100, por defecto 50 | COLLECTION_QUERY_DEFAULT_LIMIT, COLLECTION_QUERY_MAX_LIMIT |
Expiración. Ajustable por llamada a través de EncodeCursorInput.ttlSeconds cuando un caso de uso lo justifique, hasta el máximo de la tabla.
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: la expiración limita cuánto tiempo un cursor sigue siendo válido, no ofrece ni promete un snapshot.
Tamaño máximo. 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.
Rotación de clave. 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.
Un cursor cuyo keyId no coincide con el de la clave activa no se distingue de una clave desconocida, porque el servidor solo mantiene la clave activa.
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, 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.
Códigos de error del cursor
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 las reglas de paginación ilustran: 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
Tres primitivas C3 componibles:
| Primitiva | Contrato |
|---|---|
createLimitSchema() | limit, entero 1–100, por defecto 50 |
AfterCursorSchema | after, cadena no vacía acotada a 2048 caracteres |
createSortQuerySchema(camposPermitidos) | sort, solo campo/-campo de una lista declarada por endpoint |
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
| Nivel | Prueba |
|---|---|
| Unitaria | src/foundation/pagination/**/*.spec.ts — el códec, los códigos de error y la validación de consulta de forma aislada, incluyendo src/foundation/pagination/cursor/cursor-codec.spec.ts y src/foundation/pagination/cursor/cursor.exceptions.spec.ts |
| Integración | keyset-pagination.integration-spec.ts — pagina reference.nutrients (schema reference, legible por ninaku_runtime según database/access/runtime_grants.sql) con PostgreSQL real |
| Extremo a extremo | test/e2e/collections.e2e-spec.ts — 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.
Política por frontera
| Frontera | Campos desconocidos | Mecanismo |
|---|---|---|
body | .strict() por defecto recomendado, declarado explícitamente en cada esquema | @Body() + bodySchema() |
query | .strict() | @Query() + querySchema() |
path | .strict() | @Param() + pathSchema() |
header | No pueden ser estrictos | RequestHeadersParam + headerSchema() |
Los headers no pueden ser estrictos porque una petición real siempre transporta headers ajenos al contrato de la operación (Host, User-Agent, Accept, Connection, …).
El esquema de headers valida únicamente la clave nombrada por el contrato —por ejemplo idempotency-key— y deja pasar el resto sin interpretarlos.
Por qué un decorador propio para headers. 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.
Decisiones de formato
| Tipo | Decisión |
|---|---|
| Identificadores | 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 |
| Decimales exactos | Exigen la escala declarada de forma obligatoria (escala 2 acepta "24.50" pero rechaza "24.5" y "24"); nunca se coacciona el valor a number |
| Instantes | Exigen sufijo Z (UTC explícito, sin convertir un offset no UTC) |
| Fechas de negocio | Fechas sin componente horario. Contrato distinto del anterior y no intercambiables |
Códigos de campo
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+jsonjson
{
"type": "/problems/validation-error",
"title": "Invalid request",
"status": 400,
"detail": "The request does not satisfy the input contract.",
"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."
}
]
}title y detail provienen del catálogo del servidor (PROBLEM_TYPE_REGISTRY) y hoy están en inglés. errors[].message lo aporta quien lanza la excepción y sigue el idioma del contrato de esa operación.
Reglas
| Regla | Detalle |
|---|---|
errors es una lista homogénea | Cuando existe, siempre el mismo esquema, nunca alternativamente un objeto por campo. location identifica body, query, path o header según el contrato |
code es el contrato de lógica | Estable, 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 nunca se inventa | Corresponde al contexto real de la ejecución cuando está disponible; no se inventan identificadores para aparentar instrumentación |
| Un solo contrato de error | Validación, autenticación, autorización, rutas inexistentes y fallos inesperados controlados por la aplicación pasan por él. Una petición a una ruta que no existe responde application/problem+json igual que cualquier otro error. No prometer reformatear respuestas que genera un proxy externo antes de llegar a Nest |
| Nada interno en la respuesta pública | Sin SQL, nombres internos de constraints, stack traces, secretos, detalles de conexión o datos personales |
| PostgreSQL se traduce con mapeos explícitos | Por códigos/constraints conocidos. 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 202 no sustituyen 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ómo se resuelve una excepción
buildProblemDetails (src/foundation/errors/problem-details/problem-details.builder.ts) evalúa cuatro ramas en orden. No existe ninguna otra:
Catálogo de códigos
| 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 |
El mapa inverso estado → código
HTTP_STATUS_ERROR_CODE lo deriva buildHttpStatusErrorCode() desde ERROR_CODE_STATUS_REGISTRY, excluyendo los códigos declarados en ERROR_CODES_OUTSIDE_STATUS_FALLBACK:
| Código excluido | Por qué |
|---|---|
tenant.context_rejected | 403 ya lo reclama auth.unauthorized; se resuelve por su propia rama |
error.unexpected | Es el destino del saneo, no un respaldo por estado |
Los cuatro collection.cursor_* | 400 debe seguir cayendo en validation.failed |
buildStatusFallbackIndex() lanza si dos códigos reclaman el mismo estado, de modo que una entrada duplicada nueva falla en el arranque en vez de resolverse por orden de declaración.
Decisiones que C4 resuelve al implementar
TenantContextRejectedError es 403, no 404. El rechazo 42501 de identity.assert_tenant_context() se traduce a 403 con el código tenant.context_rejected.
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.
Dónde ocurre realmente la traducción de SQLSTATE. src/foundation/tenancy/tenant-context-runner.service.ts reconoce el SQLSTATE 42501 y lanza TenantContextRejectedError antes de que la excepción llegue al filtro.
problem-details.builder.ts no inspecciona ningún SQLSTATE ni la propiedad code de un error de pg. Un error crudo de pg que llegue al filtro, incluso con SQLSTATE 42501, se sanea como error.unexpected con estado 500, comportamiento fijado en problem-details.builder.spec.ts.
La regla de la sección 6 sobre traducir PostgreSQL con mapeos explícitos sigue siendo el contrato para cuando un módulo real necesite otra traducción; hoy el único mapeo existente es el de tenancy.
Estados no documentados. Un HttpException de Nest con un estado fuera del mapa 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.
code/detail/errors propios sí; retryable nunca. Un HttpException puede aportar su propio code, detail y errors en el cuerpo de la excepción, y el filtro los reenvía sin modificar, sujeto al saneo de detail en estados 5xx.
retryable 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, 415 y 422 participan del mapa de respaldo. 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 un type reconocido y un status/statusCode numérico— y los resuelve a través del mismo HTTP_STATUS_ERROR_CODE que un HttpException:
type de body-parser | Estado | Código |
|---|---|---|
entity.too.large | 413 | payload.too_large |
charset.unsupported | 415 | media_type.unsupported |
encoding.unsupported | 415 | media_type.unsupported |
entity.parse.failed | 400 | validation.failed |
Esos cuatro son los únicos tipos que body-parser/raw-body producen. 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.
entity.parse.failed cubre 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 con contrato propio se declaran, no se infieren. Los health checks (sección 3) usan @UseFilters(ProtocolExceptionFilter): el filtro de protocolo reenvía la respuesta original de la excepción sin traducirla a Problem Details.
Toda ruta inexistente responde Problem Details, dentro y fuera del prefijo. Un 404 por ruta que no existe devuelve application/problem+json con el código resource.not_found, tanto en /api/v1/... como fuera del prefijo (/wp-admin, /).
Hasta este cambio devolvía la página HTML por defecto de Express. No se detectaba porque los specs end to end construían la aplicación sin configureHttpApplication, y sin prefijo global ese camino sí producía Problem Details.
La causa está en Nest: RoutesResolver.registerNotFoundHandler() pasa el prefijo global tal cual a setNotFoundHandler, y ExpressAdapter monta this.use('api', router). Sin barra inicial, Express 5 nunca hace coincidir ese path: el handler de not-found no se ejecutaba y la petición caía al HTML de finalhandler.
configureHttpApplication monta ahora esa frontera en la raíz (mountNotFoundBoundaryAtRoot), antes de init(), en lugar de bajo el prefijo. Una sola frontera cubre ambos lados:
| Variante | Ruta inexistente en /api/v1/... | /wp-admin, / | /health/* |
|---|---|---|---|
Prefijo 'api' (antes) | HTML | HTML | 200 |
Solo corregir la barra ('/api') | Problem Details | HTML | 200 |
| Frontera en la raíz (actual) | Problem Details | Problem Details | 200 |
Corregir solo la barra no alcanza: con cualquier prefijo configurado, Nest no monta nada en la raíz. Los health checks no se ven afectados porque son rutas que coinciden y responden antes de llegar a la frontera.
Fuera del prefijo, traceId se omite; nunca se envía vacío ni se inventa. Por decisión de producto, una petición fuera de /api no genera correlación: el middleware de correlación no se ejecuta para /wp-admin.
El filtro de excepciones sí es global y se ejecuta igual. Sin contexto de correlación, la propiedad traceId desaparece del cuerpo en lugar de viajar como cadena vacía o como identificador fabricado, conforme a la regla de esta sección de que traceId nunca se inventa. Dentro de /api el traceId sí viaja.
test/e2e/problem-details.e2e-spec.ts fija los dos lados de la frontera: la respuesta Problem Details a ambos lados y la presencia o ausencia de traceId.
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.
La severidad de un 404 depende de si la ruta existía
| Caso | Nivel | Razón |
|---|---|---|
Ninguna ruta de Express coincidió (route resuelve a unmatched) | info | Nadie del sistema se equivocó: alguien pidió algo que nunca existió. 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 |
La ruta coincidió pero el recurso falta (un futuro /organizations/:id cuyo id no existe) | 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 propietarioLa 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
| Área | Archivos |
|---|---|
| Registro JSON permitido | src/foundation/logging/log-record.ts |
| Política de datos permitidos | src/foundation/logging/redaction/redaction.ts, sensitive-field-names.ts |
| Secretos configurados | known-secrets.registry.service.ts |
| Adaptador de Nest | NinakuLoggerService (implementa LoggerService, se conecta con app.useLogger()) |
| Arranque de OpenTelemetry | src/foundation/telemetry: tracing-pipeline.ts, metrics-pipeline.ts, node-instrumentation.ts, telemetry-bootstrap.ts |
| Exportación con redacción | redacting-span-exporter.ts |
| Instrumentación HTTP | http-telemetry.middleware.ts, http-metrics.service.ts |
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.
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, sustituyendo también los mensajes internos del framework, y añade event(level, nombre, campos) para las llamadas estructuradas propias.
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 —el 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
| Capa | Qué cubre |
|---|---|
| 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 |
| 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.
Presupuestos: cada cadena se acota a 2000 caracteres y el registro completo a 8 KiB.
| Fallo | Registro emitido |
|---|---|
| Excede el presupuesto de tamaño | Registro mínimo (timestamp, level, event, identidad de correlación) con truncated: true, en vez de truncar campos a mitad de palabra |
JSON.stringify del registro completo lanza una excepción (estructura no serializable) | El mismo conjunto mínimo de campos seguros, pero con serializationFailed: true |
| Incluso ese registro mínimo falla al serializarse | La línea de último recurso {"level":"error","event":"log.serialization_failed"} |
log-record.ts distingue los dos primeros casos para que un consumidor no confunda "se recortó por tamaño" con "no se pudo serializar en absoluto". 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.
Solo en instrumentación: sig/Signature/AWSAccessKeyId/X-Goog-Signature/X-Amz-Signature/X-Amz-Credential/X-Amz-Security-Token. Solo en redacción: authorization/cookie/dsn/connectionstring/credential/certificate/privatekey/signingkey.
logging/redaction/sensitive-field-names.ts es ahora la única fuente:
| Declaración | Contenido |
|---|---|
SENSITIVE_FIELD_NAME_WORDS | Palabras sueltas |
SENSITIVE_FIELD_NAME_COMPOUND_WORDS | Compuestas de dos palabras, por ejemplo api+key |
ADDITIONAL_SENSITIVE_QUERY_PARAM_NAMES | Adicionales exclusivas de query params: 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, 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, con el 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.
Es 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
| Instrumentación | Configuración | Razón |
|---|---|---|
@opentelemetry/instrumentation-http | Por defecto | No captura cabeceras como atributos de span salvo que se declare headersToSpanAttributes, que este trabajo no declara |
@opentelemetry/instrumentation-pg | enhancedDatabaseReporting: false | Nunca adjunta los valores de los parámetros de la consulta, solo el texto SQL |
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 dos instrumentos:
| Instrumento | Tipo | Unidad |
|---|---|---|
http.server.request.count | Contador | peticiones |
http.server.request.duration | Histograma | segundos |
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; dos valores fijos, sin riesgo de cardinalidad).
Ningún requestId, trace_id, organizationId u otro identificador de alta cardinalidad se usa como etiqueta.
Segundos es 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.
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. Depuración queda 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.
Caso sobre /health | ¿Se registra? | Razón |
|---|---|---|
| Éxito | No | Es el caso rutinario que la supresión existe para silenciar |
| Fallo | Sí, a warn/error según el estado | Señala degradación real |
| Abortado por el cliente | Sí, siempre | Una desconexión a mitad de un health check no es el caso rutinario |
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. 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.
Evidencia de correlación
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
En 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 |
Destino de exportación elegido
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.
Resolución de SERVICE_VERSION
El esquema de entorno (environment-variables.schema.ts) implementa una cadena de resolución de tres pasos, evaluada por el propio esquema Zod antes de exponer la configuración validada:
| Orden | Fuente | Resultado |
|---|---|---|
| 1 | SERVICE_VERSION explícito | Gana siempre |
| 2 | RAILWAY_GIT_COMMIT_SHA | Truncado a sus primeros 12 caracteres |
| 3 | Ninguna de las anteriores | "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.
Sigue abierto
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.
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:
| Archivo | Responsabilidad |
|---|---|
zod-json-schema.ts | Envuelve z.toJSONSchema() de Zod 4 |
tooling/openapi/shared-components.ts | Deriva PageInfo, ProblemDetails y ProblemFieldError de sus esquemas Zod, y los parámetros limit/after de collection-query.schema.ts (C2); describe Envelope y el header X-Request-Id a mano |
openapi-document.ts | Combina SwaggerModule.createDocument() con esos componentes compartidos |
schema-compatibility.ts, response-compatibility.ts, parameter-compatibility.ts, request-body-compatibility.ts, openapi-compatibility.ts | Clasificador de compatibilidad |
generate-openapi-document.ts, check-openapi-document.ts | Scripts openapi:generate/openapi:check |
docs/generated/openapi.json | Artefacto versionado |
src/foundation/health/health.controller.ts lleva los únicos decoradores @nestjs/swagger de una ruta real.
Destino de conversión de Zod a JSON Schema
target: 'openapi-3.0', unrepresentable: 'throw', io: 'output'.
| Opción | Razón |
|---|---|
openapi-3.0 | Ú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 (z.bigint(), z.date()) rompe la generación en vez de emitir {} silenciosamente |
io: 'output' | Documenta el valor ya validado/coaccionado (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 |
Una sola fuente por esquema
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.
Componentes compartidos sin ruta que los use
/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, incluidas /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.
Versión del documento y detección de drift
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 contra docs/generated/openapi.json, tras serializeOpenApiDocument(), que ordena claves recursivamente para que el orden de inserción de Object.keys() nunca produzca una diferencia espuria.
Si difieren, falla indicando npm run openapi:generate. No reescribe el archivo por sí solo.
El clasificador de compatibilidad
classifyOpenApiChanges() recorre compareParameters, compareRequestBody (request-body-compatibility.ts) y compareResponses por cada operación: cubre request body, nullable, uniones y headers de respuesta, no solo parámetros y cuerpos de respuesta.
| Comparador | Qué clasifica |
|---|---|
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 |
compareNullable | Un nullable: true que desaparece es breaking; que aparece es additive |
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 |
compareResponseHeaders | Resuelve $ref contra components.headers; clasifica headers removidos, agregados (breaking si nace required: true, additive si no) y su paso de opcional a obligatorio, y difunde su schema por compareSchema |
Quitar o volver obligatoria una propiedad del cuerpo de una petición se clasifica igual que en una respuesta.
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_*).
Límite conocido, acotado a propósito. 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
Job en .github/workflows/repository.yml | Comando | Necesita |
|---|---|---|
pre-foundation-integrity | npm run openapi:check, junto a lint:no-ternary y pre-foundation:check | Nada extra: al ejecutar nest build internamente no necesita un paso de build ni una base de datos separados |
openapi-compatibility | npm run openapi:compat (tooling/openapi/check-openapi-compatibility.ts) | Ni nest build ni base de datos: compara dos documentos ya serializados, nunca los regenera |
La verificación de compatibilidad del contrato que la sección 8 exige no existía antes de este trabajo.
La base de la comparación es un git show, no un segundo archivo 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.
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, o 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.
| Situación | Clasificación | Salida |
|---|---|---|
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) | missing | exit 0, imprimiendo que no hay base contra la cual comparar |
| La referencia no resuelve, o el JSON base o el actual no parsean | error | exit 1 |
| Hay base y se compara | Resumen de cambios | Según la política de abajo |
Son tres mensajes distintos para tres causas distintas; un missing nunca se confunde con un pass.
La rama missing cubre el arranque de un documento nuevo: hoy docs/generated/openapi.json ya existe en origin/staging, así que la comparación real se ejecuta contra ese baseline.
Subir el 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:
| Situación | Resultado | Salida |
|---|---|---|
Sin cambios breaking | compatible | Pasa siempre, haya o no cambios aditivos |
Con breaking y el major de info.version subió respecto al base | breaking_accepted | exit 0, imprimiendo qué cambios se aceptaron y por qué |
Con breaking y el major no subió | breaking_blocked | exit 1, listando cada cambio breaking con su categoría y ruta |
Esta 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 de aceptación 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.