Skip to content

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

SliceResponsabilidadResultado verificable
A — Arranque y conexiónConfiguración tipada, pool PostgreSQL con ninaku_runtime, verificación del rol y health checksArranque controlado, rechazo de configuración insegura y conexión comprobable
B — Contexto y transaccionesContexto por petición con AsyncLocalStorage, transacciones por caso de uso y contexto autorizado para RLSNo se mezclan tenants/peticiones; commit/rollback y aislamiento se prueban con PostgreSQL real
C — Contrato HTTP y observabilidadRequests, éxitos, errores, colecciones, validación, logs, trazas, métricas básicas y OpenAPILos módulos reutilizan contratos y mecanismos verificables
D — Validación operativa y rendimientok6, pruebas bajo carga y medición del costo de la instrumentaciónResultados 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-4ec8d344e9a6
json
{
  "customerId": "81c7dd6c-406c-4280-a839-64ca0d219a43",
  "items": [
    {
      "productId": "d5765916-adfe-4c39-b0a7-b07f4a41055b",
      "quantity": "2.000"
    }
  ]
}

Éxito JSON

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

http
HTTP/1.1 201 Created
Content-Type: application/json
Location: /api/v1/sales/orders/84e7252c-c8bc-4cda-8e77-945818caa260
X-Request-Id: 01994821-16a0-784e-8b2c-4ec8d344e9a6
json
{
  "data": {
    "orderId": "84e7252c-c8bc-4cda-8e77-945818caa260",
    "status": "draft",
    "version": 1,
    "total": { "amount": "24.50", "currency": "USD" }
  }
}

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

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

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

Representación de datos

DatoContrato
Nombres JSONcamelCase
IdentificadoresStrings con formato declarado en el esquema
Importes y cantidades decimales exactasStrings con precisión/escala documentadas; moneda o unidad cuando corresponda
Contadores, versiones y límites enterosNúmeros enteros dentro de los rangos documentados
InstantesISO 8601 en UTC
Fechas de negocioFecha sin conversión implícita a instante; zona horaria explícita cuando interviene en el caso de uso
Campo ausente y nullSemántica declarada por contrato; no son equivalentes automáticamente
EnumsValores 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

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

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

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

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

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

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

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

3.2 Prefijo global de API y versionamiento URI

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

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

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

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

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

4. C2 — Colecciones, cursores y consultas

Forma única

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

http
GET /api/v1/sales/orders?limit=50&status=draft&sort=-createdAt
json
{
  "data": [
    {
      "orderId": "84e7252c-c8bc-4cda-8e77-945818caa260",
      "status": "draft"
    }
  ],
  "meta": {
    "pageInfo": {
      "hasNextPage": true,
      "nextCursor": "opaque-cursor"
    }
  }
}
http
GET /api/v1/sales/orders?limit=50&status=draft&sort=-createdAt&after=opaque-cursor

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

Reglas de paginación

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

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

