Skip to content

ADR-039: Desclasificación de lo clasificado con segunda persona y catálogo cerrado de causales de reserva

Estado: Aceptado Fecha: 2026-10-21 Autores: Giampiero (mantenedor principal); triage 2026-10-09, tanda C-2 (C4 y C12) Relacionados: ADR-010 (auditoría inmutable), ADR-003 (SQL crudo), ADR-021 (eventos), ADR-035 (la entidad suspendida se lee, no se muta), ADR-040 (cuadro de clasificación por serie)


Contexto

Reclasificar un radicado (PATCH /documents/{id}/security-level) decide quién puede verlo a partir de ya. Una auditoría de la tanda C encontró cuatro defectos en él y uno en el índice que la ley exige:

  1. Una sola persona podía bajar la protección de lo clasificado. PERM_RECLASIFICAR crud 3 bastaba para pasar un radicado del nivel 3 al 1; el único control era el asiento posterior.
  2. El gate de clearance no estaba en el WHERE. get_security_context leía sin clearance ni FOR UPDATE y comparaba en Python: viola el invariante 5 y deja una carrera (leer nivel 2, que otro lo suba a 3, bajarlo a 1 sin aprobación).
  3. clasificado_at = now() en cada re-motivación. Re-guardar al mismo nivel reiniciaba el reloj del plazo y permitía perpetuar una reserva más allá de los 15 años del art. 22 de la Ley 1712.
  4. Mismo nivel = no-op. No se podía corregir la causal ni el fundamento sin cambiar de nivel: una fila mal motivada no tenía salida.
  5. La causal era texto libre. El índice de información clasificada y reservada (Ley 1712 art. 20) no se podía agrupar ni auditar, y era un solo listado por radicado: la norma pide el índice por serie documental (Decreto 103 de 2015 art. 40, compilado hoy en el DUR 1081 de 2015 [confirmar campos]).

El mapa nivel↔artículo vigente es: nivel 2 = reservada (art. 19: intereses públicos), nivel 3 = clasificada (art. 18: intimidad, vida/salud/seguridad, secretos).

Decisión

