Skip to content

ADR-061: Resolución de la revisión por fin de vigencia (cerrado_tras_fin_de_vigencia)

Estado: Aceptado Derogado en parte por ADR-065: destrucción en fases, «punto de no retorno» y causa WORM. Siguen vigentes la resolución de la revisión, la compuerta y la omisión. Fecha: 2026-10-30 Autores: Giampiero (mantenedor principal); salda la deuda declarada de ADR-059 («se señala, no se reclasifica») Relacionados: ADR-025 (amarre inmutable tras el cierre), ADR-044 (search_path), ADR-050 (selección), ADR-053 (criterio ACT del MCP)


Contexto

El ADR-059 hizo que la vigencia de una versión de TRD fuera exacta ([convalidación, valid_to)). Con un acto retroactivo (relevo o derogación) quedan expedientes cerrados entre el acto y su registro bajo una versión que ya no regía. El sistema los señalaba (trd_revision_requerida, motivo cerrado_tras_fin_de_vigencia) pero la marca no tenía salida: ninguna acción la limpiaba y el amarre es inmutable tras el cierre.

Normas (texto del Ac. AGN 001/2024, verificado contra el PDF firmado): art. 4.4.3 par. 1 (la transferencia se rige por la TRD vigente al cierre) y par. 2 (se transfiere con el índice electrónico firmado); art. 4.4.2 (cuerpo y parágrafo: no se deben incorporar nuevos documentos a lo transferido); Circular AGN 003/2015 num. 3.2 (el dato histórico no se reescribe). Lo que sigue es decisión de este ADR, no texto de la norma: que la convalidación abra la vigencia (5.1.2.7, ya adoptado en ADR-059); que lo transferido solo se «mantenga» (ancla: el cuerpo del 4.4.2 más la Circular 3.2; el parágrafo no dice «no se modifica» ni «FUID congelado»); y la fórmula «se sustituye con rastro», que es una glosa de la Circular 003/2015 num. 3.2, no una cita.

Decisiones

D1. Un acto del Comité, dos salidas, una tabla append-only. expediente_trd_resoluciones (migración 084) guarda cada resolución con su acta, el snapshot previo y el nuevo; public.audit_log lleva el asiento archive.trd_revision_resuelta (lo último dentro de la misma transacción). (a) mantener-version limpia la marca con una causal de catálogo cerrado (sin_version_regente, expediente_transferido, cierre_material_anterior) o la excepción declarada decision_comite (fundamento de 200 caracteres, el asiento marca excepcion_4_4_3), que solo se admite si re-amarrar es imposible (hay un impedimento de D4): si reamarre_posible, el Comité re-amarra (409 causal_incoherente). (b) reamarrar re-amarra a la versión que regía el día local del cierre y recalcula expedientes.disposition conservando la base original. Solo closed, solo TRD.

Elegida: tabla de resoluciones + excepción en el trigger que la exige Bandera de sesión (SET LOCAL) Re-clonar el expediente
Prueba en el motor El trigger verifica la fila del acto de la misma transacción (txid_current()), la zona y la vigencia de la destino Cualquier código que fije la bandera esquiva el trigger Rompe el número, el índice firmado y la hoja de ruta
Zona La fila guarda zona y fecha_cierre_local: el motor no depende del reloj ni de la sesión UTC Ciega —

D2. La versión destino no la elige el cliente. El servidor la resuelve: mismo code, TRD, convalidada|derogada, fecha_convalidacion <= d < COALESCE(valid_to, infinity) con d el día del cierre en la zona de la entidad. El cliente envía version_destino, disposicion_destino y retencion_total_destino_anios solo como eco de la vista previa; si difieren, 409. migrada nunca es destino. Sin regente: solo mantener (sin_version_regente).

D3. WORM: ni se prohíbe (b) ni se inventa un calendario mixto. El snapshot pasa a ser exactamente el de la destino. Si retiene menos, el candado COMPLIANCE ya puesto no se acorta y queda como piso (la eliminación ya lo respeta: worm_vigente); la respuesta declara excede_calendario. Si retiene más, se encola en expediente_worm_extension y lo consume la fase 13 del job de reconciliación (con backoff) tras un intento síncrono post-commit. Storage es monotónico: reintentar es seguro.

