Decisiones de arquitectura

ADR-0008: Arquitectura de errores

Estado: Aceptado · Fecha: 2026-09-25

Contexto

Un LIS se pasa la vida negándose: prueba sin consentimiento, muestra sin alícuota, transición fuera de la matriz, crítico sin confirmar, lote vencido, unidad que no corresponde. La mayoría de las reglas del marco conceptual son negativas, así que el contrato de errores no es el envoltorio del sistema: es la superficie por la que las reglas clínicas hablan con el exterior. Los 22 slices de M1 lo usan todos.

Hoy no existe. PLAN.md lo declara como deuda desde M0: el 404 devuelve el cuerpo por defecto de Spring y el 401 no trae cuerpo. Pero el problema de fondo es peor y está en la base.

El esquema rechaza 22 veces, y todos los rechazos se parecen

V1__esquema_base.sql contiene 22 raise exception repartidos en 12 funciones de trigger. Sus SQLSTATE son estándar, y ahí está el problema:

SQLSTATE Rechazos de regla Restricciones estructurales que usan el mismo código
23514 check_violation 16 144 CHECK
23505 unique_violation 2 56 UNIQUE
55000 object_not_in_prerequisite_state 3 —
22023 invalid_parameter_value 1 —

Dieciséis reglas clínicas y ciento cuarenta y cuatro restricciones estructurales comparten el mismo código. La consecuencia no es solo que no se distinga una regla de otra: no se distingue un rechazo de negocio de un error de programación. «Esta transición de orden exige motivo» y «se insertó nulo en una columna obligatoria» llegan a Java como la misma DataIntegrityViolationException. Una es operación normal que el usuario debe corregir; la otra es un defecto que debe despertar a alguien.

Lo único que hoy las separa es una cadena de prosa en español. Y ni eso es fiable: cinco de los 22 mensajes se construyen con format(), así que llevan datos interpolados y ni la comparación exacta funciona.

Catálogo completo de los 22 rechazos

Extraído de V1__esquema_base.sql. La columna de regla es la correspondencia propuesta, pendiente de revisión adversarial; la de familia es la que determina el código HTTP.

Función Rechazo Regla Familia
validar_transicion_orden Transición de orden no permitida: %s -> %s §7 matriz Estado
validar_transicion_orden Esta transición de orden exige motivo §7 matriz Requisito
validar_transicion_prueba Transición de prueba no permitida: %s -> %s §7 matriz Estado
validar_transicion_prueba Esta transición de prueba exige motivo §7 matriz Requisito
validar_resultado_version La opción no pertenece al analito del resultado RN-07 Dato inválido
validar_resultado_version El analito requiere un valor numérico RN-08 Dato inválido
validar_resultado_version El analito requiere un valor textual RN-08 Dato inválido
validar_resultado_version El analito requiere una opción codificada RN-08 Dato inválido
validar_resultado_version Un analito de título no produce resultado RN-08 Dato inválido
validar_resultado_version La unidad reportada no coincide con la configurada RN-09 Dato inválido
validar_validacion_automatica Un resultado crítico nunca se autovalida RN-23 Estado
proteger_resultado_version resultado_version es append-only RN-12 Inmutable
proteger_resultado_version Solo el estado puede cambiar; el valor liberado es inmutable RN-12 Inmutable
impedir_modificacion_inmutable La tabla %I.%I es append-only RN-17, RN-19 Inmutable
validar_informe_detalle Un informe solo incluye versiones de su propia orden RN-13 Coherencia
validar_coherencia_soporte_prueba %s: la muestra o alícuota no pertenece a la misma orden y paciente RN-05 Coherencia
validar_codigo_barras_unico El código ya identifica una muestra primaria en esta organización RN-05 Unicidad
validar_codigo_barras_unico El código ya identifica una alícuota en esta organización RN-05 Unicidad
validar_consumo_reactivo El lote está en estado %s y no puede consumirse RN-16 Estado
validar_consumo_reactivo No se puede consumir un lote vencido sin excepción registrada RN-16 Estado
validar_fecha_nacimiento La fecha de nacimiento no puede ser futura RN-20 Dato inválido
siguiente_numero_documento Tipo de documento no soportado RN-18 Defecto