C4 — Bajar desde «clasificada» exige solicitud y aprobación de otra persona

  1. Umbral NIVEL_DOBLE_FIRMA = 3 (shared/orpycamcp_common/causales_reserva.py). Decisión literal del usuario: la segunda persona protege la información clasificada (derechos de personas, art. 18); bajar de 2 a 1 (reservada, art. 19) sigue siendo directo, con asiento y evento, como antes. Para cambiarlo hay que tocar tres sitios que el guardián tests/test_causales_reserva.py compara: la constante, el CHECK (nivel_anterior = N) de reclasificacion_solicitudes y la comparación OLD.nivel_seguridad >= N del trigger de frontera (una migración nueva que reescriba ambos). La tool MCP y la UI leen el umbral de GET /api/v1/documents/causales-reserva (nivel_doble_firma).
  2. Aprobar = ejecutar. Una tabla propia reclasificacion_solicitudes (migración 050) y, al aprobar, en una sola transacción: la solicitud pasa a aprobada, el nivel baja, se asientan dos entradas (document.desclasificacion_aprobada y document.radicado_reclassified) y se encola el evento reclassified por el outbox. El evento solo existe al aprobar: solicitar, rechazar, retirar o caducar no cambian el acceso a nada y no emiten nada.
  3. Quién aprueba: PERM_RECLASIFICAR con crud 4 (mismo criterio que USUA_PERM_TRD≥4 en el aval de eliminación), clearance suficiente para el radicado (en el WHERE) y persona distinta de quien solicitó (en el WHERE y en un CHECK de la tabla). Solicitar y retirar piden crud 3. No se creó un permiso nuevo. Riesgo fail-closed mudo: si ningún rol tiene crud 4, nadie aprueba; seed_demo.py añade PERM_RECLASIFICAR: 4 a CRUD_POR_PERMISO_GESTION y la pantalla de grupos debe advertirlo.
  4. Cierre de las vías laterales por la base, no por el servicio. Un trigger BEFORE UPDATE OF nivel_seguridad en radicados rechaza (P0001, HINT = desclasificacion_requiere_aprobacion) toda bajada desde el umbral que no tenga una solicitud aprobada con resuelto_at = now(), es decir resuelta en esa misma transacción: una aprobación vieja no es pase para una desclasificación posterior. Cierra la importación, el borrador, el SQL a mano y cualquier ruta futura (frontera por admisión, no por enumeración).
  5. El PATCH directo desde el umbral responde 409 desclasificacion_requiere_aprobacion con la ruta de la solicitud: el contrato síncrono no cambia y el cliente sabe qué hacer (no se convirtió en 202, que mezclaría dos actos con permisos distintos). Orden: 404 (no existe o supera el clearance) → 409 → 403 (no-write-up).
  6. El gate de clearance pasa al WHERE también en el PATCH (get_security_context_for_update: ... AND nivel_seguridad <= $2 FOR UPDATE; None = 404 neutro, sin distinguir inexistente de inalcanzable, y sin 409 que revele el nivel). El UPDATE lleva además AND nivel_seguridad = $esperado: si el nivel cambió entre la lectura y la escritura, 409 nivel_cambio y se revierte.
  7. El tope de 15 años corre desde la GENERACIÓN de la información, no desde su clasificación (Ley 1712 art. 22; Decreto 103 de 2015 art. 29, hoy DUR 1081 de 2015 [confirmar]). La versión anterior de este punto decía que plazo ≤ 180 meses «ES el tope», y no lo era: un documento de 10 años reservado hoy a 180 meses quedaba reservado 25 años desde su generación, y cambiar de categoría (2→3→2) reiniciaba clasificado_at y alargaba la reserva otra vez. Ahora el plazo se mide siempre contra la generación (registered_at: la fecha de radicación o, en un paquete importado, la del documento): plazo_reserva_meses ≤ 180 − meses transcurridos hasta la clasificación. Lo hace una sola cuenta (reserva_meses_entre, función de la migración 051) que leen el servicio (get_security_context_for_update trae meses_desde_generacion y meses_hasta_clasificacion) y el CHECK radicados_reserva_tope_15_anios, que es la última frontera. clasificado_at sigue sin reiniciarse si el nivel no cambia (la re-motivación mide contra la clasificación vigente); si el nivel cambia (2→3→2) pasa a ser hoy, pero el plazo permitido ya descuenta lo corrido desde la generación. Una reserva que ya cumplió los 15 años no se puede constituir: 422 reserva_agotada; un plazo que excede lo que resta, 422 plazo_excede_tope_legal. Lo anterior que excedía el tope pasa a por_clasificar en la propia 051 (sin job de backfill). El endpoint interno de clasificados lleva fecha_generacion.
  8. Re-motivación al mismo nivel: PATCH con el mismo nivel y causal, fundamento o plazo distintos corrige la metadata sin tocar nivel_seguridad, clasificado_at ni la publicación; asiento propio (document.radicado_remotivado) y sin evento. Repetir lo mismo sigue siendo un no-op idempotente.
  9. Caducidad perezosa a los 15 días (medidos por el servidor, vence_at con DEFAULT): vence_at > now() va en el WHERE de aprobar y rechazar; la cola filtra por él; al pedir de nuevo sobre el mismo radicado se materializa pendiente → caducada (asiento document.desclasificacion_caducada, actor NULL, canal system) en la transacción del INSERT. Las lecturas devuelven estado_efectivo. Sin job.
  10. Una solicitud viva por radicado (índice único parcial); una resuelta no se reescribe ni se borra (trigger), salvo en cascada desde la purga del radicado por disposición final (ON DELETE CASCADE; el audit_log conserva la traza). La aprobación toma los cerrojos en el mismo orden que el PATCH y que la solicitud (primero el radicado, luego la solicitud), así que no hay interbloqueo; dos aprobadores simultáneos dan una ejecución y un 409 solicitud_ya_resuelta.
  11. Cola: GET /api/v1/desclasificaciones (crud 4), FIFO, con el recorte de clearance en la misma condición para las filas y para X-Total-Count (si no, la diferencia sería la existencia de solicitudes ajenas); marca propia las del llamante.

