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:
- HTTP, Problem Details, OpenAPI, W3C Trace Context y OpenTelemetry son las referencias externas elegidas.
data,meta.pageInfo,X-Request-Id, los códigos de error y los límites descritos aquí son convenciones de Ninaku; no requisitos universales de esas referencias.- Documentar una decisión no instala una dependencia ni implementa endpoints.
- Un ejemplo de Sales es un contrato ilustrativo futuro, no evidencia de que Sales ya exista.
- El objetivo es permitir cambios internos sin romper consumidores ni rehacer mecanismos transversales; no prometer que nunca habrá refactors.
Al adoptar esta especificación, Slice A está integrada mediante PR #14/#16. B, C1–C6 y D requieren su propia implementación y evidencia; consultar el issue #13 para el estado actualizado. El gate de producción sigue en #7. Este cambio documental no habilita despliegues, proveedores, backups ni gastos nuevos.
2. Los cuatro slices
| Slice | Responsabilidad | Resultado verificable |
|---|---|---|
| A — Arranque y conexión | Configuración tipada, pool PostgreSQL con ninaku_runtime, verificación del rol y health checks | Arranque controlado, rechazo de configuración insegura y conexión comprobable |
| B — Contexto y transacciones | Contexto por petición con AsyncLocalStorage, transacciones por caso de uso y contexto autorizado para RLS | No se mezclan tenants/peticiones; commit/rollback y aislamiento se prueban con PostgreSQL real |
| C — Contrato HTTP y observabilidad | Requests, éxitos, errores, colecciones, validación, logs, trazas, métricas básicas y OpenAPI | Los módulos reutilizan contratos y mecanismos verificables |
| D — Validación operativa y rendimiento | k6, pruebas bajo carga y medición del costo de la instrumentación | Resultados reproducibles, límites y regresiones medibles |
Son cuatro capacidades principales, no exactamente cuatro PRs. C se entrega en PRs pequeños C1–C6, cada uno con implementación y pruebas, siguiendo el flujo staging-first. No se declara C completa al aprobar solo su documentación.
B conserva la propagación del contexto autorizado mediante SET LOCAL —o su equivalente parametrizado local a la transacción— de app.actor_identity_id, app.identity_id, app.membership_id y app.organization_id, seguida de identity.assert_tenant_context(), según los contratos existentes. AsyncLocalStorage transporta contexto; no autentica ni autoriza. Ningún header de trazabilidad sustituye esas verificaciones.
3. C1 — Convenciones HTTP
Requests
Los comandos reciben su payload natural, sin request.data ni un envoltorio universal de acciones. Se definen body, path, query y headers por operación de negocio, no por tabla.
Ejemplo de contrato futuro:
http
POST /api/v1/sales/orders
Content-Type: application/json
Idempotency-Key: 01994820-17a0-784e-8b2c-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.
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-4ec8d344e9a6json
{
"data": {
"orderId": "84e7252c-c8bc-4cda-8e77-945818caa260",
"status": "draft",
"version": 1,
"total": { "amount": "24.50", "currency": "USD" }
}
}La aplicación devuelve X-Request-Id en las respuestas que controla, también en errores cuando sea posible. El frontend puede usarlo para soporte sin exigir meta.traceId en cada éxito. La política de CORS debe exponer los headers que el cliente necesita leer; la lista exacta se prueba en C1.
El servidor genera o valida explícitamente los identificadores de correlación en su frontera de confianza. No copia headers arbitrarios a logs ni los usa como identidad autorizada.
C1 resuelve dos puntos que esta sección deja abiertos: el servidor ignora cualquier X-Request-Id entrante y genera siempre el suyo, porque requestId identifica un intento HTTP del servidor (sección 7.2), no una entrada de confianza del cliente; y el header x-trace-id que B exponía en la respuesta deja de presentarse — el trace id sigue resolviéndose y almacenándose para la correlación de OpenTelemetry en C5, pero X-Request-Id es ahora el único header de correlación de la aplicación. La política de orígenes permitidos por entorno (ver NESTJS_STACK_GUIDE.md sección 22) ya está implementada mediante CORS_ALLOWED_ORIGINS y expone ETag/X-Request-Id a los orígenes permitidos; ver la sección 3.1 para el mecanismo completo.
Representación de datos
| Dato | Contrato |
|---|---|
| Nombres JSON | camelCase |
| Identificadores | Strings con formato declarado en el esquema |
| Importes y cantidades decimales exactas | Strings con precisión/escala documentadas; moneda o unidad cuando corresponda |
| Contadores, versiones y límites enteros | Números enteros dentro de los rangos documentados |
| Instantes | ISO 8601 en UTC |
| Fechas de negocio | Fecha sin conversión implícita a instante; zona horaria explícita cuando interviene en el caso de uso |
Campo ausente y null | Semántica declarada por contrato; no son equivalentes automáticamente |
| Enums | Valores documentados; cambios revisados por compatibilidad |
No convertir decimales exactos silenciosamente a number. Las restricciones del DTO no reemplazan las reglas del dominio.
Estados, headers y excepciones
| HTTP | Uso de Ninaku |
|---|---|
| 200 | Consulta o comando completado con representación |
| 201 | Recurso creado; Location cuando corresponde |
| 202 | Aceptación durable asíncrona, no finalización; contrato de consulta del estado de operación |
| 204 | Éxito sin cuerpo |
| 400 | JSON inválido o incumplimiento del contrato de entrada |
| 401 / 403 | Falta autenticación / operación no autorizada |
| 404 | Recurso no disponible en el contexto autorizado, sin revelar otro tenant |
| 409 | Conflicto de negocio, concurrencia o idempotencia definido por el caso de uso |
| 412 / 428 | Precondición obsoleta / precondición requerida ausente |
| 413 | Cuerpo de la solicitud excede el tamaño máximo aceptado por el endpoint |
| 415 | Content-Type de la solicitud no soportado por el endpoint |
| 422 | Solicitud bien formada pero semánticamente inválida (RFC 9110 §15.5.21); la validación de entrada de C3 sigue usando 400, no 422 |
| 429 / 503 | Límite temporal / indisponibilidad transitoria; Retry-After cuando existe un valor conocido |
| 500 | Fallo inesperado, con detalle público seguro |
Un módulo no inventa otra clasificación: cualquier estado adicional necesita propósito y esquema documentados. Nunca se responde 200 para ocultar un error.
Las respuestas 204/304, HEAD, archivos, streams y health checks conservan su contrato propio. Un interceptor no envuelve todo indiscriminadamente. Las excepciones se declaran y prueban, no se deducen examinando si un objeto tiene una propiedad data. Los errores siguen C4 salvo protocolos operativos expresamente documentados.
Se documentan Authorization, Content-Type, Accept, Accept-Language, Idempotency-Key, If-Match/ETag y headers de correlación según la operación. C fija el contrato de idempotencia y precondiciones; no afirma que todos los comandos futuros ya estén deduplicados. Una escritura solo admite reintentos según la política del comando y su evidencia durable existente. retryable: true por sí solo nunca autoriza repetir un cobro.
3.1 Mecanismo implementado y decisiones que la especificación dejaba abiertas
src/foundation/http implementa peticiones condicionales (ETag, If-None-Match, If-Match, 304, 412) y cierra las excepciones de envoltorio que esta sección declaraba sin implementación verificable.
- Quién calcula el validador: el handler, no un cálculo central sobre el cuerpo serializado. Un handler que quiere participar devuelve
HttpConditionalResponse(data, etag)(src/foundation/http/conditional-requests/http-conditional-response.ts), análogo aHttpResponseWithMeta;etages el identificador opaco que el propio módulo considera representativo del estado actual (por ejemplo unversiono un hash de negocio), no un hash genérico del JSON de salida. Se descarta calcular el validador centralmente (hashear la respuesta en el interceptor) porque produciría solo un validador fuerte que cambia con cualquier diferencia incidental de bytes (espacios, orden de claves, un campo no relacionado) y porque exigiría serializar el cuerpo completo en cada petición solo para descartar el resultado. La contrapartida documentada de la opción elegida es la misma: el handler sigue produciendo su representación completa antes de que el framework decida si el cliente ya la tiene (ver más abajo); evitar ese costo exigiría una vía de "solo verificación" específica de cada módulo, fuera del alcance de Foundation. - Default seguro: un handler que no devuelve
HttpConditionalResponseno recibe cabeceraETagni ningún comportamiento condicional, incluso si el cliente envíaIf-None-Match. Esto exigió un cambio adicional no anticipado por la especificación: Express calcula y envía por defecto unETagdé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 deHttpAdapterHost, de modo que la única fuente deETages un handler que optó explícitamente. If-None-Match: la interceptor solo fija la cabeceraETagcuando el handler participa; la evaluación deIf-None-Match(coincidencia exacta, comodín*, lista separada por comas y equivalencia débil/fuerte) no se reimplementa en Foundation. Se delega en el chequeo de "freshness" ya incorporado enres.send()de Express (paquetefresh), que aplica exactamente esa semántica y solo paraGET/HEADcon estado 2xx o 304. Comparación elegida para esta cabecera: débil (freshnormaliza el prefijoW/en ambos lados), que es la correcta paraIf-None-Matchsegún RFC 9110 §13.2.2. Probado con coincidencia exacta, sin coincidencia, ausencia de cabecera,*, lista de varios validadores y un validador débil del cliente contra el validador fuerte del servidor (test/e2e/conditional-requests.e2e-spec.ts).If-Match:assertIfMatchSatisfied(src/foundation/http/conditional-requests/conditional-request.ts) es una función pura que el handler llama con la cabecera entrante y el validador actual antes de escribir; sin cabecera, no hace nada (default seguro simétrico al de lectura). Comparación elegida: fuerte (src/foundation/http/conditional-requests/entity-tag.ts, funciónmatchesStrong), correcta paraIf-Matchsegún RFC 9110 §13.2.1 — un validador débil nunca satisface la precondición aunque el valor opaco coincida. Un fallo lanzaPreconditionFailedException(@nestjs/common), que ya atraviesa el registro de errores existente (HTTP_STATUS_ERROR_CODE[412]enproblem-details.builder.ts) sin ningún cambio enerrors/. 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 eltagopaco a la gramáticaetagcde 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 enformatEntityTagen vez de producir una cabeceraETagmalformada o ambigua; un valor entrante fuera de ese conjunto se descarta enparseEntityTagigual que cualquier otro valor mal formado. El tag recomendado es un entero de versión, un UUID, texto base64url o un digest hexadecimal: formas que ya cumplen ese charset por construcción. - La respuesta 304: no lleva cuerpo, no se envuelve en
{ data }y no se traduce a Problem Details, verificado con aserciones HTTP reales sobre estado, cuerpo vacío y ausencia de ambas formas. - Excepciones de envoltorio (204, HEAD, streams): al probar estos casos end-to-end no se encontró una brecha real en el interceptor. Antes y después de este cambio, 204 nunca lleva cuerpo — Express descarta
Content-Type/Content-Length/cuerpo cuando el estado es 204 o 304 sin importar qué recibares.json(), incluso si un handler decorado con@HttpCode(204)devuelve un objeto por error —; HEAD nunca escribe cuerpo — Express omite la escritura para ese método aunque el handler devuelva la misma representación que en GET —; y unStreamableFileque falla nunca se traduce a{ data }ni a Problem Details — un error antes de enviar cabeceras produce un 400 con el mensaje crudo, y un error después de empezar a transmitir trunca la respuesta sin cerrarla como error HTTP-, todo ya provisto por el manejo deStreamableFilede Nest/Express.test/e2e/http-envelope.e2e-spec.tsfija esos cuatro comportamientos con handlers que fuerzan el caso adverso (cuerpo devuelto por error en una ruta 204, stream que falla antes de cualquier byte y stream que falla a mitad de transferencia), y añade el mismo chequeo de regresión ya existente para/health/livea/health/ready. - Un handler sin representación responde 204, no
{ data: undefined }: la regla esHttpEnvelopeInterceptor(src/foundation/http/envelope/http-envelope.interceptor.ts) fija así: una respuesta JSON siempre exigedata(nunca{ data: undefined }, que unJSON.stringifyreal convierte en{}sin que el cliente pueda distinguirlo de "ocurrió una omisión"), y la ausencia real de representación responde204sin envoltorio, nunca un200con cuerpo vacío u omitido. Antes de esta regla, un handler como@Get() foo(): void {}—sin@HttpCodeni@SkipHttpEnvelope— quedaba sin cubrir:wrap()lo envolvía igual que cualquier otro resultado ({ data: result }), y comoresulteraundefined, el objeto viajaba como{ data: undefined };JSON.stringifydescarta las propiedades con valorundefined, así que el cliente recibía en realidad200con el cuerpo{}, no un fallo, pero tampoco el204documentado en la sección 3 ni ningúndata. El interceptor distingue ese caso por identidad estricta conundefined(result === undefined), fijaresponse.statusCode = 204y no envuelve nada — unnullexplícito sigue siendo una representación real y se envuelve como{ data: null }, porque la sección "Representación de datos" ya distingue campo ausente denullexplícito y esa distinción no debe colapsar en el nivel del sobre completo.http-envelope.interceptor.spec.tsfija ambas mitades de la regla a nivel de interceptor (unundefinedproduce204sin cuerpo; unnullproduce{ data: null }sin tocar el estado);test/e2e/http-envelope.e2e-spec.tsla fija de punta a punta contra un handlerno-representationsin ningún decorador de envoltorio o estado. - Política CORS y confianza de proxy:
configureHttpApplication(src/bootstrap/configure-http-application.ts) resuelve el punto que esta sección dejaba abierto.CORS_ALLOWED_ORIGINS(src/foundation/config/schema/environment-variables.schema.ts) es una lista de orígenes absolutos separados por comas, sin comodín; cada entrada debe cumplirnew URL(entrada).origin === entrada(sin path, query ni fragment) o el arranque falla nombrando la entrada inválida. Por defecto, sin configurar o vacía, la lista es cero orígenes permitidos en todo entorno, incluido desarrollo: es una decisión deliberada de fail-closed, no un valor provisional — esta API maneja datos de tenant y un default permisivo dejaría un entorno nuevo aceptando lecturas cross-origin hasta que alguien recuerde restringirlo.enableCorsexpone exactamenteETagyX-Request-Id(exposedHeaders, los dos headers de respuesta propios que un navegador necesita leer víafetch/XHR) y permite exactamenteContent-Type,If-MatcheIf-None-Matchcomo headers de petición (allowedHeaders); no se permiteAuthorizationni se expone ningún otro header porque nada en este código los consume todavía.credentialsesfalse: no existe autenticación por cookies ni por sesión HTTP en este código (DatabaseSessiones una sesión de base de datos, no de HTTP), así que habilitar credenciales CORS ahora solo ampliaría la superficie de ataque sin ningún beneficio correspondiente.app.set('trust proxy', 1)confía exactamente un salto de cabecerasX-Forwarded-*, correspondiente al único borde de Railway que termina TLS delante del contenedor (docs/operations/RAILWAY_OPERATIONS.md); no se usatrust proxy: trueporque eso confiaría en una cadena arbitraria de cabeceras reenviadas. Esta fase deliberadamente no agregó todavía un prefijo global de API; el prefijo/apiy el versionamiento URI se agregaron en el cambio encadenado siguiente, descrito en la sección 3.2. Probado end to end entest/e2e/http-application-policy.e2e-spec.ts: origen no permitido sinAccess-Control-Allow-Origin, origen permitido reflejado, preflight con los headers correctos,ETag/X-Request-IdenAccess-Control-Expose-Headers, ausencia deAccess-Control-Allow-Credentials, el ajustetrust proxyde la instancia Express, y una ruta sin relación (/health/live) sin cambios de comportamiento.
3.2 Prefijo global de API y versionamiento URI
Mecanismo: configureHttpApplication (src/bootstrap/configure-http-application.ts) llama a app.setGlobalPrefix('api', { exclude: [...] }) y a app.enableVersioning({ type: VersioningType.URI, defaultVersion: '1' }). Toda ruta de negocio queda expuesta bajo /api/v{n}; un método sin @Version() explícito hereda defaultVersion y responde ya en /api/v1/... sin que su módulo declare nada. Un método que en el futuro necesite cambiar de forma incompatible agrega @Version('2') junto al método existente (no al controlador completo, salvo que el controlador entero cambie): las dos versiones conviven en la misma tabla de rutas bajo /api/v1/... y /api/v2/...; ninguna reemplaza a la otra y un endpoint sin cambios entre versiones no se duplica.
Por qué versionamiento en vez de un prefijo estático: la razón declarada para este trabajo es permitir varias versiones de API coexistiendo en el futuro, no solo anteponer texto a la ruta. Un prefijo global fijo (app.setGlobalPrefix('api/v1'), sin enableVersioning) habría producido el mismo /api/v1/... de hoy, pero el día que exista una v2 real no hay forma de servirla junto a v1 sin cambiar ese prefijo global — lo que rompe toda v1 existente — o sin montar un segundo NestApplication/adaptador en paralelo, que Nest no necesita y este documento no adopta. app.enableVersioning({ type: VersioningType.URI }) resuelve el problema en el mecanismo del framework: cada método declara su propia versión (o hereda defaultVersion), y Nest construye ambas rutas en la misma tabla de enrutamiento.
Salud queda fuera de ambos mecanismos, deliberadamente por partida doble: HealthController se declara @Controller({ path: 'health', version: VERSION_NEUTRAL }) y sus dos rutas se listan en el exclude de setGlobalPrefix ({ path: 'health/live', method: RequestMethod.GET }, { path: 'health/ready', method: RequestMethod.GET }). Los dos mecanismos son necesarios, no alternativos: en RoutePathFactory.create() (@nestjs/core) el segmento de versión (/v1/...) se inserta antes de que setGlobalPrefix decida si excluye la ruta del prefijo, y esa exclusión nunca revierte un segmento de versión ya insertado — solo evita anteponer api. Sin VERSION_NEUTRAL, salud pasaría a /v1/health/live aunque estuviera excluida del prefijo api; sin la exclusión de setGlobalPrefix, salud pasaría a /api/health/live. Ambos juntos son la única combinación que deja exactamente /health/live y /health/ready, sin prefijo ni versión, igual que antes de este cambio — la misma forma que exige el healthcheck de Railway (.railway/railway.ts, healthcheck: '/health/ready'). La razón de fondo es la misma que ya excluye salud del envoltorio { data } (sección 3): es infraestructura para un probe, no superficie de API para un cliente de negocio.
Consecuencia verificada, no asumida: el documento OpenAPI publicado (docs/generated/openapi.json) describía, antes de este cambio, únicamente /health/live y /health/ready — ninguna ruta de negocio existe todavía — y las dos quedan excluidas del prefijo y de la versión. npm run openapi:generate produce por lo tanto un documento byte a byte idéntico al ya versionado; npm run openapi:check pasa sin detectar drift y npm run openapi:compat no encuentra ningún cambio breaking que evaluar contra origin/staging. D13 (DECISIONS.md) no aplica a este cambio porque no hay ninguna ruptura de contrato que aceptar ni ningún motivo para subir el major de OPENAPI_DOCUMENT_VERSION. Este trabajo agrega el mecanismo que la próxima ruta de negocio usará por defecto; no reescribe docs/generated/openapi.json.
Prueba: test/e2e/api-versioning.e2e-spec.ts monta AppModule junto a un fixture de prueba (test/fixtures/versioning, nunca registrado en AppModule) para probar de punta a punta: una ruta sin @Version() explícito resuelve en /api/v1/...; la misma ruta sin prefijo, con el prefijo pero sin versión, o con una versión que nadie declaró, responde 404 en vez de resolver por accidente; dos métodos del mismo controlador con @Version('1')/@Version('2') sobre el mismo path conviven en /api/v1/... y /api/v2/... sin que uno reemplace al otro; y /health/live//health/ready siguen respondiendo exactamente en esas rutas, sin prefijo ni versión, con el mecanismo ya activo (test/e2e/http-application-policy.e2e-spec.ts fija la misma regresión).
4. C2 — Colecciones, cursores y consultas
Forma única
Se usa meta.pageInfo, no un segundo formato pageInfo en la raíz. Esto conserva el envoltorio data + meta.
http
GET /api/v1/sales/orders?limit=50&status=draft&sort=-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.
Antes de cerrar C2 se documentan el mecanismo de protección elegido, rotación de claves cuando aplique, tamaño máximo, política de expiración y códigos de error. Estos parámetros no se inventan como garantías implementadas en esta especificación. Codificar en base64 no equivale a proteger integridad o confidencialidad; el cursor no debe contener secretos.
Consistencia y ownership
La paginación operacional es una vista viva salvo contrato contrario. Un orden único no crea un snapshot entre peticiones: actualizaciones, eliminaciones y cambios de pertenencia a los filtros pueden alterar lo que se observa. Los campos de orden mutables requieren política explícita del módulo.
Los reportes que requieren un corte consistente definen snapshot/asOf y su mecanismo, sin prometer esa consistencia para todos los listados. Un cursor de listado no se presenta como protocolo de sincronización offline.
Foundation proporciona contratos, validación y utilidades del cursor. Cada módulo conserva la consulta SQL, los índices, las reglas de autorización y la consistencia. C2 prueba las utilidades con datos de prueba; el primer listado real debe aportar evidencia sobre su consulta y sus índices.
4.1 Mecanismo implementado y decisiones que la especificación dejaba abiertas
src/foundation/pagination implementa el mecanismo compartido, agrupado por responsabilidad: cursor/ (cursor-codec.ts, codificación/verificación HMAC pura; cursor-codec.service.ts, envoltorio inyectable sobre CURSOR_SIGNING_KEY; cursor.exceptions.ts, traducción a códigos C4; cursor-binding.ts, huella de a qué es válido un cursor), keyset/ (keyset-pagination.ts, predicado de keyset y recorte de página) y, en la raíz, collection-query.schema.ts (validación C3 de limit/after/sort). CollectionsModule expone CursorCodecService igual que TransactionsModule/TenancyModule.
Decisiones que C2 resuelve al implementar (la especificación las dejaba abiertas):
Mecanismo de protección: HMAC-SHA256 (
node:crypto, sin dependencia nueva) sobre un token con cuatro segmentos separados por.:v1.<keyId>.<payload-base64url>.<firma-base64url>. El payload (JSON antes de codificar) contieneexp,binding,sortValueseid; nunca la clave de firma ni datos de negocio adicionales. Codificar en base64url no sustituye la firma: la firma cubre versión,keyIdy payload completos, así que cualquier alteración de cualquiera de los tres se detecta comocollection.cursor_tampered. El cursor está firmado, no cifrado: HMAC garantiza que el payload no fue alterado, pero cualquiera puede decodificar el base64url y leerbinding,sortValueseiden 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 ensortValues(por ejemplo un correo, un monto o un identificador que no deba exponerse) confiando en que nadie puede leerlos.Formato e id de clave:
keyIdson los primeros 16 caracteres hexadecimales deSHA-256(CURSOR_SIGNING_KEY), derivados en el momento, no configurados por separado. Un cursor cuyokeyIdno coincide con el de la clave activa se rechaza comocollection.cursor_invalid(no se distingue de una clave desconocida, porque el servidor solo mantiene la clave activa).Rotación: fijar un nuevo valor de
CURSOR_SIGNING_KEYy desplegar. Como elkeyIdse deriva de la clave, todos los cursores previos cambian dekeyIdautomáticamente y la siguiente petición que los reutilice falla concollection.cursor_invalid; el cliente reinicia la paginación desde la primera página. No existe una ventana de verificación con dos claves simultáneas en C2: el radio de impacto de una rotación queda acotado por la expiración corta del cursor (ver debajo), no por infraestructura de rotación adicional. Una necesidad futura de rotación sin cortes requeriría una decisión explícita y documentada aparte.Expiración: 900 segundos (15 minutos) por defecto, ajustable por llamada a través de
EncodeCursorInput.ttlSecondscuando un caso de uso lo justifique, hasta un máximo de 86400 segundos (24 horas,CURSOR_MAX_TTL_SECONDS).encodeCursorexige 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.ttlSecondses un parámetro interno (nunca proviene directamente de un cliente HTTP), pero la validación falla igual de fuerte que en una frontera pública. La paginación es una vista viva (sección "Consistencia y ownership"); la expiración limita cuánto tiempo un cursor sigue siendo válido, no ofrece ni promete un snapshot.Tamaño máximo: 2048 bytes por token (
CURSOR_MAX_TOKEN_LENGTH), verificado antes de intentar decodificar. Un cursor real con un único valor de orden e id UUID mide varios cientos de bytes; el límite es un margen defensivo contra abuso, no una expectativa de uso normal.encodeCursorverifica el mismo límite y el mismo esquema (CursorPayloadSchema, que exigebinding/id/cadasortValuesno vacíos) antes de firmar, no solodecodeCursoral verificar: Foundation nunca debe poder emitir un cursor que ella misma rechazaría en la siguiente petición. Decodificar tolera la entrada del cliente (un JSON no parseable o un payload inválido son entrada incorrecta, no una excepción del servidor); codificar es estricto porque el payload lo construye Foundation misma, así que cualquier violación ahí es un bug propio y debe fallar en voz alta.Códigos de error: cuatro códigos estables, cada uno un
400 Bad Requestconerrors: [{ location: 'query', field: 'after', code, message }], sin ningún detalle interno del HMAC o del payload:Código Motivo collection.cursor_invalidToken mal formado, versión no soportada o keyIdno reconocidocollection.cursor_tamperedLa firma no coincide con el payload collection.cursor_expiredexpya pasócollection.cursor_incompatibleEl binding del cursor no coincide con el de la petición actual Estos códigos se producen mediante
HttpExceptionconcodepropio (el mecanismo que la sección 6.1 ya documenta para C4). Los cuatro están registrados enERROR_CODE/ERROR_CODE_STATUS_REGISTRY(estado 400,retryable: false) y enPROBLEM_TYPE_REGISTRY, pero se excluyen deliberadamente del mapa inverso estado→código (HTTP_STATUS_ERROR_CODE, derivado porbuildHttpStatusErrorCode()enproblem-details.builder.ts): asívalidation.failedsigue siendo el código de respaldo para cualquierHttpExceptioncon estado 400 no mapeado explícitamente, y solo elcodepropio 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 devuelveSHA-256de su forma canónica (claves ordenadas, valores codificados conJSON.stringifypara no confundirnullcon 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 comocollection.cursor_incompatibleen vez de aplicarse silenciosamente a la nueva consulta. Los valores numéricos deben ser finitos:NaNeInfinityse rechazan en voz alta, porqueJSON.stringifycolapsa ambos anully haría que un filtro numérico inválido comparta binding con un filtronullreal.Alcance del helper de keyset:
buildKeysetPredicate()/sliceKeysetPage()cubren la forma que la sección "Reglas de paginación" ilustra: una columna de orden más un id de desempate, ambos con la misma dirección, columnas NOT NULL. No es un motor de filtros: los nombres de columna los declara el módulo (verificados contra un patrón de identificador seguro), no el cliente. Un orden compuesto por más de una columna de negocio, o un desempate sobre una columna nullable, queda fuera de C2 y requiere una extensión explícita cuando un módulo real lo necesite.buildKeysetPredicate()exigeparamIndexStart >= 1(los parámetros de PostgreSQL empiezan en$1, nunca$0) ysliceKeysetPage()exige unlimitentero positivo; ambas primitivas fallan en voz alta con el mismo criterio que ya protege los nombres de columna, en vez de generar un placeholder inválido o recortar la última fila silenciosamente.Validación de consulta:
createLimitSchema()(limit, entero 1-100, por defecto 50),AfterCursorSchema(after, cadena no vacía acotada a 2048 caracteres) ycreateSortQuerySchema(camposPermitidos)(sort, solocampo/-campode una lista declarada por endpoint) son primitivas C3 componibles; cada endpoint las combina con sus propios filtros bajo.strict(), igual queidentifier.schema.ts/decimal.schema.tsse combinan en un esquema de negocio. Ningún esquema de C2 acepta nombres de columna, SQL o expresiones arbitrarias.Evidencia:
src/foundation/pagination/**/*.spec.tsprueba el códec, los códigos de error y la validación de consulta de forma aislada;keyset-pagination.integration-spec.tspaginareference.nutrients(schemareference, legible porninaku_runtimesegúndatabase/access/runtime_grants.sql) con PostgreSQL real;test/e2e/collections.e2e-spec.tsprueba el contrato HTTP completo — múltiples páginas, límite por defecto y límites, colección vacía, y cursor inválido/alterado/vencido/incompatible-por-filtro/incompatible-por-tenant — sobre un endpoint de fixture (test/fixtures/collections) que nunca se registra enAppModule.
5. C3 — Validación de entrada
Usar Zod y el mecanismo Standard Schema previsto en la guía del stack. Validar body, path params, query params y headers relevantes.
Cada contrato define:
- campos requeridos/opcionales y significado de
null; - conversiones admitidas, sin coerción accidental;
- tratamiento de campos desconocidos;
- límites de tamaño, longitud, rangos, arrays y profundidad cuando corresponda;
- formatos de identificadores, decimales, fechas y enums;
- traducción uniforme a los errores de C4.
La validación responde «¿la entrada cumple su contrato?». El dominio responde «¿esta acción está permitida en este estado?». No se mueve lógica de Sales, Inventory o Payments a un pipe.
Las pruebas cubren entrada inválida en todas las fronteras, no solo el body. Los mensajes no incluyen automáticamente el valor rechazado cuando puede ser sensible.
5.1 Mecanismo implementado y decisiones que la especificación dejaba abiertas
src/foundation/validation implementa el mecanismo compartido. Cada ruta declara su esquema con bodySchema(), querySchema(), pathSchema() o headerSchema() sobre @Body()/@Query()/@Param()/un decorador de parámetro dedicado, y Nest 12 valida mediante su StandardSchemaValidationPipe nativo.
Decisiones que C3 resuelve al implementar (la especificación las dejaba abiertas):
- El decorador nativo
@Headers()de Nest 12.0.1 no admite adjuntar un esquema ni pipes en línea, a diferencia de@Body()/@Query()/@Param().headerSchema()añade un decorador de parámetro propio (RequestHeadersParam, víacreateParamDecorator) que reutiliza exactamente el mismoStandardSchemaValidationPipey el mismo mapeo de errores, habilitandovalidateCustomDecoratorssolo para ese pipe. - Política de campos desconocidos por frontera:
body,queryypathusan.strict()por defecto recomendado, y cada esquema lo declara explícitamente. Los headers no pueden ser estrictos: una petición real siempre transporta headers ajenos al contrato de la operación (Host,User-Agent,Accept,Connection, ...), así que el esquema de headers valida únicamente la clave nombrada por el contrato (por ejemploidempotency-key) y deja pasar el resto sin interpretarlos. - Identificadores aceptan cualquier UUID bien formado (RFC 4122, versiones 1-8), no solo UUIDv7: la frontera valida forma, no procedencia; la generación UUIDv7 sigue siendo responsabilidad del dominio/base de datos.
- Cadenas decimales exactas exigen la escala declarada de forma obligatoria (por ejemplo escala 2 acepta
"24.50"pero rechaza"24.5"y"24"), nunca se coacciona el valor anumber. - Los instantes exigen sufijo
Z(UTC explícito, sin convertir un offset no UTC); las fechas de negocio son fechas sin componente horario. Ambos son contratos distintos y no intercambiables. - El tipo público
StandardSchemaV1.Issuesolo declaramessage/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 sobreunknown, nuncaany) para producir un código de campo establevalidation.<motivo>. Un emisor de otro validador Standard Schema sin ese campo cae al código genéricovalidation.invalid, nunca a un código inventado. - Un fallo de nivel raíz (sin segmento de propiedad,
path: []) se reporta confield: ''; 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": "Solicitud inválida",
"status": 400,
"detail": "Revisa los campos indicados.",
"code": "validation.failed",
"traceId": "4bf92f3577b34da6a3ce929d0e0e4736",
"retryable": false,
"errors": [
{
"location": "body",
"field": "items[0].quantity",
"code": "validation.positive_required",
"message": "La cantidad debe ser mayor que cero."
}
]
}Reglas:
errors, cuando existe, es siempre una lista del mismo esquema, nunca alternativamente un objeto por campo.locationidentifica body, query, path o header según el contrato.codees estable y sirve para lógica y traducción del cliente. No analizartitle,detailomessagecomo códigos.statuscoincide con el estado HTTP real.typeidentifica una clase documentada de problema.traceIdcorresponde al contexto real de la ejecución cuando está disponible; no se inventan identificadores para aparentar instrumentación.- Validación, autenticación, autorización, rutas inexistentes y fallos inesperados controlados por la aplicación pasan por el contrato común. No prometer reformatear respuestas que genera un proxy externo antes de llegar a Nest.
- Sin SQL, nombres internos de constraints, stack traces, secretos, detalles de conexión o datos personales en la respuesta pública.
- Traducir PostgreSQL mediante códigos/constraints conocidos y mapeos explícitos. No buscar texto en mensajes ni convertir cualquier constraint desconocido a un conflicto de negocio.
- Los módulos producen errores tipados; Domain/Application no construyen respuestas HTTP.
- Reintentos y respuestas 202 no sustituyen el contrato durable de idempotencia, ni convierten un timeout de proveedor en prueba de que no hubo efecto externo.
C4 incluye registro/catálogo de códigos, estados y esquemas. No agrega códigos especulativos para todas las verticales futuras.
6.1 Catálogo implementado y decisiones que la especificación dejaba abiertas
El filtro global (HttpAdapterHost) y el registro de códigos viven en src/foundation/errors. El catálogo actual cubre exactamente los estados de error de la tabla de la sección 3:
| Código | Estado | retryable |
|---|---|---|
validation.failed | 400 | false |
auth.unauthenticated | 401 | false |
auth.unauthorized | 403 | false |
resource.not_found | 404 | false |
conflict.business | 409 | false |
precondition.failed | 412 | false |
payload.too_large | 413 | false |
media_type.unsupported | 415 | false |
content.unprocessable | 422 | false |
precondition.required | 428 | false |
rate_limit.exceeded | 429 | true |
service.unavailable | 503 | true |
tenant.context_rejected | 403 | false |
error.unexpected | 500 | false |
Un HttpException de Nest con un estado no documentado se sanea siempre como error.unexpected, conservando el estado HTTP real. Un fallo que no sea HttpException también se sanea a error.unexpected salvo el caso explícito de errores de body-parser/raw-body descrito abajo; en ambos saneos el mensaje es genérico, sin stack ni detalle interno.
Decisiones que C4 resuelve al implementar (la especificación las dejaba abiertas):
TenantContextRejectedError(rechazo42501deidentity.assert_tenant_context()) se traduce a403con el códigotenant.context_rejected, no a404: no es "recurso no disponible en el contexto autorizado" sino un contexto de tenant inválido detectado por RLS, y nunca expone el mensaje interno de PostgreSQL.- La tabla de traducción PostgreSQL por SQLSTATE hoy contiene únicamente
42501(mismo código queTenantContextRejectedError, para el caso en que unpgerror crudo con ese SQLSTATE llegue directo al filtro). Cualquier otro SQLSTATE o constraint desconocido cae al camino saneado deerror.unexpected, nunca a un conflicto de negocio inventado. - Un
HttpExceptionpuede aportar su propiocode/detail/errorsen el cuerpo de la excepción; el filtro los reenvía sin modificar (sujeto al saneo dedetailen estados 5xx).retryablenunca se lee del cuerpo de la excepción: siempre es el valor fijo deERROR_CODE_STATUS_REGISTRYpara ese código, así que una excepción que incluya su propia propiedadretryablela ve ignorada. - 413 (
payload.too_large), 415 (media_type.unsupported) y 422 (content.unprocessable) participan en el mismo mapa de respaldo estado→código (HTTP_STATUS_ERROR_CODE, derivado porbuildHttpStatusErrorCode()) que el resto de la tabla de la sección 3: unHttpExceptioncon uno de esos tres estados y sincodepropio ya no cae enerror.unexpected, y conserva sudetail/errorscomo cualquier otro estado documentado. La validación de entrada (C3) sigue usando exclusivamente400/validation.failed;content.unprocessablecubre el contrato RFC 9110 §15.5.21 de "bien formado pero semánticamente inválido" para elHttpExceptionque 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íahttp-errors) para esos rechazos, y esos objetos nunca soninstanceof HttpExceptionde Nest, así que sin más caían en el saneo genérico deerror.unexpectedcon estado 500 aunque el estado real ya fuera 4xx — el cliente recibía "fallo del servidor" por haber enviado una solicitud inválida, yrecordServerFailureResponselo registraba enerrorjunto a los fallos reales.buildProblemDetailsreconoce ahora la forma de esos errores con una guarda de tipo sobreunknown(nunca un cast): un objeto contypeigual aentity.too.large,entity.parse.failed,encoding.unsupportedocharset.unsupported(los únicos tipos quebody-parser/raw-bodyproducen) y unstatus/statusCodenumérico se resuelve a través del mismoHTTP_STATUS_ERROR_CODEque unHttpException. Eldetail/messagedel error original nunca se reenvía — esos objetos pueden llevar el charset o el contenido rechazado del cliente incrustado en su mensaje — solo se usa el estado y el mensaje por defecto del catálogo. Cubre 413 (entity.too.large), 415 (charset.unsupported,encoding.unsupported) y 400 (entity.parse.failed, para un fallo de parseo que no sea elSyntaxErrorde JSON que Nest ya convierte enBadRequestExceptionantes 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 unContent-Typecon un charset inválido, no solo con unHttpExceptionconstruido a mano. - Las rutas que conservan contrato propio (health checks, sección 3) se declaran con
@UseFilters(ProtocolExceptionFilter), no con inferencia automática: el filtro de protocolo reenvía la respuesta original de la excepción sin traducirla a Problem Details.
7. C5 — Logs, trazas, métricas y auditoría
7.1 Un límite transversal, no un framework propio
Inicialmente usar el logger JSON de Nest detrás de una interfaz pequeña de Ninaku que centralice formato, contexto, campos permitidos y política de errores. No implementar un logger desde cero ni permitir una configuración por módulo.
Pino/nestjs-pino sigue siendo una alternativa evaluable: adoptarlo solo mediante una decisión documentada, compatibilidad verificada y las mismas pruebas de seguridad/rendimiento. Cambiar el adaptador no debe requerir reescribir módulos.
La estrategia de trazas adoptada es OpenTelemetry con W3C Trace Context, con exportación configurable y sin SDKs APM solapados. Elegir instrumentaciones compatibles es parte de C5; aprobar este documento no instala paquetes ni activa un proveedor comercial. @nestjs/observe no es un segundo SDK obligatorio.
7.2 Identificadores distintos
| Identificador | Finalidad |
|---|---|
requestId | Un intento HTTP; disponible mediante X-Request-Id en respuestas controladas |
traceId | Correlacionar segmentos instrumentados de una ejecución |
commandId | Intención estable de negocio que puede sobrevivir reintentos/offline |
No usar el mismo identificador para todas las finalidades. Un retry puede conservar commandId y tener otra petición/traza. La política de enlace y propagación se documenta para workers e integraciones cuando existan.
El trace ID sigue W3C/OpenTelemetry; los ejemplos usan 32 caracteres hexadecimales para la traza y 16 para el span. En JSON técnico se serializan como trace_id/span_id; en Problem Details la extensión pública conserva traceId. Es un mapeo explícito, no dos trazas distintas.
Solo incluir campos de traza/span cuando exista contexto válido. No inventar spans en logs de arranque. Un ID en un log no equivale a instrumentar una traza. Los valores de traceparent o baggage no conceden permisos ni establecen el tenant; aplicar una política de confianza y propagación sin copiar datos sensibles.
7.3 Formato y campos
JSON estructurado, un registro por línea. Ejemplo del formato de aplicación; no es un payload OTLP:
json
{
"timestamp": "2026-09-15T12:30:45.123Z",
"level": "info",
"event": "http.request.completed",
"service": "ninaku-api",
"serviceVersion": "commit-sha",
"environment": "staging",
"requestId": "01994821-16a0-784e-8b2c-4ec8d344e9a6",
"trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
"span_id": "00f067aa0ba902b7",
"module": "sales",
"operation": "CreateOrder",
"http": {
"method": "POST",
"route": "/api/v1/sales/orders",
"statusCode": 201,
"durationMs": 48
}
}Los campos comunes son timestamp, level, event, service, serviceVersion y environment. Contexto HTTP, módulo, operación, error y organización solo cuando estén disponibles y permitidos. Para rutas usar plantillas, no URLs con query strings o identificadores sensibles.
La estructura del error técnico usa campos permitidos como error.code y error.category; stacks internos, cuando sean necesarios, se sanitizan y restringen. No serializar indiscriminadamente objetos Error, causas o respuestas de SDKs.
7.4 Cuatro responsabilidades
| Señal | Propósito | Destino/ownership |
|---|---|---|
| Logs técnicos | Diagnóstico de peticiones, fallos e hitos | Salida del proceso y recolección central, fuera de la base transaccional |
| Trazas | Segmentos HTTP, espera de pool, SQL y llamadas externas instrumentadas | Exportación OpenTelemetry configurable |
| Métricas | Tráfico, latencia, errores y saturación | Backend de métricas configurado, con dimensiones acotadas |
| Auditoría | Evidencia de negocio y seguridad | Mecanismo durable con propietario, autorización y retención definidos |
No crear una tabla universal de logs. Reutilizar y revisar el mecanismo de auditoría existente antes de proponer persistencia nueva.
Una auditoría exigida por un caso de uso no depende solo de stdout ni de un servicio de logs. Su persistencia forma parte de la transacción o del mecanismo durable especificado. Un fallo de auditoría obligatoria necesita política del caso de uso; no se confunde con pérdida tolerada de telemetría técnica.
C define esa frontera y sus mecanismos compartidos, no la auditoría de todas las acciones de módulos aún inexistentes.
7.5 Datos permitidos y protección
No registrar requests, responses, usuarios, headers o parámetros SQL completos por defecto. Construir registros con campos permitidos y usar redacción adicional como defensa, no como sustituto de la minimización.
Excluir contraseñas, PIN/OTP, tokens, cookies, API keys, cadenas de conexión, credenciales de pago y material privado de certificados. No capturar por defecto cédulas, teléfonos, correos o XML fiscales completos.
Aplicar la política también a mensajes/cadenas de error, causas, URLs, respuestas de proveedores, instrumentación automática y exportadores. Limitar tamaño y estructura; no interpolar entrada no confiable en líneas de log. Probar el registro ya serializado/exportado, no únicamente una función de redacción aislada.
Los identificadores de organización, actor y recurso requieren finalidad y permisos de consulta. No convertir request IDs, trace IDs, order IDs o tenant IDs en etiquetas de métricas o índices de cardinalidad ilimitada.
7.6 Severidad, duplicados y volumen
| Nivel | Uso |
|---|---|
| debug | Diagnóstico temporal y controlado; apagado en producción por defecto |
| info | Resultado normal e hitos útiles |
| warn | Degradación o situación que necesita seguimiento |
| error | Fallo técnico inesperado o de operación |
| fatal | El proceso no puede continuar con seguridad |
Los rechazos esperados de validación/negocio no producen automáticamente stack traces ni alertas urgentes. Definir un responsable para registrar un error inesperado; no repetir el mismo stack en repositorio, servicio, controller y filtro.
Registrar el resumen HTTP una vez y utilizar spans/eventos cuando añadan contexto real. Suprimir o muestrear health checks exitosos. La auditoría obligatoria no se muestrea.
Un 404 no es automáticamente warn. Cuando ninguna ruta de Express coincidió con la petición (route resuelve a unmatched), nadie del sistema se equivocó: alguien pidió algo que nunca existió, y eso es el caso más común en un host público apenas publicado, donde cualquier rastreador de Internet genera ese mismo 404 sin que haga falta ninguna acción. Ese caso se registra a info. Un 404 sobre una ruta que sí coincidió (por ejemplo un futuro /organizations/:id cuyo id no existe) sí se queda en warn: la API conoce la ruta y el recurso falta, lo que puede señalar un cliente roto o una referencia colgante. El resto de la clasificación no cambia: 5xx sigue en error, cualquier otro 4xx sigue en warn, y todo lo demás sigue en info. warn se reserva para lo que una persona puede necesitar revisar; diluirlo con ruido que no requiere acción es la forma más rápida de que deje de significar algo.
Definir presupuesto de bytes por registro, volumen, niveles, muestreo, retención por entorno/clase de dato y permisos de acceso. No inventar aquí plazos legales o cifras de capacidad. Las alertas deben ser accionables y tener responsable, no dispararse por cada mensaje rojo.
7.7 Exportación y fallos
text
Logs JSON → stdout/stderr → recolección/almacenamiento central
Trazas/métricas → exportación OpenTelemetry → destino configurado
Auditoría → persistencia durable del mecanismo 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
src/foundation/logging implementa el límite de logging: log-record.ts construye el registro JSON permitido (timestamp ISO 8601, level, event, service, serviceVersion, environment y, condicionalmente, requestId/trace_id/span_id/module/operation/organizationId/http/error/message), redaction.ts aplica la política de datos permitidos y known-secrets.registry.service.ts reúne los valores secretos configurados (contraseña de base de datos, CURSOR_SIGNING_KEY, cabeceras del exportador OTLP) para que la redacción los reconozca aunque aparezcan incrustados en un mensaje libre. NinakuLoggerService implementa LoggerService de Nest (se conecta con app.useLogger(), sustituyendo también los mensajes internos del framework) y añade event(level, nombre, campos) para las llamadas estructuradas propias. src/foundation/telemetry implementa el arranque de OpenTelemetry (tracing-pipeline.ts, metrics-pipeline.ts, node-instrumentation.ts, telemetry-bootstrap.ts), la exportación con redacción (redacting-span-exporter.ts) y la instrumentación HTTP (http-telemetry.middleware.ts, http-metrics.service.ts).
Decisiones que C5 resuelve al implementar (la especificación las dejaba abiertas):
- Por qué no el
ConsoleLoggerJSON de Nest tal cual: el modojsondeConsoleLoggeremitetimestampcomo epoch numérico ylevelcon los nombres internos de Nest (log,verbose), no eltimestampISO 8601 ni los cinco niveles (debug/info/warn/error/fatal) que exige la sección 7.6.NinakuLoggerServicesigue implementando el mismo contratoLoggerServiceque Nest expone (mismo punto de sustitución quenestjs-pinousaría) pero construye el registro JSON él mismo conprocess.stdout/process.stderrdirectamente; cambiar de adaptador en el futuro no requiere reescribir módulos, igual que exige la sección 7.1. - Redacción en dos capas: por nombre de campo (
password,secret,token,authorization,signingKey,otp,pin, etc. — coincidencia insensible a mayúsculas sobre la clave, redacción total sin mirar el valor) y por contenido (sustitución de cualquier valor secreto conocido dondequiera que aparezca dentro de una cadena, más patronesBearer <token>,Basic <credenciales>yusuario:contraseña@en URLs). La redacción por contenido es la defensa que cubre mensajes de error, causas y salidas de proveedores que no pasan por un campo con nombre reconocible. Cada cadena se acota a 2000 caracteres y el registro completo a 8 KiB; si excede el presupuesto, se sustituye por un registro mínimo (timestamp,level,event, identidad de correlación,truncated: true) en vez de truncar campos a mitad de palabra. CuandoJSON.stringifydel registro completo lanza una excepción (una estructura no serializable, no el presupuesto de tamaño),log-record.tsdistingue ese caso del anterior: emite el mismo conjunto mínimo de campos seguros pero conserializationFailed: trueen vez detruncated: true, para que un consumidor no confunda "se recortó por tamaño" con "no se pudo serializar en absoluto". Si incluso ese registro mínimo fallara al serializarse, la línea de último recurso sigue siendo{"level":"error","event":"log.serialization_failed"}: un catch puede decidir que un error no se propague, pero nunca que deje de conocerse. - Una sola política de nombres sensibles, no dos que puedan desacordar:
logging/redaction/redaction.tsytelemetry/pipelines/node-instrumentation.tsmantenían cada uno su propio vocabulario de nombres sensibles y ya habían empezado a desacordar (sig/Signature/AWSAccessKeyId/X-Goog-Signature/X-Amz-Signature/X-Amz-Credential/X-Amz-Security-Tokensolo en instrumentación;authorization/cookie/dsn/connectionstring/credential/certificate/privatekey/signingkeysolo en redacción).logging/redaction/sensitive-field-names.tses ahora la única fuente: declara las palabras sueltas (SENSITIVE_FIELD_NAME_WORDS), las compuestas de dos palabras (SENSITIVE_FIELD_NAME_COMPOUND_WORDS, por ejemploapi+key) y las adicionales exclusivas de query params (ADDITIONAL_SENSITIVE_QUERY_PARAM_NAMES:cursor,code).redaction.tsla usa paraisSecretFieldName;node-instrumentation.tsconstruyeREDACTED_HTTP_QUERY_PARAM_NAMESconcatenando ese mismo vocabulario con sus propias adiciones de firma de nube (sig,Signature,AWSAccessKeyId,X-Goog-Signature,X-Amz-Signature,X-Amz-Credential,X-Amz-Security-Token), que siguen siendo suyas porque son nombres de parámetro exactos de un proveedor, no palabras genéricas de campo. - Coincidencia por token completo, no por subcadena libre: la política anterior probaba el patrón como subcadena insensible a mayúsculas contra la clave cruda, así que un campo inocente como
shippingofootprintse redactaba por completo solo por contener las letraspin/otpen algún punto de su ortografía.isSensitiveFieldNametokeniza 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_idreales, no fabricados:TraceContextMiddlewareprioriza el span activo de OpenTelemetry (trace.getActiveSpan()) cuando@opentelemetry/instrumentation-httpya 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 detraceparent). El filtro de C4 sigue leyendoTraceContextService.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 comoTraceContextServicela 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 dePgInstrumentation(una consulta SQL) habría reportado elspan_iddel 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/createMetricsPipelineregistran ceroSpanProcessor/MetricReader: se siguen generandotraceId/spanIdW3C válidos (para correlación en logs) pero no se intenta ninguna llamada de red. Con la variable presente, se añade/v1/traceso/v1/metricsa la URL configurada y se usa@opentelemetry/exporter-trace-otlp-http/@opentelemetry/exporter-metrics-otlp-http. Ninguna variable se declaró en.railway/railway.tsen este trabajo, según lo pedido. - Propagación W3C sin baggage:
NodeTracerProvider.register({ propagator: new W3CTraceContextPropagator() })registra solo Trace Context, deliberadamente sinW3CBaggagePropagator(que Node registraría por defecto si no se pasarapropagator): 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/PeriodicExportingMetricReaderusanOTEL_BSP_MAX_QUEUE_SIZE(2048),OTEL_BSP_MAX_EXPORT_BATCH_SIZE(512) yOTEL_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 yOTEL_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 dondeOTEL_SHUTDOWN_TIMEOUT_MSsea menor queOTEL_EXPORT_TIMEOUT_MS(mismo mecanismosuperRefineque ya validaOTEL_BSP_MAX_EXPORT_BATCH_SIZEcontraOTEL_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.withBoundedTimeoutya no resuelve en silencio: devuelvecompleted/timed-out/failed, y cada pipeline (tracing-pipeline.ts,metrics-pipeline.ts) reporta conconsole.errorcuando el resultado no escompleted, nombrando el pipeline y el resultado — el mismoconsole.errordeliberado que usareportShutdownFailureensrc/bootstrap/graceful-shutdown.ts, porque a esa altura del apagado el logger de la aplicación puede ya estar cerrado.RedactingSpanExporterenvuelve cualquier exportador (real o de prueba) y redacta atributos de span, atributos de evento, el mensaje de estado (status.message; el código de estado es un enum y no se redacta) y los atributos de cada link antes de reenviarlos, sin mantener buffers propios; cuando el análisis de una query string lanza una excepción, el resultado es el marcador de redacción completo, nunca el valor original sin redactar. - Instrumentación HTTP y PostgreSQL mínima y explícita:
@opentelemetry/instrumentation-httpse usa con su configuración por defecto (no captura cabeceras como atributos de span salvo que se declareheadersToSpanAttributes, que este trabajo no declara).@opentelemetry/instrumentation-pgse configura conenhancedDatabaseReporting: false(nunca adjunta los valores de los parámetros de la consulta, solo el texto SQL) yrequireParentSpan: 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 enmain.ts, que llama astartTelemetry()antes de llamar acreateApplication()(src/bootstrap/create-application.ts), y es esta última función la que importaAppModulede forma dinámica (por tanto antes de la primera carga depg): unimport()dinámico después destartTelemetry()es necesario porque unimportestático de nivel superior, en cualquier módulo alcanzable estáticamente desdemain.ts, se evaluaría antes de que el proceso llegue a esa línea.tooling/repository/check-main-imports.tsrecorre esa cadena de imports estáticos y falla el build si alguno de esos módulos importaAppModuleestáticamente o alcanzahttp/pgcomo especificador desnudo. - Métricas mínimas y de cardinalidad acotada:
HttpMetricsServiceexpone únicamentehttp.server.request.count(contador) yhttp.server.request.duration(histograma en segundos, la unidad que exige la convención semántica estable de OpenTelemetry para ese nombre de instrumento; el registro JSON de logs sigue reportandodurationMspara lectura humana, solo la métrica cambió de unidad), con las etiquetashttp.request.method,http.route(plantilla de Express, ounmatched, nunca la URL cruda con query string),http.response.status_codeyninaku.http.outcome(completed/aborted, ver más abajo; dos valores fijos, sin riesgo de cardinalidad). NingúnrequestId,trace_id,organizationIdu otro identificador de alta cardinalidad se usa como etiqueta. Los buckets explícitos por defecto del SDK (0, 5, 10, ..., 10000) están calibrados para milisegundos; sin ajustarlos, toda petición normal (menor a 5 segundos) colapsaría en el primer bucket una vez la unidad pasa a segundos.createMetricsPipelineregistra unViewsobrehttp.server.request.durationcon 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 yHttpTelemetryFieldsvive encorrelation/http-request-fields.ts(HttpRequestFields), no enlogging/: no tiene nada de específico de logs, y antes obligaba atelemetry/http/http-metrics.service.tsa importar delogging/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 esinfoenproductionydebugen cualquier otroNODE_ENV, cumpliendo que depuración esté apagada en producción por defecto sin impedir activarla explícitamente. - Suprimir, no muestrear, los health checks exitosos:
HttpTelemetryMiddlewareomite el registrohttp.request.completedcuando la ruta empieza por/health, el estado es menor que 400 y la petición terminó normalmente; un health check fallido sí se registra (awarn/errorsegún el estado), porque señala degradación real, y uno abortado por el cliente también se registra siempre, incluso sobre/health, porque una desconexión a mitad de un health check no es el caso rutinario que la supresión existe para silenciar. Las métricas de tráfico igual se registran para toda petición, exitosa, fallida o abortada. - Peticiones abortadas antes de completarse:
HttpTelemetryMiddlewareescuchaba únicamenteresponse.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 escucharesponse.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.outcomedistingue ambos casos con exactamente dos valores (completed/aborted);http.response.status_codeen una petición abortada refleja lo que el objetoResponsetenía en ese instante (a menudo el200por defecto de Express si nunca se llamó ares.status()), por lo que un consumidor no debe interpretar el código de estado como significativo cuandooutcomeesaborted. X-Request-Id, trace id y log lines:test/e2e/logging-correlation.e2e-spec.tsprueba que cada respuesta se corresponde con exactamente una líneahttp.request.completedcon el mismorequestId, y que peticiones concurrentes no mezclanrequestIdnitrace_id;test/e2e/telemetry-correlation.e2e-spec.tsprueba que una petición a/health/readyproduce un span HTTP y un span de PostgreSQL anidado bajo el mismotraceId(evidencia de instrumentación real, no solo un campotraceIddecorativo).
Configuración añadida (src/foundation/config/schema/environment-variables.schema.ts, expuesta por AppConfigService.logging/.telemetry/.serviceIdentity):
| Variable | Tipo/validación | Por defecto |
|---|---|---|
SERVICE_VERSION | cadena opcional, recortada | RAILWAY_GIT_COMMIT_SHA (primeros 12 caracteres) si está presente, si no "unknown" |
RAILWAY_GIT_COMMIT_SHA | cadena opcional, recortada; no aparece en la configuración ya validada, solo alimenta la resolución de SERVICE_VERSION | inyectada por Railway cuando el despliegue viene de un trigger de GitHub |
LOG_LEVEL | debug|info|warn|error|fatal, opcional | según NODE_ENV (ver arriba) |
OTEL_EXPORTER_OTLP_ENDPOINT | URL opcional (cadena vacía tratada como ausente) | deshabilitado |
OTEL_EXPORTER_OTLP_HEADERS | cadena clave=valor separada por comas, opcional | {} |
OTEL_EXPORT_TIMEOUT_MS | entero positivo | 10000 |
OTEL_BSP_MAX_QUEUE_SIZE | entero positivo | 2048 |
OTEL_BSP_MAX_EXPORT_BATCH_SIZE | entero positivo | 512 |
OTEL_BSP_SCHEDULE_DELAY_MS | entero positivo | 5000 |
OTEL_SHUTDOWN_TIMEOUT_MS | entero positivo, debe ser >= OTEL_EXPORT_TIMEOUT_MS | 12000 |
OTEL_TRACES_SAMPLER_RATIO | número entre 0 y 1 | 1 |
OTEL_METRICS_EXPORT_INTERVAL_MS | entero positivo | 10000 |
El propietario ya eligió el destino de exportación: Grafana Cloud, contra su gateway OTLP en la región prod-sa-east-1. El servicio api de staging lo tiene configurado mediante OTEL_EXPORTER_OTLP_ENDPOINT (la URL base termina en /otlp, porque el código le añade /v1/traces y /v1/metrics) y OTEL_EXPORTER_OTLP_HEADERS.
Advertencia operativa: OTEL_EXPORTER_OTLP_HEADERS debe llevar el par completo Authorization=Basic <base64>, no solo el valor base64. parseOtlpHeaders (telemetry-configuration.ts) exige que cada entrada separada por comas tenga la forma nombre=valor; una entrada sin =, sin nombre o con el valor vacío hace fallar el arranque con un error que nombra OTEL_EXPORTER_OTLP_HEADERS, en vez de producir cabeceras vacías y dejar el exportador enviando peticiones sin autenticar hasta que el destino las rechace con un 401 silencioso. El valor de la cabecera está registrado como secreto conocido y se redacta de logs, spans y salida exportada.
Evidencia verificada en staging: las trazas llegan con spans hijos reales, no decorativos. Una traza de una petición muestra el span de servidor HTTP junto con los segmentos pg.query y pg-pool.connect anidados bajo el mismo traceId, que es justamente lo que la matriz de aceptación de la sección 8 exige como segmentos útiles.
Muestreo configurable, sin cambiar el comportamiento por defecto: OTEL_TRACES_SAMPLER_RATIO (0 a 1, por defecto 1) alimenta TraceIdRatioBasedSampler en tracing-pipeline.ts; con el valor por defecto, createTracingPipeline sigue usando AlwaysOnSampler explícitamente (equivalente a un ratio de 1, sin depender de que el hash del trace id coincida) para que ningún despliegue existente cambie de comportamiento hasta que alguien reduzca el ratio deliberadamente. La decisión de qué ratio usar en producción — y su relación con el volumen y costo de Grafana Cloud — sigue sin tomarse por este trabajo; solo el mecanismo para tomarla sin un cambio de código queda resuelto.
Cadencia de métricas independiente del batching de trazas: metrics-pipeline.ts calculaba antes su intervalo de exportación como max(OTEL_BSP_SCHEDULE_DELAY_MS, OTEL_EXPORT_TIMEOUT_MS), una variable explícitamente scoped al BatchSpanProcessor de trazas en su propio nombre. Ajustar el batching de trazas estiraba en silencio la cadencia de métricas. OTEL_METRICS_EXPORT_INTERVAL_MS (por defecto 10000, el mismo valor efectivo que el cálculo anterior producía con los valores por defecto de trazas) le da a métricas su propia variable; PeriodicExportingMetricReader ya no lee nada de la configuración de BatchSpanProcessor.
Sigue abierto, sin resolver por este trabajo: la retención, los permisos de acceso y el presupuesto de volumen/costo de logs y trazas por entorno. La cuenta de Grafana Cloud está en plan de prueba (trial); estos puntos deben revisarse en cuanto exista volumen real, no antes.
Resolución de SERVICE_VERSION: el esquema de entorno (environment-variables.schema.ts) implementa una cadena de resolución con tres pasos, evaluada por el propio esquema Zod antes de exponer la configuración validada: un SERVICE_VERSION explícito gana siempre; si no está definido, se usa RAILWAY_GIT_COMMIT_SHA truncado a sus primeros 12 caracteres; si tampoco existe, el valor es "unknown". RAILWAY_GIT_COMMIT_SHA se lee de process.env como cualquier otra variable (nunca se asume presente; no aparece en las variables del servicio en Railway del mismo modo que tampoco aparece PORT, aunque ambas existen en tiempo de ejecución) y nunca se expone como campo propio en la configuración ya validada, porque solo alimenta esta resolución. El resultado final es siempre una cadena no vacía y obligatoria: nada aguas abajo necesita manejar undefined. Railway solo inyecta RAILWAY_GIT_COMMIT_SHA cuando el despliegue se originó en un trigger de GitHub; un redeploy manual o disparado desde la CLI de Railway no la define, así que un build puede seguir reportando "unknown" sin que eso sea un error de esta resolución. Los 12 caracteres son suficientes para distinguir cualquier commit real de este repositorio sin necesidad del SHA completo de 40 caracteres en cada línea de log o atributo de span. Cobertura: environment-variables.schema.spec.ts prueba las tres ramas, incluyendo que un SERVICE_VERSION explícito gana sobre RAILWAY_GIT_COMMIT_SHA cuando ambos están definidos.
8. C6 — OpenAPI y verificación automática
Usar @nestjs/swagger para producir el contrato de las rutas realmente implementadas y los esquemas comunes. No generar CRUD ni endpoints ficticios para llenar Swagger.
Documentar requests, respuestas, headers, códigos, ejemplos, cursores, filtros, sorts, límites, autenticación/precondiciones e idempotencia cuando corresponda. Incluir las excepciones operativas al wrapper.
CI debe generar/validar OpenAPI y verificar compatibilidad del contrato. Un esquema/decorador no demuestra por sí solo que el runtime responde igual: se necesitan pruebas HTTP contra las formas documentadas.
Los clientes no deben depender de mensajes traducidos ni de propiedades internas. Los cambios de nombres, tipos, obligatoriedad, enums, paginación o semántica necesitan evaluación explícita de compatibilidad. No declarar que cualquier adición es siempre compatible.
Matriz mínima de aceptación
| Prueba | Resultado exigido |
|---|---|
| Éxitos | Envoltorio único, tipos correctos, headers y excepciones sin doble wrapping |
| Errores | Estructura y content type comunes para fallos controlados; status coherente |
| Cursores | Rechazo de alteración, expiración e incompatibilidad; límite, fin y colección vacía coherentes |
| Autorización paginada | Un cursor no permite acceder a otro tenant ni conservar permisos revocados |
| Concurrencia de contexto | No se mezclan requestId, tenant o contexto de trazas entre peticiones |
| Protección de datos | Secretos de prueba no aparecen en errores públicos, logs, trazas ni salida exportada |
| Correlación | El identificador de respuesta localiza logs y ejecución instrumentada |
| Instrumentación | No se duplican logs/spans; existen segmentos útiles, no solo IDs decorativos |
| Destino caído | API estable con memoria/colas acotadas, pérdida técnica controlada y shutdown verificado |
| OpenAPI | Esquemas, ejemplos y respuestas reales concuerdan; cambios incompatibles detectados |
Cuando una familia de endpoints aún no existe, usar fixtures de prueba no expuestos en producción para probar el mecanismo, y exigir la misma prueba al implementar la primera ruta real. No anunciar autenticación, auditoría de negocio o integraciones futuras como terminadas por probar un fixture.
8.1 Mecanismo implementado y decisiones que la especificación dejaba abiertas
tooling/openapi implementa la generación, verificación y detección de compatibilidad; src/foundation/health/health.controller.ts lleva los únicos decoradores @nestjs/swagger de una ruta real. zod-json-schema.ts envuelve z.toJSONSchema() de Zod 4; shared-components.ts deriva PageInfo, ProblemDetails y ProblemFieldError de sus esquemas Zod y los parámetros limit/after de collection-query.schema.ts (C2), y describe Envelope y el header X-Request-Id a mano por las razones que siguen. openapi-document.ts combina SwaggerModule.createDocument() con esos componentes compartidos. schema-compatibility.ts, response-compatibility.ts, parameter-compatibility.ts y openapi-compatibility.ts implementan el clasificador de compatibilidad. generate-openapi-document.ts/check-openapi-document.ts son los scripts openapi:generate/openapi:check; docs/generated/openapi.json es el artefacto versionado.
Decisiones que C6 resuelve al implementar (la especificación las dejaba abiertas):
- Destino de conversión de Zod a JSON Schema:
target: 'openapi-3.0',unrepresentable: 'throw',io: 'output'.openapi-3.0es el único destino que produce el dialecto que@nestjs/swaggerya emite (nullable: trueen vez detype: [T, 'null'],exclusiveMinimumbooleano en vez de numérico); mezclar dialectos habría producido un documento inconsistente.unrepresentable: 'throw'cumple el mandato de "fallar en voz alta": un esquema Zod sin equivalente (por ejemploz.bigint()oz.date()) rompe la generación en vez de emitir{}silenciosamente.io: 'output'documenta el valor ya validado/coaccionado (por ejemplolimitcomointeger, no como el string crudo de la query string), que es lo que un cliente necesita conocer para construir una petición válida. PageInfo,ProblemDetailsyProblemFieldErrordejan de ser interfaces escritas a mano: ahora sonz.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).Envelopees 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 fijarTData, porque cada ruta real tendría su propiodata.tooling/openapi/shared-components.tsdescribe a mano únicamente la forma del sobre (datapresente,meta.pageInfoopcional referenciandoPageInfo), no duplica ningún esquema Zod existente porque no hay ninguno del que derivar.- Los componentes compartidos se documentan aunque ninguna ruta real los use todavía:
/health/livey/health/readyson excepciones documentadas al sobre y a Problem Details (usanProtocolExceptionFilter, sección 3), así que hoy ninguna ruta real referenciaEnvelopeoProblemDetails. Se agregan igualmente acomponents.schemas/components.parameters/components.headersdespués deSwaggerModule.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 entooling/openapi/openapi-document.spec.ts). X-Request-Idsí se documenta en una respuesta real: a diferencia deEnvelope/ProblemDetails, el middleware de C1 pone ese header en toda respuesta, incluida/health/livey/health/ready;health.controller.tsdeclara@ApiOkResponse/@ApiServiceUnavailableResponsecon 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 propioschema/description, así que@nestjs/swaggerfusiona el header con esa entrada en vez de sobrescribirla.- Los cuerpos 200/503 de salud no derivan de Zod:
@nestjs/terminusdetecta@nestjs/swaggeren tiempo de decoración (HealthCheck({ swaggerDocumentation: true })es el valor por defecto) y adjunta su propiogetHealthCheckSchema(); 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/swaggercomo 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 usaSwaggerModule.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 decoradoresexperimentalDecorators, así quenode tooling/openapi/generate-openapi-document.tsno puede importarsrc/app/app.module.tsdirectamente (los decoradores de Nest revientan como error de sintaxis).openapi:generate/openapi:checkejecutan primeronest buildy luego importandist/app/app.module.jsya compilado. Para no requerir Postgres en ese paso,DatabaseRuntimeRoleGuardService(que sí consulta la base de datos real enonModuleInit()) se sustituye víaTest.createTestingModule(...).overrideProvider(...)por un valor inerte;DatabasePoolServiceno necesita sustituirse porque su constructor solo crea elpg.Pool, sin conectar.openapi-generation-environment.tsrellenaDATABASE_*/CURSOR_SIGNING_KEYcon valores ficticios únicamente si el entorno no los define ya, para que ambos scripts funcionen sin configuración local ni en CI. - La versión del documento es del contrato, no de la aplicación:
OPENAPI_DOCUMENT_VERSION(tooling/openapi/openapi-document.ts) es1.0.0, independiente depackage.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:checkes una comparación exacta, no una re-generación silenciosa: regenera el documento y lo compara byte a byte (trasserializeOpenApiDocument(), que ordena claves recursivamente para que el orden de inserción deObject.keys()nunca produzca una diferencia espuria) contradocs/generated/openapi.json; si difieren, falla indicandonpm run openapi:generate. No reescribe el archivo por sí solo.- El clasificador de compatibilidad cubre request body, nullable, uniones y headers de respuesta, no solo parámetros y cuerpos de respuesta:
classifyOpenApiChanges()recorrecompareParameters,compareRequestBody(request-body-compatibility.ts) ycompareResponsespor cada operación.compareRequestBodyresuelve$refcontracomponents.requestBodies, clasifica el propiorequestBody(ausente → presente, presente → ausente,requiredpasando defalse/ausente atrue) y reutilizacompareSchemasobre cadacontent[mediaType].schema, así que quitar o volver obligatoria una propiedad del cuerpo de una petición se clasifica igual que en una respuesta.compareSchemaañadecompareNullable(unnullable: trueque desaparece esbreaking, que aparece esadditive) ycompareSchemaMemberssobreoneOf/anyOf/allOf(identifica cada miembro por su$refo, si es un esquema inline, por su forma resuelta serializada; un miembro que desaparece esbreaking, uno nuevo esadditive).compareResponsesañadecompareResponseHeaders, que resuelve$refcontracomponents.headersy clasifica headers removidos, agregados (breakingsi nacerequired: true,additivesi no) y su paso de opcional a obligatorio, además de difundir suschemaporcompareSchema. Categorías nuevas:nullable_removed/nullable_added,union_member_removed/union_member_added,request_body_removed/request_body_added/request_body_became_required,header_removed/header_added/header_became_required, sumadas a las ya existentes (path_*,operation_*,response_code_*,property_*,type_*,enum_value_*,parameter_*,media_type_*). El límite conocido se mantiene acotado: una propiedad nueva y ya obligatoria en el mismo cambio (en un request body o en una respuesta) se sigue reportando comoproperty_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 siemprebreakinges que el cuerpo completo o un header completo pasen de opcional a obligatorio (request_body_became_required/header_became_required), porque ahí sí hay una única interpretación posible. - CI verifica drift y compatibilidad por separado:
npm run openapi:check(drift byte a byte contradocs/generated/openapi.json) sigue en el jobpre-foundation-integrityde.github/workflows/repository.yml, junto alint:no-ternaryypre-foundation:check; al ejecutarnest buildinternamente no necesita un paso de build ni una base de datos separados en ese job. La verificación de compatibilidad del contrato (que section 8 exige y que antes no existía) vive en un job separado,openapi-compatibility, con su propionpm run openapi:compat(tooling/openapi/check-openapi-compatibility.ts); no necesitanest buildni base de datos porque compara dos documentos ya serializados, nunca los regenera. - La base de la comparación de compatibilidad es un
git show, no un segundodocs/generated/openapi.jsonen disco:check-openapi-compatibility.tsresuelve la referencia base conresolveBaseRef(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, luegoorigin/stagingpor defecto) y ejecutagit show <ref>:docs/generated/openapi.json(loadBaselineDocument) para obtener el documento base sin necesitar un segundo checkout ni un archivo aparte. En el jobopenapi-compatibility,OPENAPI_COMPAT_BASE_REFse fija aorigin/${{ github.base_ref }}en unpull_request(la rama destino real de la PR) y aorigin/stagingen cualquier otro evento (pushamain/staging,workflow_dispatch); el checkout de ese job pidefetch-depth: 0porque el checkout por defecto deactions/checkout@v7(usado sin opciones enpre-foundation-integrity) es superficial y no trae las ramas remotas quegit show origin/<rama>:...necesita resolver. - Documento base ausente vs. evaluación fallida son resultados distintos, nunca el mismo mensaje: si
git showfalla porque la ruta no existe en una referencia que sí resuelve (fatal: path '...' does not exist in '...'oexists on disk, but not in),loadBaselineDocumentlo trata comomissing— exactamente lo que pasa hoy contraorigin/staging, que todavía no tienedocs/generated/openapi.jsonporque esta cadena de cambios lo introduce por primera vez — y el script termina en éxito (exit 0) imprimiendo que no hay base contra la cual comparar. Cualquier otra falla (la referencia no resuelve, el JSON base o el actual no parsean) se clasifica comoerror, termina enexit 1y nunca se confunde con unpass: son tres mensajes distintos (missing,error, el resumen de cambios) para tres causas distintas. - Bump del major del contrato es la forma explícita de aceptar un cambio incompatible:
evaluateOpenApiCompatibility()(openapi-compatibility-policy.ts) filtra los cambios declassifyOpenApiChanges()enbreakingChanges/additiveChanges. Sin cambiosbreaking, el resultado escompatibley pasa siempre, haya o no cambios aditivos. Con al menos un cambiobreaking, compara el componente major deinfo.versiondel documento actual contra el del documento base (OPENAPI_DOCUMENT_VERSIONentooling/openapi/openapi-document.ts): si el major subió, el resultado esbreaking_acceptedy pasa (exit 0) imprimiendo qué cambios se aceptaron y por qué; si no subió, esbreaking_blockedy falla (exit 1) listando cada cambiobreakingcon su categoría y ruta. Esto es exactamente la decisión que la sección 8.1 original dejaba pendiente ("cambiar la versión del contrato es una decisión explícita sobre compatibilidad"): ahora CI la hace cumplir en vez de solo documentarla.
Matriz mínima de aceptación (sección 8) y su evidencia:
| Prueba | Evidencia | Nota |
|---|---|---|
| Éxitos | src/foundation/http/envelope/http-envelope.interceptor.spec.ts, test/e2e/http-envelope.e2e-spec.ts (C1); test/e2e/openapi-contract.e2e-spec.ts (C6) | C6 prueba que la respuesta real de /health/live//health/ready concuerda con su esquema documentado; no repite la prueba del sobre { data }, que sigue siendo de C1. |
| Errores | test/e2e/problem-details.e2e-spec.ts, src/foundation/errors/problem-details/problem-details.section6-conformance.spec.ts (C4) | ProblemDetails/ProblemFieldError se documentan como componentes Zod-derivados (tooling/openapi/shared-components.spec.ts), pero ninguna ruta real los produce hoy porque salud usa ProtocolExceptionFilter. Sin evidencia runtime C6 adicional hasta que exista una ruta real que use el wrapper de errores. |
| Cursores | src/foundation/pagination/cursor/cursor-codec.spec.ts, src/foundation/pagination/cursor/cursor.exceptions.spec.ts, test/e2e/collections.e2e-spec.ts (C2) | C6 documenta limit/after como parámetros compartidos derivados de los mismos esquemas Zod que C2 ya prueba (tooling/openapi/shared-components.spec.ts); no duplica la prueba de rechazo/expiración del cursor. |
| Autorización paginada | test/e2e/collections.e2e-spec.ts (C2) | Sin relación directa con C6; no se agrega evidencia nueva. |
| Concurrencia de contexto | test/e2e/logging-correlation.e2e-spec.ts, test/e2e/telemetry-correlation.e2e-spec.ts (C5) | Sin relación directa con C6; no se agrega evidencia nueva. |
| Protección de datos | src/foundation/logging/redaction/redaction.spec.ts, src/foundation/logging/redaction/known-secrets.registry.service.spec.ts (C5) | Sin relación directa con C6; no se agrega evidencia nueva. |
| Correlación | test/e2e/logging-correlation.e2e-spec.ts (C5); test/e2e/openapi-contract.e2e-spec.ts (C6) | C6 añade la prueba de que X-Request-Id está documentado en el componente compartido y presente en la respuesta real de salud. |
| Instrumentación | test/e2e/telemetry-correlation.e2e-spec.ts (C5) | Sin relación directa con C6; no se agrega evidencia nueva. |
| Destino caído | src/foundation/telemetry/telemetry-bootstrap.spec.ts, src/foundation/telemetry/pipelines/redacting-span-exporter.spec.ts (C5) | Sin relación directa con C6; no se agrega evidencia nueva. |
| OpenAPI | tooling/openapi/*.spec.ts (derivación de esquemas y clasificador de compatibilidad); test/e2e/openapi-contract.e2e-spec.ts (esquemas y respuestas reales concuerdan, fixture de C2 ausente); docs/generated/openapi.json + openapi:check (drift detectado en CI); openapi:compat (compatibilidad de contrato evaluada contra un baseline en CI, sección 8.1) | Fila propia de C6; combina drift, compatibilidad de contrato y una prueba HTTP contra /health, no una cobertura completa de extremo a extremo: no ejercita autenticación, autorización de negocio ni datos reales, que siguen bajo los módulos que los implementen. |
9. Slice D — Validación operativa y rendimiento
D valida la base A/B/C disponible con k6 y evidencia reproducible en staging. Debe registrar commit, configuración del entorno, dataset, carga/concurrencia, duración, thresholds, resultados y limitaciones.
Medir latencias, errores, saturación/espera del pool, memoria y costo/volumen de telemetría. Comparar la instrumentación con una referencia controlada cuando se evalúe su overhead. Los umbrales se fijan antes de ejecutar y se versionan; no se presentan cifras sin medición como capacidad garantizada.
Una prueba de health checks solo demuestra el comportamiento de esas rutas, no capacidad para cientos de restaurantes. Las pruebas de Foundation incluyen caminos técnicos acotados y se amplían con ventas/pagos/inventario reales al existir esos módulos. No cargar producción ni datos de clientes para demostrar Foundation.
Resultado: informe y scripts reproducibles, no una calificación arbitraria. Las regresiones de correctness/aislamiento no se aceptan para mejorar throughput.
10. Cierre, entregas y responsabilidades futuras
C1–C6 se cierran con especificación, implementación reutilizable, pruebas y evidencia. El issue #13 conserva el seguimiento; esta documentación no marca los checkboxes de implementación.
Antes del primer módulo real, C debe tener contrato único HTTP/paginación/errores, logging con protección de datos, correlación real y OpenAPI verificable. D debe aportar una línea base reproducible. La elección de proveedor y los parámetros que este documento deja para C2/C5 deben resolverse en esas entregas, sin trasladar decisiones transversales a veinte módulos.
Cada módulo futuro aporta reglas de negocio, consultas e índices, autorización y auditoría propias. Reutiliza la frontera HTTP, contexto, errores, paginación y logging; no conoce detalles internos del proveedor de observabilidad.
No se agregan tablas, repositorios universales, wrappers distintos por vertical ni dependencias especulativas para que Foundation parezca completa. Idempotencia durable, outbox, ejecución de jobs y políticas específicas de dominio siguen sus contratos y pruebas propias; describir sus headers/eventos no los implementa.
Referencias de diseño
Estas referencias identifican los estándares y guías elegidos; las convenciones particulares de Ninaku están declaradas arriba.