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.LS301yLS302(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 dePLAN_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í.LS901no 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 exceptionquedó 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
.httpdewiki/httpcubren 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.
−
typees contrato público: cambiarlo después rompe clientes. Por eso se deriva del códigoLS, 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.