El último no es un rechazo al usuario: si el código pide un tipo de documento que no existe, el código está mal. Su familia es defecto, y por tanto su respuesta es 500, no 4xx. Reconocerlo ahora evita que un bug se presente al operador como si fuera culpa suya.

Decisión

1. Cada regla del esquema tiene su propio SQLSTATE

Se asigna un código en la clase LS, que no es una clase estándar de PostgreSQL. Se comprobó contra PostgreSQL 18 real y contra el driver JDBC 42.7.11: raise exception using errcode = 'LS001' llega a Java como SQLException.getSQLState() == "LS001", sin traducirse. jOOQ lo expone en DataAccessException.sqlState().

Los rangos agrupan por área del dominio, no por código HTTP. Son dos ejes distintos a propósito: el código LS es contrato estable y dice de qué habla el rechazo; la familia es política y dice qué se responde. Dos códigos del mismo rango pueden tener estados distintos, y eso es correcto —una transición no permitida y una transición sin motivo fallan en la misma área por razones distintas—.

Rango Área
LS0xx Transiciones de estado
LS1xx Resultado y versión
LS2xx Inmutabilidad y append-only
LS3xx Muestras y códigos de barras
LS4xx Inventario y lotes
LS5xx Coherencia entre agregados
LS6xx Datos de paciente y admisión
LS9xx Defectos: el código pidió algo imposible

Se dejan huecos dentro de cada rango. Un código no se reutiliza ni se renumera nunca: es contrato público a través de type.

Asignación de los 22 códigos

Esta tabla es la entrada de la migración. La columna origen dice de dónde sale la trazabilidad, y es deliberadamente explícita sobre lo que no está escrito en ningún documento: cuatro rechazos no tienen regla que citar, y forzar una cita habría sido inventarla.

Código Función Rechazo Origen Familia HTTP
LS001 validar_transicion_orden Transición de orden no permitida §7 matriz Estado 409
LS002 validar_transicion_orden Exige motivo §7 matriz Requisito 422
LS003 validar_transicion_prueba Transición de prueba no permitida §7 matriz Estado 409
LS004 validar_transicion_prueba Exige motivo §7 matriz Requisito 422
LS101 validar_resultado_version La opción no pertenece al analito RN-07 Dato inválido 422
LS102 validar_resultado_version Requiere valor numérico RN-08 Dato inválido 422
LS103 validar_resultado_version Requiere valor textual RN-08 Dato inválido 422
LS104 validar_resultado_version Requiere opción codificada RN-08 Dato inválido 422
LS105 validar_resultado_version Un analito de título no produce resultado RN-08 Dato inválido 422
LS106 validar_resultado_version La unidad no coincide con la configurada RN-09 (la más floja) Dato inválido 422
LS120 validar_validacion_automatica Un crítico nunca se autovalida RN-23 (literal) Estado 409
LS201 proteger_resultado_version resultado_version es append-only RN-12 Inmutable 409
LS202 proteger_resultado_version Solo el estado cambia; el valor liberado es inmutable RN-12 Inmutable 409
LS210 impedir_modificacion_inmutable La tabla es append-only RN-17, RN-19 Inmutable 409
LS301 validar_codigo_barras_unico Ya identifica una muestra primaria en la organización RN-05 · CU-07, vía S1.09 Unicidad 409
LS302 validar_codigo_barras_unico Ya identifica una alícuota en la organización RN-05 · CU-07, vía S1.09 Unicidad 409
LS401 validar_consumo_reactivo El lote está en un estado que no permite consumo RN-16 (literal) Estado 409
LS402 validar_consumo_reactivo Lote vencido sin excepción registrada RN-16 (literal) Estado 409
LS501 validar_coherencia_soporte_prueba La muestra o alícuota no es de la misma orden y paciente RN-05 Coherencia 409
LS502 validar_informe_detalle El informe solo incluye versiones de su propia orden RN-13, vía S1.19 Coherencia 409
LS601 validar_fecha_nacimiento La fecha de nacimiento no puede ser futura Sin RN: sanidad del dato Dato inválido 422
LS901 siguiente_numero_documento Tipo de documento no soportado Sin RN: defecto en la función de RN-18 Defecto 500