Consistencia y ownership

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

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

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

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

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

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

  • Mecanismo de protección: HMAC-SHA256 (node:crypto, sin dependencia nueva) sobre un token con cuatro segmentos separados por .: v1.<keyId>.<payload-base64url>.<firma-base64url>. El payload (JSON antes de codificar) contiene exp, binding, sortValues e id; nunca la clave de firma ni datos de negocio adicionales. Codificar en base64url no sustituye la firma: la firma cubre versión, keyId y payload completos, así que cualquier alteración de cualquiera de los tres se detecta como collection.cursor_tampered. El cursor está firmado, no cifrado: HMAC garantiza que el payload no fue alterado, pero cualquiera puede decodificar el base64url y leer binding, sortValues e id en texto claro. Es opaco para el cliente por convención de contrato, no confidencial por construcción. Un módulo no debe colocar valores sensibles en sortValues (por ejemplo un correo, un monto o un identificador que no deba exponerse) confiando en que nadie puede leerlos.

  • Formato e id de clave: keyId son los primeros 16 caracteres hexadecimales de SHA-256(CURSOR_SIGNING_KEY), derivados en el momento, no configurados por separado. Un cursor cuyo keyId no coincide con el de la clave activa se rechaza como collection.cursor_invalid (no se distingue de una clave desconocida, porque el servidor solo mantiene la clave activa).

  • Rotación: fijar un nuevo valor de CURSOR_SIGNING_KEY y desplegar. Como el keyId se deriva de la clave, todos los cursores previos cambian de keyId automáticamente y la siguiente petición que los reutilice falla con collection.cursor_invalid; el cliente reinicia la paginación desde la primera página. No existe una ventana de verificación con dos claves simultáneas en C2: el radio de impacto de una rotación queda acotado por la expiración corta del cursor (ver debajo), no por infraestructura de rotación adicional. Una necesidad futura de rotación sin cortes requeriría una decisión explícita y documentada aparte.

  • Expiración: 900 segundos (15 minutos) por defecto, ajustable por llamada a través de EncodeCursorInput.ttlSeconds cuando un caso de uso lo justifique, hasta un máximo de 86400 segundos (24 horas, CURSOR_MAX_TTL_SECONDS). encodeCursor exige un entero positivo dentro de ese rango y lanza una excepción inmediatamente si no lo es (Infinity, NaN, cero, negativo, no entero o por encima del máximo), en vez de aceptarlo y producir silenciosamente un cursor que nunca expira o que ya nació vencido. EncodeCursorInput.ttlSeconds es un parámetro interno (nunca proviene directamente de un cliente HTTP), pero la validación falla igual de fuerte que en una frontera pública. La paginación es una vista viva (sección "Consistencia y ownership"); la expiración limita cuánto tiempo un cursor sigue siendo válido, no ofrece ni promete un snapshot.

  • Tamaño máximo: 2048 bytes por token (CURSOR_MAX_TOKEN_LENGTH), verificado antes de intentar decodificar. Un cursor real con un único valor de orden e id UUID mide varios cientos de bytes; el límite es un margen defensivo contra abuso, no una expectativa de uso normal. encodeCursor verifica el mismo límite y el mismo esquema (CursorPayloadSchema, que exige binding/id/cada sortValues no vacíos) antes de firmar, no solo decodeCursor al verificar: Foundation nunca debe poder emitir un cursor que ella misma rechazaría en la siguiente petición. Decodificar tolera la entrada del cliente (un JSON no parseable o un payload inválido son entrada incorrecta, no una excepción del servidor); codificar es estricto porque el payload lo construye Foundation misma, así que cualquier violación ahí es un bug propio y debe fallar en voz alta.

  • Códigos de error: cuatro códigos estables, cada uno un 400 Bad Request con errors: [{ location: 'query', field: 'after', code, message }], sin ningún detalle interno del HMAC o del payload:

    CódigoMotivo
    collection.cursor_invalidToken mal formado, versión no soportada o keyId no reconocido
    collection.cursor_tamperedLa firma no coincide con el payload
    collection.cursor_expiredexp ya pasó
    collection.cursor_incompatibleEl binding del cursor no coincide con el de la petición actual

    Estos códigos se producen mediante HttpException con code propio (el mecanismo que la sección 6.1 ya documenta para C4). Los cuatro están registrados en ERROR_CODE/ERROR_CODE_STATUS_REGISTRY (estado 400, retryable: false) y en PROBLEM_TYPE_REGISTRY, pero se excluyen deliberadamente del mapa inverso estado→código (HTTP_STATUS_ERROR_CODE, derivado por buildHttpStatusErrorCode() en problem-details.builder.ts): así validation.failed sigue siendo el código de respaldo para cualquier HttpException con estado 400 no mapeado explícitamente, y solo el code propio de cada excepción distingue el motivo concreto del cursor.

  • Binding: computeCursorBinding() recibe un registro plano de valores (identidad de ruta/colección, filtros validados, selección de orden y contexto de tenant autorizado) y devuelve SHA-256 de su forma canónica (claves ordenadas, valores codificados con JSON.stringify para no confundir null con la cadena "null"). El binding no es secreto — viaja dentro del payload firmado — pero nunca se expone en texto claro para no publicar la forma interna del contrato; su único uso es comparar igualdad. Cambiar filtro, orden o contexto de tenant cambia el binding, así que el cursor se rechaza como collection.cursor_incompatible en vez de aplicarse silenciosamente a la nueva consulta. Los valores numéricos deben ser finitos: NaN e Infinity se rechazan en voz alta, porque JSON.stringify colapsa ambos a null y haría que un filtro numérico inválido comparta binding con un filtro null real.

  • Alcance del helper de keyset: buildKeysetPredicate()/sliceKeysetPage() cubren la forma que la sección "Reglas de paginación" ilustra: una columna de orden más un id de desempate, ambos con la misma dirección, columnas NOT NULL. No es un motor de filtros: los nombres de columna los declara el módulo (verificados contra un patrón de identificador seguro), no el cliente. Un orden compuesto por más de una columna de negocio, o un desempate sobre una columna nullable, queda fuera de C2 y requiere una extensión explícita cuando un módulo real lo necesite. buildKeysetPredicate() exige paramIndexStart >= 1 (los parámetros de PostgreSQL empiezan en $1, nunca $0) y sliceKeysetPage() exige un limit entero positivo; ambas primitivas fallan en voz alta con el mismo criterio que ya protege los nombres de columna, en vez de generar un placeholder inválido o recortar la última fila silenciosamente.

  • Validación de consulta: createLimitSchema() (limit, entero 1-100, por defecto 50), AfterCursorSchema (after, cadena no vacía acotada a 2048 caracteres) y createSortQuerySchema(camposPermitidos) (sort, solo campo/-campo de una lista declarada por endpoint) son primitivas C3 componibles; cada endpoint las combina con sus propios filtros bajo .strict(), igual que identifier.schema.ts/decimal.schema.ts se combinan en un esquema de negocio. Ningún esquema de C2 acepta nombres de columna, SQL o expresiones arbitrarias.

  • Evidencia: src/foundation/pagination/**/*.spec.ts prueba el códec, los códigos de error y la validación de consulta de forma aislada; keyset-pagination.integration-spec.ts pagina reference.nutrients (schema reference, legible por ninaku_runtime según database/access/runtime_grants.sql) con PostgreSQL real; test/e2e/collections.e2e-spec.ts prueba el contrato HTTP completo — múltiples páginas, límite por defecto y límites, colección vacía, y cursor inválido/alterado/vencido/incompatible-por-filtro/incompatible-por-tenant — sobre un endpoint de fixture (test/fixtures/collections) que nunca se registra en AppModule.

