Saltar a contenido

ADR-045: La anulación de un radicado la decide otra persona que la solicitó

Estado: Aceptado Fecha: 2026-10-22 Autores: Giampiero (mantenedor principal); lote D, bloque D-1 Relacionados: ADR-039 (misma separación de funciones para desclasificar), ADR-028 (el estado del radicado), ADR-010 (auditoría inmutable), ADR-003 (SQL crudo)


Contexto

Anular un radicado es retirarlo del trámite sin borrarlo: el número es inmutable (Ley 594/2000, Acuerdo AGN 060/2001) y el acto debe ser trazable. La solicitud existía, pero la decisión podía tomarla la misma persona que la pidió si tenía ambos permisos (PERM_SOL_ANULAR y PERM_PANU_CODI), y nada impedía que un UPDATE a mano dejara un radicado en anulado sin solicitud aprobada. Tampoco había salida para quien pedía por error: la solicitud quedaba solicitada hasta que otra persona la rechazara.

Decisión

  1. La separación de funciones vive en el WHERE, no en una comprobación previa: AnulacionRepository.resolver aprueba/rechaza con a.estado = 'solicitada' AND a.solicitado_por <> $3 y el clearance del radicado (EXISTS ... nivel_seguridad <= $5) en la misma sentencia. Un 0-filas se explica después con diagnosticar (404 neutro frente al clearance; 403 separacion_de_funciones; 409 anulacion_ya_decidida).
  2. Dos fronteras en la base (migración tenant 052): CHECKs ra_separacion_funciones (decidido_por <> solicitado_por), ra_decision_forma, ra_retirada_por_solicitante, ra_rechazo_motivado (observación ≥10); trigger radicado_anulacion_guard (los estados terminales son inmutables; borrar solo en cascada, pg_trigger_depth() >= 2) y trigger radicados_anulacion_guard sobre radicados.status: pasar a anulado exige una fila aprobada con decidido_at = now() (la MISMA transacción) y otro decidido_por; salir de anulado es irreversible. solicitado_por pasa a NOT NULL.
  3. Estado nuevo retirada y POST /documents/{id}/anulacion/retirar (PERM_SOL_ANULAR): solo el solicitante, con observación opcional. Es el "me equivoqué" que antes no existía.
  4. Una identidad es obligatoria: sin X-User-Id el servicio responde 403 forbidden; ningún actor None decide.
  5. Asientos document.anulacion_aprobada|rechazada|retirada con solicitado_por y decidido_por, atómicos con la escritura; el evento al outbox sale solo al aprobar.
  6. Cola: cada fila lleva propia (la solicitó quien mira) para que la UI no ofrezca decidir lo propio; el recuento usa la misma condición que las filas.
  7. UI: /anulaciones ofrece "Retirar mi solicitud" en filas propias; /admin/grupos avisa si el único grupo que decide es el único que solicita.

Alternativas descartadas

  • Solo validar en el servicio: un UPDATE directo o un camino nuevo la esquivaría; el CHECK y el trigger son la última frontera.
  • Caducar las solicitudes: se descartó en este lote; la salida es retirar o rechazar (residuo declarado).

Residuos declarados

  • Un radicado anulado importado (INT-05) esquiva el trigger de frontera: llega ya anulado, sin solicitud.
  • Dos personas con ambos permisos pueden pedirse mutuamente: la separación es por persona, no por grupo.

Consecuencias

  • Una anulación siempre tiene dos personas distintas y un asiento por acto; revertirla exige un acto nuevo, no un UPDATE.
  • Los tests que anulaban a mano deben pasar por AnulacionService o aprobar en la misma transacción (HINT anulacion_requiere_aprobacion).