D4. Impedimentos de (b) (409): expediente_transferido, reamarre_solo_trd (TVD), transferencia_en_curso, expediente_en_lote_eliminacion, disposicion_ya_decidida, seleccion_trd_registrada, sin_version_regente_al_cierre, version_regente_ambigua.

D5. Mientras la marca esté, el expediente no avanza. 409 trd_revision_pendiente en preparar y recibir una transferencia (también la vía corta POST /expedientes/{id}/transferir), decidir la disposición vencida y registrar la selección S/M; la propuesta, el aval y la ejecución de un lote de eliminación lo rechazan (la propuesta con el motivo trd_revision_pendiente; aval y ejecución, con FOR KEY SHARE sobre todo el inventario antes de tocar nada). Todo con FOR KEY SHARE sobre la fila (lock-then-check contra el FOR UPDATE del acto; KEY SHARE y no SHARE porque dos recibir concurrentes con SHARE caían en deadlock al hacer su UPDATE). Además, fail-closed: sobre un expediente re-amarrado esos mismos actos exigen que el índice vigente tenga worm_protegido_at; si falta, 409 indice_sin_worm. La compuerta devuelve lo que la fila dice bajo el bloqueo y los actos que leyeron el snapshot antes (decidir, preparar, proponer) lo comparan: si difiere, 409 trd_revision_carrera. El gate de índice firmado de preparar se evalúa dentro de la transacción (art. 4.4.3 par. 2). El no-read-up de la selección va en el WHERE de la compuerta (fuera de alcance es 404, no 409).

D6. Permisos. Vista previa: USUA_PERM_TRD nivel 1 y clearance en el WHERE (404 indistinguible). Los dos POST: nivel 3 (el 4 es de avalar/ejecutar la eliminación), require_actor fail-closed, sin gate de clearance en la mutación (disposición ⊥ lectura) y respuesta acotada construida desde la fila bloqueada. Los intentos denegados dejan un asiento best-effort archive.trd_revision_resolucion_fallida.

D7. MCP. Se ofrece la lectura consultar_resolucion_revision_trd; mantener-version y reamarrar no se ofrecen (motivo ACT: son la constancia de una decisión del Comité tomada fuera del sistema, el mismo criterio que avalar un lote o convalidar una TRD).

D8. El índice nuevo se protege bajo WORM como una versión encadenada. Storage tenía un único objeto WORM indice por expediente (índice único parcial de la 009). La migración storage 027 lo relaja a la raíz de la cadena (renueva_a IS NULL, igual que el AIP en la 023) y añade unicidad por (expediente_id, file_id): la identidad del objeto pasa a ser (expediente, 'indice', file_id), de modo que la 2.ª versión no responde «ya protegido» sobre la 1.ª, y un file_id ajeno sin declarar nueva_version sigue siendo conflicto. Se descartó un tipo nuevo indice_version: obligaba a tocar el CHECK de tipos, el listado de renovación y el contrato de archive en tres sitios, y la cascada de renovación (que desciende por renueva_a desde la raíz) ya da lo que se quiere (la versión vive al menos tanto como el índice original). La retención de la versión nueva sale de la TRD destino (archive protege con el amarre vivo). El barrido de la fase 13 reempaqueta entonces el AIP con nueva_version_indice: storage exige que el índice sea distinto del embebido y el más reciente protegido, y el reempaquetado deja en el AIP y en preservacion_evento un evento PREMIS de modificación de metadatos que enlaza el resolucion_id.

Consecuencias

  • Migración archive 084 (tabla, trigger del amarre reemplazado —que exige además que el snapshot nuevo sea exactamente el del acto—, cola WORM, motivo nueva_version_indice) y storage 026 (aip.motivo, columna ensanchada a VARCHAR(32)) y 027 (índice versionado). Sin backfill.
  • Re-amarrar cambia el contenido del índice (lleva serie_id y nombres): se emite una versión nueva, se sella tras el commit y se protege bajo WORM; la firmada anterior se conserva (append-only, WORM).
  • Nueva métrica de agotamiento extension (expediente_worm_extension.extension_agotada).