5. C3 — Validación de entrada

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

Cada contrato define:

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

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

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

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

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

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

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

6. C4 — Errores y traducción segura

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

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

Reglas:

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

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

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

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

CódigoEstadoretryable
validation.failed400false
auth.unauthenticated401false
auth.unauthorized403false
resource.not_found404false
conflict.business409false
precondition.failed412false
payload.too_large413false
media_type.unsupported415false
content.unprocessable422false
precondition.required428false
rate_limit.exceeded429true
service.unavailable503true
tenant.context_rejected403false
error.unexpected500false

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

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

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

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

7.1 Un límite transversal, no un framework propio

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

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

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

7.2 Identificadores distintos

IdentificadorFinalidad
requestIdUn intento HTTP; disponible mediante X-Request-Id en respuestas controladas
traceIdCorrelacionar segmentos instrumentados de una ejecución
commandIdIntenció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ñalPropósitoDestino/ownership
Logs técnicosDiagnóstico de peticiones, fallos e hitosSalida del proceso y recolección central, fuera de la base transaccional
TrazasSegmentos HTTP, espera de pool, SQL y llamadas externas instrumentadasExportación OpenTelemetry configurable
MétricasTráfico, latencia, errores y saturaciónBackend de métricas configurado, con dimensiones acotadas
AuditoríaEvidencia de negocio y seguridadMecanismo 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

NivelUso
debugDiagnóstico temporal y controlado; apagado en producción por defecto
infoResultado normal e hitos útiles
warnDegradación o situación que necesita seguimiento
errorFallo técnico inesperado o de operación
fatalEl proceso no puede continuar con seguridad

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

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

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

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

7.7 Exportación y fallos

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

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

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

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

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

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

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

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

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

VariableTipo/validaciónPor defecto
SERVICE_VERSIONcadena opcional, recortadaRAILWAY_GIT_COMMIT_SHA (primeros 12 caracteres) si está presente, si no "unknown"
RAILWAY_GIT_COMMIT_SHAcadena opcional, recortada; no aparece en la configuración ya validada, solo alimenta la resolución de SERVICE_VERSIONinyectada por Railway cuando el despliegue viene de un trigger de GitHub
LOG_LEVELdebug|info|warn|error|fatal, opcionalsegún NODE_ENV (ver arriba)
OTEL_EXPORTER_OTLP_ENDPOINTURL opcional (cadena vacía tratada como ausente)deshabilitado
OTEL_EXPORTER_OTLP_HEADERScadena clave=valor separada por comas, opcional{}
OTEL_EXPORT_TIMEOUT_MSentero positivo10000
OTEL_BSP_MAX_QUEUE_SIZEentero positivo2048
OTEL_BSP_MAX_EXPORT_BATCH_SIZEentero positivo512
OTEL_BSP_SCHEDULE_DELAY_MSentero positivo5000
OTEL_SHUTDOWN_TIMEOUT_MSentero positivo, debe ser >= OTEL_EXPORT_TIMEOUT_MS12000
OTEL_TRACES_SAMPLER_RATIOnúmero entre 0 y 11
OTEL_METRICS_EXPORT_INTERVAL_MSentero positivo10000

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

PruebaResultado exigido
ÉxitosEnvoltorio único, tipos correctos, headers y excepciones sin doble wrapping
ErroresEstructura y content type comunes para fallos controlados; status coherente
CursoresRechazo de alteración, expiración e incompatibilidad; límite, fin y colección vacía coherentes
Autorización paginadaUn cursor no permite acceder a otro tenant ni conservar permisos revocados
Concurrencia de contextoNo se mezclan requestId, tenant o contexto de trazas entre peticiones
Protección de datosSecretos de prueba no aparecen en errores públicos, logs, trazas ni salida exportada
CorrelaciónEl identificador de respuesta localiza logs y ejecución instrumentada
InstrumentaciónNo se duplican logs/spans; existen segmentos útiles, no solo IDs decorativos
Destino caídoAPI estable con memoria/colas acotadas, pérdida técnica controlada y shutdown verificado
OpenAPIEsquemas, ejemplos y respuestas reales concuerdan; cambios incompatibles detectados

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

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

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

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

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

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

PruebaEvidenciaNota
Éxitossrc/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.
Errorestest/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.
Cursoressrc/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 paginadatest/e2e/collections.e2e-spec.ts (C2)Sin relación directa con C6; no se agrega evidencia nueva.
Concurrencia de contextotest/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 datossrc/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óntest/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óntest/e2e/telemetry-correlation.e2e-spec.ts (C5)Sin relación directa con C6; no se agrega evidencia nueva.
Destino caídosrc/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.
OpenAPItooling/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.

Application Foundation in progress. Tracked in issue #13.