Cuatro correspondencias merecen mirada, y se señalan en lugar de disimularse:

  • LS106 (unidad) se cita a RN-09, que habla de conservar rango, unidad, método y límites con la versión liberada, no de que la unidad reportada coincida con la configurada. Es la regla más cercana, no la misma.
  • LS301 y LS302 (código de barras) no tienen RN propia: RN-05 dice que una muestra tiene un solo paciente, no que el código sea único. La unicidad por organización sí está en el esquema y en el criterio de S1.09 de PLAN_MAESTRO.md, que es de donde se toma.
  • LS601 (fecha futura) no está en las 28 reglas. RN-20 habla del tipo de dato —fecha civil frente a instante—, no de su rango. Es sanidad del dato, y se declara así.
  • LS901 no es un rechazo al usuario. Es un defecto dentro de la función que implementa RN-18.

El mensaje humano se conserva tal cual, para el log y para el operador. Lo que cambia es que ya no hay que interpretarlo: el código dice qué regla fue.

Se aplica en una migración nueva con create or replace function sobre las 12 funciones. No se edita V1 —ninguna migración aplicada se edita— y reemplazar la función no obliga a recrear los triggers, que la referencian por identificador. El código jOOQ regenerado acompaña a la migración en el mismo commit.

Regla derivada: un raise exception nuevo en el esquema nace con su código LS, en la misma migración. Un rechazo con SQLSTATE estándar es indistinguible de un defecto, y eso ya no se acepta.

2. Los datos del rechazo no se parsean: ya los tiene quien invocó

Cinco mensajes interpolan datos —los estados de la transición rechazada, el estado del lote, la tabla—. No se extraen del mensaje. La aplicación que pidió la transición ya sabe de qué estado a qué estado iba; no necesita que la base se lo devuelva. El código LS dice qué regla falló y el caso de uso completa el contexto con lo que ya tenía en la mano.

Esto evita el peor diseño posible: parsear prosa en español para reconstruir datos que el llamante nunca perdió.

3. Los errores de dominio son tipos, no mensajes

En compartido/dominio, una jerarquía sellada con datos estructurados: qué regla, sobre qué entidad, y —cuando la regla lo admite— si existe alguien que pueda autorizar la excepción. El dominio los define; el adaptador los traduce, porque quien conoce el SQLSTATE es infraestructura y el dominio no conoce SQL. La tabla de correspondencia entre código LS y tipo de dominio vive ahí.

Un SQLSTATE que no esté en la tabla es un defecto, no un rechazo: se propaga como fallo técnico en lugar de convertirse en un 4xx genérico que esconda el problema.

4. El borde HTTP habla RFC 9457

ProblemDetail está en spring-web 7.0.8. spring.mvc.problemdetails.enabled viene a false, así que se activa explícitamente o se construye la respuesta en el manejador; en cualquier caso el tipo de contenido es application/problem+json.

Campo Contenido
type URI estable por regla, derivada del código LS. Es el contrato que un cliente puede programar
title Texto corto y estable de la familia
status Ver tabla de familias
detail Mensaje para una persona. Sin PII
instance La petición
traceId Extensión propia, la misma que va en la cabecera X-Trace-Id
Familia Estado
Estado (transición, crítico, lote) 409 Conflict
Unicidad (código de barras) 409 Conflict
Inmutable (append-only) 409 Conflict
Requisito (falta el motivo) 422 Unprocessable Content
Dato inválido (unidad, tipo de valor, fecha) 422 Unprocessable Content
Coherencia entre agregados 409 Conflict
No encontrado, y «no es tuyo» 404 Not Found
Sin autenticar 401 con WWW-Authenticate, sin redirección (ADR-0009)
Autenticado y sin permiso 403 Forbidden
Defecto 500, cuerpo sin detalle interno