C12 — Catálogo cerrado de causales y índice público por serie

  1. 13 causales (art. 18 lit. a-c; art. 19 lit. a-i y parágrafo) más por_clasificar. Viven en tres sitios que deben decir lo mismo: la constante CAUSALES de document-service (manda), el CHECK radicados_causal_catalogo de la 050 y el enum de la tool MCP; los ata tests/test_causales_reserva.py (los dos primeros) y shared/tests/test_causales_reserva_una_sola_lista.py (el tercero, que solo se ve desde el repositorio). Es un catálogo legal, no un dato de la entidad: no es una tabla editable (un cambio de ley = una migración).
  2. Reglas: nivel 3 ⇒ causal art18_* y sin plazo (se mantiene mientras subsista el derecho [confirmar]); nivel 2 ⇒ causal art19_* y plazo 1 a 180 meses obligatorio (art. 22); nivel 1 ⇒ ni causal ni plazo. Una sola función (validar_clasificacion) sirve a la radicación, a la reclasificación y a la solicitud, y es la misma regla del CHECK.
  3. por_clasificar es el valor honesto de lo que quedó clasificado sin causal: lo escriben solo la propia migración (el paso va en la 050, sin job de backfill ni rama «legado»), la importación INT-05 (causal ausente o fuera de catálogo; el texto original va al asiento del import como causal_original) y la herencia de un borrador cuyo padre no la tiene. La API lo rechaza con 422. Se publica tal cual en el índice («Pendiente de motivar») y se resuelve re-motivando.
  4. fundamento_juridico (texto) se conserva: es la norma especial concreta que sustenta la reserva (p. ej. Ley 1581 de 2012) y es interno; nunca sale en el índice público porque podría transportar contenido.
  5. Índice público por serie compuesto por archive-service (dueño del CCD/TRD): pide a document-service GET /api/v1/internal/documentos/clasificados (content-free por construcción: ni número, ni asunto, ni fundamento; más el catálogo, para que archive no tenga una segunda copia de la ley), lo cruza con expediente_radicados (vínculos vinculado) → expedientes.trd_serie_id → trd_series (+ serie madre y dependencia productora) y lo publica en GET /api/v1/public/archive/indice-clasificados/{tenant} (y .csv). Es una obligación legal: no cuelga de public_interop_enabled. Una entidad inexistente devuelve lo mismo que una sin clasificados (lista vacía); una suspendida se sirve (ADR-035); si document-service no responde, 503 (nunca una lista parcial presentada como completa).
  6. Fila (Decreto 103 de 2015 art. 40, DUR 1081 de 2015 [confirmar campos]): serie y subserie (código y nombre), categoría (clasificada/reservada), causal (código, artículo, literal y descripción), fundamento legal publicable (fundamento_norma), excepción (total), rango de la fecha de generación (desde/hasta, por grupo), fecha de calificación (desde/hasta), plazo máximo (null = mientras subsista el derecho) y responsable de la información (responsable_informacion: la productora de la serie en la TRD, no la del radicado). El fundamento_norma es una cita normativa controlada por formato (tipo de norma + número/artículo, p. ej. «Ley 1581 de 2012, art. 5»; radicados.clasificacion_norma, migración 051, con CHECK y la misma regla en validar_cita_norma), distinta del fundamento_juridico libre, que sigue siendo interno: separarlos es lo que permite publicar el fundamento sin arriesgar que un texto libre transporte contenido. La cita parte el grupo (dos citas distintas no se mezclan). Sin conteos, sin números de radicado, sin ids, sin asunto, sin fundamento_juridico. Un radicado en N expedientes con serie aparece en N series; sin expediente con serie va en «Sin serie documental asignada» (honesto: señala el rezago).
  7. El índice interno por radicado (/reports/indice-reservado) sigue con PERM_RECLASIFICAR y añade causal_articulo y causal_descripcion del catálogo.

