ADR-028: El flujo mueve el estado del radicado¶
Estado: Aceptado
Fecha: 2026-10-02
Autores: Giampiero (mantenedor principal); diseño de orfeo-architect
Relacionados: ADR-009 (historial de flujos como eventos), ADR-010 (auditoría inmutable), E05 (flujos), E01 (radicación), RF-SEG-08 (no-read-up); continuado por ADR-029 (la ubicación actual se escribe en el mismo UPDATE que el estado)
Contexto¶
Había dos máquinas de estado que no se hablaban. Repartir y tramitar avanzan los
pasos (flow_steps); radicados.status solo cambiaba si alguien pulsaba a mano
«Distribuir» / «En trámite» (un PATCH /documents/{id} con status). El resultado, visto
al recorrer la aplicación con el navegador (2026-10-01): un radicado repartido seguía en
registered; el panel dejó de contar desde distributed/in_progress porque el flujo
nunca los escribía; y seguían colgando de esos estados el botón «Devolver» de la bandeja y
los filtros por estado de la búsqueda.
En Orfeo, repartir es un cambio de estado. Había dos salidas y no eran equivalentes:
que el flujo mueva el estado del radicado, o retirar distributed/in_progress del
radicado y derivar el «en qué va» de sus pasos. Se eligió la primera: el estado del
radicado es una propiedad que otras pantallas, la búsqueda y el MCP consultan sin
reconstruirla desde los pasos, y es lo que el funcionario de Orfeo ya espera.
Decisión¶
- El estado lo escribe el workflow-service, en la MISMA transacción que el paso.
Excepción única:
anuladolo sigue escribiendo la anulación (document-service). - Una sola función pura (
workflow-service/app/services/estado_radicado.py):
def derivar_estado_radicado(actual, paso_activo, *, tiene_pasos, finalizar=False) -> str:
if actual in ("closed", "anulado"): return actual
if finalizar: return "closed"
if paso_activo == "pending": return "distributed"
if paso_activo == "in_progress": return "in_progress"
return "distributed" if (actual == "registered" and tiene_pasos) else actual
El backfill SQL es su gemelo literal y la prueba de integración compara cada fila con
ella. Una regla en dos sitios que no se vigilan entre sí deja de ser una regla.
3. Cierre solo por «Finalizar trámite» explícito. POST /workflows/{radicado_id}/finalizar
(PERM_TRAMITAR nivel 3, solo el poseedor del paso activo —o, sin paso activo, quien
poseía el último paso completado—, comentario y motivo_cierre obligatorios,
flow_events.tipo_tx = 'finalizar') cierra el paso activo como completed y el radicado
pasa a closed. No es un transaction_type: un tipo del catálogo del tenant lo
puede sembrar o editar cualquiera con acceso a la base, y el acto irreversible no puede
vivir en una tabla configurable.
4. in_progress solo por marcar_leido del poseedor. Para eso se cierra un hueco que
ya existía: execute_transaction no comprobaba tenencia, así que cualquiera con
PERM_TRAMITAR podía marcar como leído —y mover el estado de— un trámite ajeno. Nueva
columna transaction_types.requires_holder (true para marcar_leido). Poseedor
= assigned_to = actor, o (assigned_to IS NULL y el actor pertenece a la dependencia
del paso). Es más estrecho que el alcance de bandeja (_own_scope_condition: un
miembro de la dependencia ve lo asignado a un colega, pero no lo posee); ambos
comparten el subquery de «mis dependencias» para no divergir. Un no-poseedor recibe el
mismo 404 no_active_step que «no hay paso» (sin oráculo).
5. closed y anulado son terminales. Todo acto de trámite que mueva el paso
(assign, transfer, complete, devolver, rollback, finalizar y las
transacciones con target_step_status) sobre un radicado terminal responde
409 radicado_terminal y revierte todo. informar/agendar siguen permitidas.
6. Atomicidad y orden. Cada acto sincroniza después de mutar el paso y antes de
audit.append, dentro de su conn.transaction(): si la auditoría falla, no queda ni
paso ni estado. lock_radicado (advisory por radicado) se añadió a execute_transaction,
complete_step, devolver, rollback y finalizar. El cambio viaja en el payload del
asiento existente ("estado_radicado": {"antes", "despues"}); tras el commit se publica,
best-effort, workflow.radicado.estado_cambiado (dedup
{radicado_id}:{despues}:{flow_event_id}). Los GET nunca sincronizan.
7. Reasignación en cascada devuelve el paso a pending (Orfeo LEIDO=0: el nuevo
responsable no lo ha leído) y el radicado a distributed; excluye los radicados
terminales (skipped_terminal en la respuesta, en pasos), toma los cerrojos ordenados por
radicado_id y deja un asiento workflow.estado_radicado por radicado cambiado.
8. document-service ya no acepta status en el PATCH: 422 status_managed_by_workflow.
No basta con quitar el campo —Pydantic ignoraría el extra y el cliente creería que el
estado cambió—, así que el campo se conserva solo para rechazarlo con error explícito.
9. Backfill (migrations/tenant/013_radicado_estado_desde_flujo.sql): recalcula los
radicados existentes con el CASE gemelo, deja evidencia en
radicado_estado_recalculo (radicado_id, antes, despues, at), no escribe audit_log
desde SQL (la cadena de hash la firma la aplicación), excluye terminales, es
idempotente (IS DISTINCT FROM; la 2.ª corrida cambia 0 filas) y avisa con
RAISE NOTICE de los terminales que aún tienen paso activo (los sanea la migración 015, R7).
10. MCP: actualizar_radicado pierde status; nueva tool finalizar_tramite
(destructive=True, irreversible, tarjeta de confirmación).
Ubicación (2026-10-02, ADR-029). El mismo
sincronizar_estado_radicadoescribe también dónde está el radicado (flujo_dept,flujo_dept_code,flujo_usuario) en el mismoUPDATEquestatus.
Declarado fuera (a propósito, no un olvido)¶
- Salidas (
S) e Internos (I) sin reparto fuera. El estado solo se mueve cuando un paso los toca; un radicado sin pasos sigueregistered. Un radicado de salida no «se distribuye» a una dependencia: se envía a un tercero (E20), que es otro circuito. - ~~La anulación no cancela el paso~~ — corregido en la remediación (ver abajo, decisión R7).
rollbacksolo desdepending. Revertir un paso que ya se leyó no se admite; no cambia por esta decisión.- Completar un paso sin marcarlo leído deja
distributed. No hay estado «completado sin leer»; la lectura es un acto del poseedor y completar no la presume. - Completar un paso
in_progressdeja el radicadoin_progresssin paso activo. Se mantiene a propósito (decisión R14): el radicado ya fue leído y trabajado; degradarlo adistributedmentiría. El poseedor del último paso lo cierra con «Finalizar» (R12) o lo reparte; mientras tanto el estado dice la verdad: se leyó y nadie lo ha cerrado. - Acto «reabrir» fuera de esta tanda. Un radicado
closedno vuelve a abrirse. MoReq prevé la reapertura controlada (permiso de administración, motivo, asiento propio); queda para su propio incremento, no se improvisa aquí. - Salidas e Internos sin reparto (ya declarado arriba) siguen fuera de la tanda.
finalizar,devolveryrollbacksobre un radicado ya cerrado dan 404 / 409 propio, noradicado_terminal: un cerrado no tiene paso activo, y revelar «está cerrado» a quien no posee nada sería un oráculo sobre radicados que quizá no puede leer.- La importación (INT-05) conserva el
statusdel paquete (salvoanulado, que se rechaza; decisión R8): restaurar un radicadocloseddesde un paquete es una restauración, no un acto del flujo. - El frontend (botón «Finalizar trámite», retirada de «Distribuir»/«En trámite» manuales, traducción del 409) queda para su propio incremento.
Consecuencias¶
- El panel, la bandeja y la búsqueda pueden volver a fiarse de
radicados.status. - El
PATCHde radicado deja de ser una puerta lateral al estado (incluida la de «desanular»: ahora se rechaza antes de llegar al guard409 radicado_anulado). - Se añade un cerrojo advisory por radicado a cuatro actos que no lo tenían; el coste es serializar actos sobre el mismo radicado, que es lo correcto.
- Verificado por mutación contra Postgres real (
RUN_INTEGRATION=1): quitar la sincronización de_create_step, sacarla de la transacción, quitar la guarda terminal, quitar la comprobación de poseedor, invertir elCASEdel backfill, no excluir terminales en la cascada e ignorar la bandera definalizar— cada una tumba al menos una prueba.
Remediación de la auditoría (2026-10-02)¶
Los dos auditores (seguridad multi-tenant y cumplimiento archivístico) revisaron la implementación antes de integrarla. Estas son las decisiones que se tomaron, numeradas R1…R14 (las que cambian o completan la decisión original, con su porqué).
Seguridad¶
- R1.
assign/transferexigen clearance y tenencia. Eran la puerta lateral: B se autoasignaba un radicado ajeno (incluso reservado: write-up) y luego «Finalizar» lo cerraba. Dentro de la transacción y traslock_radicado: (a) el nivel del actor va en elWHEREde la lectura del radicado (nivel_seguridad <= $nivel; no es una comprobación previa, regla RF-SEG-08) y un radicado inaccesible da el mismo404 radicado_not_foundque uno inexistente; (b) si hay paso activo, solo su poseedor puede repartirlo o trasladarlo (mismo_holder_condition), y un no-poseedor recibe el404 no_active_stepneutro. El clearance lo resuelve el servicio desdeauth_memberships/role_clearance(mismo schema de tenant; no viaja por claims ni cabeceras: el gateway solo inyectaX-User-Id). El reparto automático al radicar es un actor de sistema (sin usuario, sin clearance ni tenencia):WorkflowService.assign(..., sistema=True), que solo pasaauto_distribute— ninguna ruta HTTP puede pasarlo. Redistribución forzada del paso de otro: no se expone porassign/transfer; basta la reasignación en cascada (USUA_PERM_ADMIN), que ya existe, con asiento por radicado. El residual declarado entonces (radicado con historial pero sin paso activo, repartible por cualquiera conPERM_TRAMITAR) se cierra en R16. - R2. Oráculo
409 radicado_terminal. Se cierra con R1 (el gate va antes de sincronizar) y, paraanular, contransaction_types.requires_holder = true(migración 014). Además el código exige tenencia a toda transacción que mueve el paso (target_step_status), la declare o no su fila: el catálogo es configurable por el tenant. - R3.
finalizara nivel 3.require_permission("PERM_TRAMITAR", min_crud=3): era1(leer); el acto irreversible no puede alcanzarlo quien solo lee. Sale deSIN_NIVEL_DECLARADASy el catálogo MCP lo espeja (min_crud=3). - R4. Una sola definición de poseedor.
complete_stepusabaassigned_to = $4 OR assigned_to IS NULLsin mirar el estado: cualquiera cerraba el paso sin dueño de otra dependencia y un paso ya cerrado se «re-cerraba». AhoraWHERE id = $3 AND status IN ('pending','in_progress') AND {_holder_condition(4)}: la misma condición quees_poseedor,tenenciayfinalizar. - R5. MCP
finalizar_tramitese confirma con el radicado concreto (confirmar_con = "radicado_id"), no con la frase fijaCONFIRMO: una frase fija confirma «un cierre», no «este cierre», y se teclea de memoria (escalera de confirmación,write_gate.py). - R6. La cascada no agota
max_locks_per_transaction. Tomaba unpg_advisory_xact_lockpor radicado (cada uno ocupa una ranura de la tabla de cerrojos, 64 por defecto y compartida): un barrido grande fallaba a medias. Elección: un único cerrojo advisory de alcance del tenant —exclusivo en la cascada, compartido enlock_radicado— más cerrojos de fila (SELECT … FOR UPDATEen orden deid) sobre los radicados, también los que aparezcan al releer. Los actos de trámite siguen serializando por radicado y no se estorban entre sí; la cascada espera a los que están en curso y los detiene mientras dura. La alternativa (lotes con una transacción por lote) dejaba la cascada parcialmente aplicada y sin asiento atómico. Costo declarado: un acto administrativo raro detiene brevemente el trámite de ese tenant. Orden de cerrojos en todos los escritores —alcance, radicado, fila del radicado, luego sus pasos— para que no haya ciclos de interbloqueo (la anulación de document-service también toma la fila antes de tocar los pasos).
Cumplimiento archivístico¶
- R7. La anulación cancela el paso activo, en la misma transacción. Un radicado anulado dejaba
su paso
pending: un zombi en bandeja, vencidos y cascada que nadie podía cerrar (todo acto sobre un terminal da 409). Decisión de frontera:AnulacionRepository.decide(document-service) pone el paso encancelledy deja el eventoflow_events.tipo_tx = 'anulacion'en la misma transacción questatus = 'anulado'y el asiento.flow_steps/flow_eventsson del workflow-service pero viven en el mismo schema de tenant (ADR-002/012) y el rol de aplicación ya escribe en todas. Se prefirió a un consumidor de eventos en workflow por atomicidad: con un evento hay una ventana en la que el radicado está anulado y su paso vivo, y un evento perdido la deja abierta para siempre. Sin las tablas del flujo (se migran aparte) elSAVEPOINTabsorbe el error y la anulación sigue. Coste: document-service conoce el estado destino del paso (cancelled, el mismo de la transacciónanulardel catálogo). Zombis existentes: migración015_sanear_pasos_zombi.sql— paso activo de radicadoanulado→cancelled, declosed→completed; cada uno deja un eventosaneamiento_terminaly una fila enflow_step_saneamiento(solo inserción, con el estado antes/después). Defensa en profundidad:inbox,pendingylist_overdue_unnotifiedexcluyen radicados terminales. - R8. Importación INT-05.
RADICADO_SCHEMAfijametadata_sistema.statusaregistered | distributed | in_progress | closed: un paquete conanuladose rechaza entero (no trae el acto de anulación —causal, aprobador, asiento— que lo justifique y crearía un radicado anulado inexplicable; el export ya excluye anulados). El asientoradicado.importregistra elstatus_origende cada radicado. Migración034_radicado_status_check.sql(document-service):CHECK (status IN ('registered','distributed','in_progress','closed','anulado'))—verificado contra el código y los seeds— añadidoNOT VALIDy validado si los datos lo permiten; con datos heredados fuera del conjunto avisa y deja la restricción rigiendo lo nuevo, sin tumbar el arranque de la entidad. - R9. Evidencia del backfill 013.
radicado_estado_recalculoes de solo inserción (trigger que rechaza UPDATE/DELETE) contracking_number,paso_activoyorigen = 'migracion_013'; el backfill ya no tocaupdated_at(recalcular un estado derivado no es editar el documento). El asiento-anclaworkflow.estado_radicado.backfill(conteo + SHA-256 del contenido ordenado) lo escribe la aplicación, nunca SQL: el runner de migraciones (migrations/runner.py:: anclar_evidencia), en la misma transacción que la migración, una vez por tenant (si ya existe el asiento no escribe otro; sin filas de evidencia tampoco). Misma mecánica para el saneamiento 015 (workflow.flow_step.saneamiento). 013 aún no se había liberado, así que se editó en lugar de añadir una migración correctora (un tenant de desarrollo que ya la aplicó debe recrearse). - R10. El asiento
workflow.finalizarañadestep_id,flow_event_id,tiene_respuesta(existe una Salida conresponde_a= este radicado),fuera_de_plazo(due_atvs. ahora) yen_expediente(vinculado enexpediente_radicados). Las dos lecturas cruzadas se hacen en unSAVEPOINT: si la tabla o la columna aún no existe en el tenant el valor esnull(«no consta»), nunca unfalseinventado, y no aborta el cierre. - R11.
motivo_cierretipificado y obligatorio:respondido | nrr | traslado_competencia | otro, en el evento y en el asiento. Aclaración sobre «NRR»: es un motivo de cierre, no una transacción que cierre; la transacciónnrrdel catálogo (No requiere respuesta) existe, no tiene efecto de estado y no cierra nada — solo «Finalizar» cierra. - R12.
finalizarsin paso activo: lo permite quien poseía el último paso, yacompleted, si el radicado no es terminal (completó el suyo y no repartió; exigir un paso activo obligaba a inventar un reparto ficticio). Cualquier otro caso, el mismo404 no_active_step; un radicado ya cerrado también da 404 (no 409).
Pruebas y deuda de las pruebas¶
- R13.
WorkflowService._cambiosse vacía al inicio de cada acto: un acto que revertía dejaba cambios nunca confirmados que el siguiente acto de la misma instancia emitía como eventos fantasma (la pruebaxfail(strict)pasó a verde). - R14. Ver «Declarado fuera»: completar un paso
in_progressdejain_progress.
Defecto previo encontrado al probar contra Postgres real¶
AnulacionRepository.decide condicionaba la decisión a estado = 'pendiente', un valor que el
CHECK de radicado_anulacion nunca admite (solicitada | aprobada | rechazada): ninguna
aprobación de anulación podía aplicarse en una base real (siempre 409 anulacion_ya_decidida),
y los mocks no lo veían porque devolvían la fila igual. Corregido a 'solicitada' y fijado con
pruebas de integración.
Re-auditoría de seguridad: residuales (2026-10-02)¶
La re-auditoría de la remediación dejó siete residuales y una guarda mal ubicada. Se cierran
así (R15…R21); cada uno tiene su prueba contra Postgres real (RUN_INTEGRATION=1) verificada por
mutación.
- R15. Los actos por tenencia también miran el clearance del POSEEDOR (media).
finalizar,complete_step,marcar_leidoy toda transacción que exige tenencia comprobaban quién tiene el paso, no qué nivel alcanza: un miembro de la dependencia con clearance 1 cerraba un reservado repartido a su dependencia sin dueño (o uno reclasificado tras el reparto). Ahora el nivel va en elWHERE(_nivel_poseedor_condition:EXISTS (SELECT 1 FROM radicados … nivel_seguridad <= $n), clearance resuelto como en R1 conclearance_para_acto) deget_active_step_for_update,ultimo_paso_si_lo_poseia,es_poseedor,complete_stepy, para rollback/devolver, de susUPDATE. Fail-closed: sin fila enradicadosno casa. El resultado es el mismo 404 neutro (no_active_step/step_not_found).rollbackydevolverdejan de usarCLEARANCE_SIN_RESTRICCION. - R16. Un radicado sin paso activo no es de cualquiera (media).
assign/transferconassigned_to = uno mismosobre un radicado sin paso activo lo tomaba cualquiera conPERM_TRAMITAR. Ahora, traslock_radicadoy sin paso activo: con historial exigeultimo_paso_si_lo_poseia(poseedor del último paso, completado, radicado no terminal); sin ningún paso exigeradicados.registered_by = actor.registered_byguarda elX-User-Idque document-service recibe al radicar, es decirauth_users.id(el mismo que el actor del workflow, no elsubde Keycloak). El reparto automático (sistema=True) no pasa por esta regla y la redistribución forzada sigue siendo la cascada. Se retira la objeción «rompería al radicador» de R1: el radicador conserva el primer reparto (esregistered_by) y, después, cualquier reparto lo hace el poseedor del trámite, que es lo que la tenencia siempre dijo. Consecuencias aceptadas: (a) quien radicó deja de poder repartir en cuanto hay historial salvo que poseyera el último paso; (b) un radicado conregistered_byNULL (importado, ingestado por sistema) no lo reparte nadie a mano (solo el reparto automático o la cascada); (c) un radicado cerrado ya no da409 radicado_terminalal reparto de quien poseía su último paso sino el404 no_active_stepneutro. - R17. Transacciones sin tenencia (media) y cierre del oráculo
no_flow(H).vobo,informar,nrr,agendar,solicitar_firma,cerrar_exp,change_folder,validate_trd_sendno declaran tenencia nitarget_step_status, no aplicaban clearance y devolvíantracking_number/to_deptde un reservado.get_latest_steplleva ahora el nivel en elWHERE: un radicado inalcanzable es404 no_flow, igual que uno inexistente o sin pasos. El no-autorizado recibe siempre esa misma respuesta (para cualquiertipo_tx, con o sin tenencia), de modo queno_flowfrente ano_active_stepya no distingue «existe y es reservado» de «no existe»;no_active_stepsolo lo ve quien sí alcanza el radicado. - R18. TOCTOU en
rollbackydevolver(baja). Leían el paso actual y la tenencia fuera de la transacción, antes del cerrojo: otro acto podía mover el paso entre la comprobación y la escritura. Ahora ambas lecturas van dentro de la transacción y traslock_radicado, yapply_rollback/apply_devolucionrepiten la condición en el propioUPDATE(estado activo + poseedor + nivel,RETURNING): si ya no se cumple devuelvenNoney el servicio responde el 404 neutro sin escribir nada. - R19. Una sola definición de poseedor también en
rollback/devolver(baja). Usabanassigned_to == actor: el paso sin dueño (el que deja una devolución) no lo podía revertir ni devolver nadie de su dependencia. Ahora_holder_condition, como en R4.rollbackconserva su exigencia propia de que el paso estépending. - R20. MCP
finalizar_tramitedeclaramotivo_cierre(funcional, bloqueante). El esquema del cuerpo esadditionalProperties: falsey el backend exigemotivo_cierre(R11): al no declararlo el catálogo, toda llamada daba 422. Parámetro obligatorio conenum respondido|nrr|traslado_competencia|otro. - R21. La carrera de dos decisiones de anulación da 409, no 500 (baja).
AnulacionRepository.decideterminaba enassert row is not None: la decisión concurrente (que elUPDATE … WHERE estado = 'solicitada'no encuentra) era un 500. Devuelverow | Noney el servicio —que ya cortaba antes del asiento— responde409 anulacion_ya_decidida. - RN-8. La guarda de
auto_distributeva dentro del cerrojo. La comprobación «sin pasos» deauto_distributeestá fuera de cualquier cerrojo: dos entregas simultáneas del mismo evento la pasaban las dos. Consistema=True,_create_stepla repite traslock_radicadoy, si ya hay pasos, lanzaYaDistribuidoError, queauto_distributetrata como «nada que hacer» (la guarda externa se conserva como atajo barato). Probado con dos conexiones reales.
Declarado, no corregido¶
- La anulación puede anular un radicado
closed. Decisión de dominio abierta (un cierre no impide anular un documento erróneo; la anulación exige causal, solicitante y aprobador distintos). USUA_PERM_ADMINenmin_crud=1para la cascada. Deuda previa a ADR-028: la reasignación en cascada, que mueve pasos ajenos, sigue pidiendo solo el nivel de lectura de ese permiso.TRUNCATEno está cubierto por los triggers de evidencia (radicado_estado_recalculo,flow_step_saneamiento): los triggers rechazanUPDATE/DELETE, noTRUNCATE. Lo cubre el privilegio del rol de aplicación, no el esquema.- Un último paso
cancelled/returned(nocompleted) sin paso activo no cuenta como «poseído» para R16: nadie reparte a mano ese radicado; la salida es la cascada o un administrador.