Deuda declarada

  • La renovación rodante de un índice CT alcanza las versiones anteriores solo por la cascada desde la raíz; no hay prueba contra un almacén S3 real del circuito completo (la integración de storage usa el SQL real y un almacén en memoria).
  • La ejecución del lote comprueba la compuerta antes de purgar el primer expediente, no entre expedientes: una marca que llegue a mitad de una ejecución se verá en la siguiente.
  • Un cerrado bajo migrada (cerrado_bajo_serie_migrada) relevado después con acto retroactivo conserva el motivo viejo (AND NOT trd_revision_requerida); necesita su propia salida.
  • TVD: solo (a). Re-amarrar una TVD exigiría decidir el hold de valoración y la fecha_extrema_final.
  • El acta del Comité es texto: no se adjunta el documento ni se exige radicado del acta.
  • La extensión WORM agotada se ve en los logs, el asiento archive.worm_extension_agotada y la métrica; no hay pantalla.
  • ~~Zona por despliegue (TZ_RADICACION), no por tenant~~ — saldada por ADR-062: la zona es de la entidad.
  • expedientes.disposition sigue sin trigger propio: solo el re-amarre lo ata al amarre.

Enmienda del lote 4 (2026-10-30)

  • TRD-09: _eliminar_expediente re-comprueba trd_revision_pendiente/indice_sin_worm por expediente bajo FOR KEY SHARE sostenido hasta la lápida; el marcado se omite con su motivo en el informe y su asiento, sin abortar el lote.
  • TRD-06: listado GET /expedientes/indices/extensiones-worm-agotadas y reintento manual POST /expedientes/{id}/worm-extension/{artefacto}/reintentar (asiento archive.worm_extension_reintento_manual).
  • TRD-11: migración tenant 086, trg_expedientes_disposition_inmutable: solo el re-amarre de su misma transacción reescribe disposition de un cerrado.
  • TRD-08: todo «hoy» de negocio de archive sale de app/core/hoy.py (zona de la entidad). SQL CURRENT_DATE queda como deuda.

Enmienda de la destrucción en fases (2026-10-30)

La enmienda anterior sostenía el FOR KEY SHARE «hasta la lápida y el borrado», es decir, durante la llamada HTTP irreversible de purga a document-service: una transacción abierta a merced de un tercero. Se rediseña en tres fases idempotentes (eliminacion_lote_progreso, migración 088):

  1. Intención (transacción corta, FOR KEY SHARE): re-chequeo de la marca (trd_revision_pendiente / indice_sin_worm) + registro de la intención con los radicados a destruir; COMMIT.
  2. Purga sin transacción; se guarda el recuento real.
  3. Cierre (transacción corta): lápida + soportes + borrado + asiento.

Decisión: el re-chequeo de la fase 1 es el punto de no retorno. Un expediente que lo supera estaba legítimamente a disposición; una marca que llegue después no puede dejar una destrucción a medias, así que (a) el reintento retoma la purga sin volver a consultar la marca, (b) marcar_revision_por_fin_de_vigencia no marca un expediente con intención registrada y (c) anular se rechaza con una intención registrada. Marca e intención son mutuamente excluyentes en ambos órdenes de llegada: si la marca gana el bloqueo, la ejecución la ve y omite el expediente; si gana la intención, la marca no se escribe. Para que (b) sea cierta bajo READ COMMITTED, marcar_* bloquea (SELECT … FOR UPDATE) en una sentencia y escribe en otra: con una sola, el UPDATE que esperó el bloqueo re-evalúa solo la fila y no el subselect NOT EXISTS, y marcaría un expediente cuya destrucción ya empezó (probado con dos conexiones reales).

Omitidos (art. 4.5.4 num. 6-7 y par. 1). El expediente omitido no es un fallo: el lote termina ejecutado_parcial, el acto de retirarlo del lote (motivo obligatorio, crud≥4) lo libera para resolver su revisión, y un addendum al acta —sellado y bajo WORM como ella— consigna lo realmente eliminado y lo omitido.