Cierre del lote C (auditoría de cierre)

  • Índice y suspensión: GET /api/v1/internal/documentos/clasificados se admite con la entidad suspendida (lista explícita método + ruta en app/core/conservacion.py, como la conservación de storage: solo lectura, X-Internal-Token y asiento entidad.conservacion_admitida); sin esto el índice público respondía 503 justo cuando la entidad está suspendida y distinguía «suspendida» de «inexistente». Una inactive o sin aprovisionar sigue vetada.
  • aprobar_desclasificacion sale del MCP: aprobar ejecuta el cambio y exige ver el documento, y el asistente no lo muestra (a quien consulta con un nivel inferior ni siquiera se le puede enseñar). Pasa a NO_OFRECIDAS con su vía alternativa (la pantalla /desclasificaciones); pedir, consultar, rechazar y retirar se quedan.
  • Cita normativa en los actos: fundamento_norma (opcional) viaja en la radicación, la reclasificación y la solicitud de desclasificación, y se asienta en el audit_log.

Alternativas descartadas

Eje Descartada Motivo
Modelo de aprobación Generalizar radicado_anulacion a «solicitudes de acto» Estados y campos distintos; hereda la falta de separación de funciones de la anulación
Tres pasos (propone ≠ aprueba ≠ ejecuta) como en archive Allí se justifica por la destrucción con acta; aquí la ejecución es un UPDATE sin acta y un tercer actor solo añade latencia
Cierre de vías laterales Solo en el servicio Cualquier ruta nueva (import, borrador, SQL) lo salta en silencio
PATCH desde el umbral 202 creando la solicitud Cambia el response_model y mezcla dos actos con permisos distintos
Catálogo Tabla global con FK FK entre schemas; editable por quien no debe
Índice por serie Proyección en archive alimentada por reclassified La radicación con nivel no emite ese evento: derivaría
document compone pidiendo a archive el mapa radicado→serie Cliente nuevo y URL nueva en la configuración, para lo que ya hace el cliente existente en el otro sentido
Umbral NIVEL_DOBLE_FIRMA = 2 (toda desclasificación) Decisión del usuario: la reserva del art. 19 protege intereses públicos y su levantamiento sigue siendo de una persona

Consecuencias

  • Contrato: quien desclasificaba desde 3 con un PATCH recibe 409. La UI y el MCP deben ofrecer la solicitud (solicitar_desclasificacion) y la cola. causal_reserva deja de ser texto libre: el <select> del catálogo (filtrado por nivel destino) sustituye al campo, y el plazo es obligatorio solo en art. 19.
  • Despliegue: document-service migra 050 antes de arrancar; archive-service pide el endpoint interno nuevo (desplegar document antes). Las filas clasificadas con causal libre quedan por_clasificar y aparecen así en el índice hasta que se re-motiven: es el efecto buscado, no una regresión.
  • Eventos: el payload de reclassified gana campos (solicitud_id, solicitado_por, aprobado_por) en la aprobación; los consumidores (signature, knowledge) solo leen nivel_nuevo. El dedup_key pasa a {tracking}:{nivel}:{sello|solicitud}: un ciclo 1→3→1→3 ya no repite clave.
  • Declarado, no hecho (cerrados en el lote D por ADR-045 —punto d— y ADR-046 —puntos a, b, c—) (follow-ups): (a) desclasificación de oficio por vencimiento del plazo (no es discrecional y no pediría segunda persona; job aparte); (b) idioma y medio de conservación del Decreto 103 art. 40 (no están modelados; el rango de generación, el fundamento legal y el responsable ya salen en el índice); (c) adopción del índice por acto administrativo y versión fechada; (d) la anulación de radicados no tiene separación de funciones (anulacion_repository.decide no compara solicitado_por con decidido_por): fuera de esta tanda, anotado en el triage; (e) expedientes reservados en el índice por serie; (f) citas normativas marcadas [confirmar]: las contrasta archival-compliance-auditor antes del merge.