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:
- Una sola persona podía bajar la protección de lo clasificado.
PERM_RECLASIFICARcrud 3 bastaba para pasar un radicado del nivel 3 al 1; el único control era el asiento posterior. - El gate de clearance no estaba en el
WHERE.get_security_contextleía sin clearance niFOR UPDATEy 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). 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.- 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.
- 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¶
- 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ántests/test_causales_reserva.pycompara: la constante, elCHECK (nivel_anterior = N)dereclasificacion_solicitudesy la comparaciónOLD.nivel_seguridad >= Ndel trigger de frontera (una migración nueva que reescriba ambos). La tool MCP y la UI leen el umbral deGET /api/v1/documents/causales-reserva(nivel_doble_firma). - Aprobar = ejecutar. Una tabla propia
reclasificacion_solicitudes(migración050) y, al aprobar, en una sola transacción: la solicitud pasa aaprobada, el nivel baja, se asientan dos entradas (document.desclasificacion_aprobadaydocument.radicado_reclassified) y se encola el eventoreclassifiedpor el outbox. El evento solo existe al aprobar: solicitar, rechazar, retirar o caducar no cambian el acceso a nada y no emiten nada. - Quién aprueba:
PERM_RECLASIFICARcon crud 4 (mismo criterio queUSUA_PERM_TRD≥4 en el aval de eliminación), clearance suficiente para el radicado (en elWHERE) y persona distinta de quien solicitó (en elWHEREy en unCHECKde 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.pyañadePERM_RECLASIFICAR: 4aCRUD_POR_PERMISO_GESTIONy la pantalla de grupos debe advertirlo. - Cierre de las vías laterales por la base, no por el servicio. Un trigger
BEFORE UPDATE OF nivel_seguridadenradicadosrechaza (P0001,HINT = desclasificacion_requiere_aprobacion) toda bajada desde el umbral que no tenga una solicitudaprobadaconresuelto_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). - El
PATCHdirecto desde el umbral responde409 desclasificacion_requiere_aprobacioncon la ruta de la solicitud: el contrato síncrono no cambia y el cliente sabe qué hacer (no se convirtió en202, que mezclaría dos actos con permisos distintos). Orden: 404 (no existe o supera el clearance) → 409 → 403 (no-write-up). - El gate de clearance pasa al
WHEREtambién en elPATCH(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ásAND nivel_seguridad = $esperado: si el nivel cambió entre la lectura y la escritura, 409nivel_cambioy se revierte. - 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) reiniciabaclasificado_aty 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ón051) que leen el servicio (get_security_context_for_updatetraemeses_desde_generacionymeses_hasta_clasificacion) y elCHECK radicados_reserva_tope_15_anios, que es la última frontera.clasificado_atsigue 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 apor_clasificaren la propia051(sin job de backfill). El endpoint interno de clasificados llevafecha_generacion. - Re-motivación al mismo nivel:
PATCHcon el mismo nivel y causal, fundamento o plazo distintos corrige la metadata sin tocarnivel_seguridad,clasificado_atni la publicación; asiento propio (document.radicado_remotivado) y sin evento. Repetir lo mismo sigue siendo un no-op idempotente. - Caducidad perezosa a los 15 días (medidos por el servidor,
vence_atconDEFAULT):vence_at > now()va en elWHEREde aprobar y rechazar; la cola filtra por él; al pedir de nuevo sobre el mismo radicado se materializapendiente → caducada(asientodocument.desclasificacion_caducada, actorNULL, canalsystem) en la transacción delINSERT. Las lecturas devuelvenestado_efectivo. Sin job. - 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; elaudit_logconserva la traza). La aprobación toma los cerrojos en el mismo orden que elPATCHy que la solicitud (primero el radicado, luego la solicitud), así que no hay interbloqueo; dos aprobadores simultáneos dan una ejecución y un409 solicitud_ya_resuelta. - Cola:
GET /api/v1/desclasificaciones(crud 4), FIFO, con el recorte de clearance en la misma condición para las filas y paraX-Total-Count(si no, la diferencia sería la existencia de solicitudes ajenas); marcapropialas del llamante.
C12 — Catálogo cerrado de causales y índice público por serie¶
- 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 constanteCAUSALESde document-service (manda), elCHECK radicados_causal_catalogode la050y elenumde la tool MCP; los atatests/test_causales_reserva.py(los dos primeros) yshared/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). - Reglas: nivel 3 ⇒ causal
art18_*y sin plazo (se mantiene mientras subsista el derecho [confirmar]); nivel 2 ⇒ causalart19_*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 delCHECK. por_clasificares el valor honesto de lo que quedó clasificado sin causal: lo escriben solo la propia migración (el paso va en la050, 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 comocausal_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.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.- Í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 conexpediente_radicados(vínculosvinculado) →expedientes.trd_serie_id→trd_series(+ serie madre y dependencia productora) y lo publica enGET /api/v1/public/archive/indice-clasificados/{tenant}(y.csv). Es una obligación legal: no cuelga depublic_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). - 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). Elfundamento_normaes 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ón051, conCHECKy la misma regla envalidar_cita_norma), distinta delfundamento_juridicolibre, 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, sinfundamento_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). - El índice interno por radicado (
/reports/indice-reservado) sigue conPERM_RECLASIFICARy añadecausal_articuloycausal_descripciondel catálogo.
Cierre del lote C (auditoría de cierre)¶
- Índice y suspensión:
GET /api/v1/internal/documentos/clasificadosse admite con la entidad suspendida (lista explícita método + ruta enapp/core/conservacion.py, como la conservación de storage: solo lectura,X-Internal-Tokeny asientoentidad.conservacion_admitida); sin esto el índice público respondía 503 justo cuando la entidad está suspendida y distinguía «suspendida» de «inexistente». Unainactiveo sin aprovisionar sigue vetada. aprobar_desclasificacionsale 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 aNO_OFRECIDAScon 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 elaudit_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
PATCHrecibe409. La UI y el MCP deben ofrecer la solicitud (solicitar_desclasificacion) y la cola.causal_reservadeja 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
050antes de arrancar; archive-service pide el endpoint interno nuevo (desplegar document antes). Las filas clasificadas con causal libre quedanpor_clasificary aparecen así en el índice hasta que se re-motiven: es el efecto buscado, no una regresión. - Eventos: el payload de
reclassifiedgana campos (solicitud_id,solicitado_por,aprobado_por) en la aprobación; los consumidores (signature, knowledge) solo leennivel_nuevo. Eldedup_keypasa 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.decideno comparasolicitado_porcondecidido_por): fuera de esta tanda, anotado en el triage; (e) expedientes reservados en el índice por serie; (f) citas normativas marcadas [confirmar]: las contrastaarchival-compliance-auditorantes del merge.