Un recurso de otra organización responde 404, no 403. Un 403 confirma que el recurso existe, y eso es una filtración: permite enumerar órdenes ajenas por diferencia de respuesta. Es la misma razón por la que un principal sin cuenta y una cuenta bloqueada devuelven lo mismo (S1.01a).

5. El cuerpo de un error no lleva PII

Ni nombre, ni documento, ni valores de resultado, ni diagnóstico, en ningún campo. Los identificadores técnicos sí. Es la regla 14 de 00-agents.md aplicada al cuerpo de error, que es tan exportable como un log: acaba en la consola del navegador, en el registro del proxy y en el ticket que alguien pega en un chat.

6. Un rechazo de regla se registra WARN; un defecto, ERROR

Por ADR-0007. Si un rechazo se registrara como ERROR, la métrica logback.events contaría operación normal como avería y el primer panel de alertas mentiría.

Cómo se verifica

  • Un test por familia que provoca el rechazo en PostgreSQL real y comprueba el código LS, el tipo de dominio y el estado HTTP. Los rechazos viven en triggers: un mock no ejecuta un trigger.
  • Un test que recorre las 12 funciones y afirma que ningún raise exception quedó con SQLSTATE estándar. Es la versión ejecutable de la regla derivada del punto 1, y no depende de que alguien se acuerde.
  • Un test que afirma que el cuerpo de error no contiene los campos prohibidos.
  • Los .http de wiki/http cubren el borde: un cambio en el contrato se comprueba en los dos sitios.

Consecuencias

  • Un rechazo de negocio deja de ser indistinguible de un defecto. Hoy lo son, y eso es lo que hace imposible alertar.
  • El cliente programa contra type, no contra una cadena en español que cambia al corregir una tilde.
  • Las deudas de M0 quedan pagadas: contrato de errores propio e identificador de correlación.
  • Los 22 slices heredan un contrato en lugar de improvisar veintidós. − Una migración que toca 12 funciones y arrastra regeneración de jOOQ. − Dos sitios que mantener sincronizados: los códigos en el esquema y su tabla en el adaptador. Lo mitiga el test que recorre las funciones. − type es contrato público: cambiarlo después rompe clientes. Por eso se deriva del código LS, que es estable, y no del nombre de una clase Java.

Lo que esta decisión NO resuelve

No cataloga los rechazos que todavía no existen: las reglas que el código hará cumplir y el esquema no puede —consentimiento vigente, estabilidad de muestra, política de publicación—. Nacerán como tipos de dominio sin SQLSTATE, porque no vienen de la base. La familia y el estado HTTP se eligen de las mismas tablas.

No decide el idioma de detail. El dominio se escribe en español y los mensajes del esquema están en español; si algún día hay clientes en otro idioma, type sigue sirviendo y detail se traduce.

No añade la cabecera X-Trace-Id: eso es trabajo de S1.01b, que consume este contrato.

Alternativas descartadas

Comparar el mensaje. Cinco de los 22 se construyen con format(), así que habría que comparar por prefijo; se rompe al corregir una tilde y no se puede testear de forma significativa. Es el camino por defecto si no se decide nada, y es la razón de este ADR.

Mover las validaciones a Java y dejar los triggers como red. Duplica la regla en dos sitios. El DDL es fuente de reglas por ADR-0003, y dos copias de una regla clínica divergen.

Un único código para todos los rechazos de dominio. Distingue el rechazo del defecto, que es la mitad del problema, pero no permite que el cliente sepa qué corregir ni que un panel cuente qué regla se incumple más. El coste de 22 códigos frente a uno es una columna en una tabla.

Usar detail o hint de PostgreSQL para el código en vez del SQLSTATE. Funcionaría, pero el SQLSTATE es el campo que JDBC y jOOQ exponen de primera clase, y detail es texto libre que invita a volver a parsear.