Saltar a contenido

ADR-059: Vigencia de las versiones de la TRD/TVD sin solape

Estado: Aceptado Fecha: 2026-10-30 Autores: Giampiero (mantenedor principal); épica «vigencia sin solape», salda la deuda declarada en ADR-058 (2.º punto) Relacionados: ADR-025 (TRD versionada), ADR-054 (cuadro público e histórico), ADR-058 (gate de formato), ADR-044 (search_path)


Contexto

Al convalidar la versión N+1, la N quedaba derogada con valid_to = CURRENT_DATE: el día en que se ejecutó la derogación (en la zona de la sesión de PostgreSQL, UTC), no el del acto. Como la N+1 se convalida casi siempre con una fecha_convalidacion retroactiva (el acto llega antes de que alguien lo registre), las dos versiones cubrían [fecha_convalidacion(N+1), hoy). Además:

  • fecha_convalidacion no tenía ningún límite: se aceptaba una fecha futura y una N+1 anterior o igual a la de la N.
  • Dos remedios de lectura gemelos escondían el defecto: document (_una_version_por_serie, el gate de formato) se quedaba con la de vigente_desde mayor, y archive (_HASTA, cuadro público e histórico) tomaba el mínimo entre valid_to y la convalidación de la siguiente. Cada lector decidía por su cuenta.
  • El CHECK trd_series_vigencia_check (037) exigía valid_to >= valid_from, pero valid_from es la fecha de registro de la fila: una migrada cargada hoy y relevada por una N+1 convalidada en marzo reventaba en cuanto valid_to pasara a ser la fecha del acto.
  • reject_trd_version_mutation (082) no protegía valid_to ni el estado de una derogada: el motor dejaba reescribir el fin de vigencia y «resucitar» una versión derogada.
  • Las fechas del día eran datetime.now(UTC).date() en archive y la zona de radicación (America/Bogota) en document.

Decisión

D1. Vigencia semiabierta [desde, valid_to); valid_to(N) = fecha_convalidacion(N+1)

valid_to pasa a significar el primer día en que la versión ya no rige (exclusivo). Todos los lectores ya comparaban valid_to > d, así que N y N+1 quedan contiguas, sin hueco ni solape. Una derogación el mismo día de la convalidación da el rango vacío [d, d) (nunca rigió), que es verdad y no rompe nada. Alternativa descartada: valid_to = fecha(N+1) − 1 (inclusivo): obliga a reescribir todos los lectores a >=, deja un día de hueco con cualquiera que siga en > y no tiene representación para «derogada el mismo día».

desde es la fecha_convalidacion; en una migrada (sin acto) el sistema solo conoce valid_from (registro) y, si hay certificado RUSD con rusd_convalidacion_declarada, la fecha del acto que se ratificó. valid_from no cambia: se documenta como «fecha de registro de la versión», no como inicio de vigencia.

D2. El servicio escribe valid_to; el motor garantiza lo que no depende del reloj

Acto valid_to Quién
Convalidar N+1 desde aprobada (TRD y TVD) fecha_convalidacion de la N+1, en el mismo UPDATE que pone estado='derogada' servicio (parámetro, no CURRENT_DATE)
Ratificar migrada → convalidada ninguno (la misma fila sigue vigente) —
Derogar sin sucesora fecha_acto del acto de derogación (cuerpo obligatorio) servicio (parámetro)
Sobre una derogada nada, nunca motor

Se descartó fijarlo en un trigger AFTER UPDATE de la N+1: movería una fila distinta desde un trigger (efecto lateral invisible al asiento) y obligaría a promover la N+1 antes de derogar la N, violando ux_trd_series_vigente.

Lock-then-check. Dentro de la transacción, SELECT … FOR UPDATE de la vigente (trd_series; v_tvd_agrupaciones para TVD), comprobación del orden sobre la fila bloqueada y UPDATE … WHERE id = $1 AND valid_to IS NULL por id. Sin el bloqueo, una ratificación concurrente de la migrada le pone una fecha_convalidacion que el chequeo no vio y el CHECK del motor reventaba con un 500.

D3. Casos

  • migrada relevada: estado='derogada', valid_to = fecha_convalidacion(N+1), fecha_convalidacion sigue NULL. Puede quedar valid_to < valid_from (la N+1 se convalidó antes de que la migrada se cargara): la migrada nunca rigió en el sistema, y document no la devuelve. Si su certificado declara la fecha del acto que la convalidó, la N+1 debe ser posterior; sin certificado no hay cota (no se inventa una con valid_from).
  • migrada ratificada: fecha no futura; <= rusd_certificado_fecha; y, si hay rusd_convalidacion_declarada, igual a ella: el certificado declara la fecha del acto que se ratifica, y dos fechas del mismo acto son dos inicios de vigencia.
  • TVD: comparte derogar_por_relevo. trd_series_tvd_soporte_check exime a derogada; como valid_to se escribe en el mismo UPDATE que el estado, una TVD convalidada antes de la 082 sin soporte (la TVD-T12 de tenant_demo) se puede relevar o derogar. Partirlo en dos UPDATE lo prohíbe el CHECK.
  • Derogación sin sucesora (ronda de auditoría): lleva su acto (Ac. 001/2024 5.1.4.3/5.1.4.4): cuerpo obligatorio {acto_administrativo, fecha_acto, instancia}, fecha_acto no futura y estrictamente posterior al inicio de la vigencia; valid_to = fecha_acto y el acto va al asiento. La baja es definitiva y fija su fin; si luego aparece un acto sustituto anterior a esa fecha no cabe sin solapar (409 trd_vigencia_solapada). La UI lo advierte y pide los tres datos.
  • Versiones ya derogada: no se tocan (sin backfill). Su valid_to viejo (día de ejecución) queda; desde la 083 el motor lo congela. Un par solapado de tenant_demo puede devolver dos candidatos en fechas pasadas; es dato de prueba y se acepta.

D4. Reglas de fecha (servicio, zona de la entidad)

Un helper único, orpycamcp_common.zona (zona_de(nombre), degrada a UTC con aviso; hoy_en(zona, ahora)), lo usan document (año del radicado y fecha base, sin gemelo) y archive (hoy la zona de la entidad, ADR-062; antes TZ_RADICACION). Las reglas viven en un módulo puro, app/core/vigencia.py:

Regla Código (status)
Fecha de acto no futura: fecha_aprobacion_comite, fecha_convalidacion, fecha_publicacion, fecha y fecha_convalidacion_declarada del certificado 422 fecha_de_acto_futura (+ campo, hoy)
N+1: fecha_convalidacion > desde(vigente) (estricto) 422 fecha_convalidacion_no_posterior_a_la_vigente (+ version, vigente_desde)
Convalidación no anterior a la aprobación del Comité de la misma versión 422 fecha_convalidacion_anterior_a_la_aprobacion
Ratificación con certificado declarado: igual a rusd_convalidacion_declarada 422 fecha_convalidacion_distinta_de_la_declarada
fecha_publicacion >= fecha_convalidacion (convalidar y registrar-rusd) 422 fecha_publicacion_anterior_a_la_convalidacion
Derogación: fecha_acto no futura y > inicio de la vigencia 422 fecha_de_acto_futura / 422 fecha_derogacion_no_posterior_a_la_vigencia
Respaldo del motor: solape con otra versión del código 409 trd_vigencia_solapada (+ versión en conflicto)
Respaldo del motor: valid_to < fecha_convalidacion (solo con datos viejos) 409 trd_vigencia_inconsistente

El orden es estricto: igual fecha deja a la N con rango vacío y vuelve a pedir desempate por versión, que es lo que se retira. No hay CHECK fecha <= CURRENT_DATE en el motor: depende del reloj y de la zona (la sesión es UTC; entre las 19:00 y las 24:00 de Bogotá sería más laxo que el servicio), no es un invariante de la fila y rompería un dump/restore. vigente_en_futuro y el día por omisión del cuadro público se miden en la misma zona.

D5. Se retiran los dos remedios de lectura

_una_version_por_serie pasa a _la_trd_anula_al_suelto (conserva la regla del tipo suelto y pierde el desempate por versión); _HASTA se borra y el cuadro público lee valid_to (historial.vigente_hasta = valid_to). Mantenerlos como defensa en profundidad sería una rama de compatibilidad para un estado que el motor ya no admite, y dos sitios más que repiten la regla. El guardián es el trigger y sus pruebas. Varias filas siguen siendo legítimas solo cuando el mismo code vive en series distintas (ambigüedad de código, ADR-058). Esto reemplaza el comentario de _HASTA de ADR-054 y el punto de deuda de ADR-058.

D6. Migración 083 (tenant)

  1. trd_series_vigencia_check reemplazado (NOT VALID, con intento de VALIDATE y aviso): version >= 1, (estado = 'derogada') = (valid_to IS NOT NULL) y valid_to >= fecha_convalidacion cuando ambos existen. Sale valid_to >= valid_from.
  2. reject_trd_version_mutation: cuerpo íntegro de la 082 + valid_to inmutable una vez escrito + derogada terminal. El congelado del acto (acto_administrativo, fecha_convalidacion, instancia_convalidante, rusd_radicado, fecha_publicacion) deja de valer solo para convalidada y vale también para derogada; acta_comite y fecha_aprobacion_comite quedan inmutables en convalidada, derogada y mientras la versión siga aprobada (solo devolver las limpia). Antes un UPDATE directo reescribía el acto de una versión ya derogada.
  3. assert_trd_vigencia_disjunta() + trg_trd_series_vigencia_disjunta (BEFORE INSERT OR UPDATE OF estado, fecha_convalidacion, valid_to): dos versiones del mismo código e instrumento no rigen el mismo día (exclusion_violation, constraint trd_series_vigencia_disjunta). La función lleva SET search_path FROM CURRENT (como la 070/077) para resolver trd_series en el schema del tenant.

D7. Expedientes cerrados tras el fin de vigencia

Un relevo o una derogación con acto retroactivo deja cerrados, bajo una versión que ya no regía, los expedientes que se cerraron entre el acto y su registro (Ac. 001/2024 art. 4.4.3 par. 1: el expediente se rige por la TRD vigente al momento de su cierre; y 5.1.2.7: acto de convalidación; su fecha abre la vigencia, ADR-059). En la misma transacción se marcan trd_revision_requerida=true con motivo cerrado_tras_fin_de_vigencia (patrón de cerrado_bajo_serie_migrada) los expedientes de la versión con closed_at en la zona de la entidad >= valid_to, sin pisar otra marca y sin mutar su instantánea; el conteo va al asiento (expedientes_marcados_revision). No hay vía automática: exige decisión archivística.

Nota (2026-10-30): abrir y reclasificar también miran la vigencia. Como el cierre se rige por la TRD vigente a esa fecha (Ac. 001/2024 4.4.3 par. 1), abrir bajo una derogada deja el expediente sin instrumento que lo gobierne: el cierre da 409 trd_version_no_vigente y se esquiva trd_derogacion_bloqueada_expedientes_abiertos, que solo se mira al derogar (por API/MCP; la UI no la ofrece). Ahora todo escritor de expedientes (POST /expedientes, el PATCH que cambia trd_serie_id y la carga masiva, ítem por ítem) pasa por un solo helper, app/services/amarre_trd.py::amarrar_serie (guardián test_todo_insert_de_expedientes_pasa_por_el_amarre.py): resuelve la vigente por código (mismo criterio que el cierre: convalidada/migrada con valid_to IS NULL, TRD y TVD), guarda el id de la vigente (no el que manda el cliente), valida la plantilla de metadatos de ESA versión y, si no hay, responde 409 trd_serie_no_vigente (solo el código; no se revela el estado de la pedida). El asiento archive.expediente_created (y el del lote) lleva trd_serie_id (el amarrado, el del INSERT) y trd_serie_id_solicitado si difiere.

Clasificación provisional (5.1.2.6). Desde la aprobación la entidad «podrá organizar» con la TRD: si el código no tiene vigente y la fila pedida está aprobada, se amarra ESA fila (clasificacion_provisional: true en la respuesta y en el asiento). Al convalidar, la misma fila pasa a convalidada: el amarre no se re-apunta. Nunca borrador. El cierre conserva su 409 trd_version_no_vigente mientras no se convalide. devolver (aprobada -> borrador) informa cuántos expedientes abiertos están amarrados a esa fila (expedientes_abiertos_amarrados, en la respuesta y el asiento) y no los bloquea; el recuento de derogar es por código y cubre también los amarrados a una aprobada. Esto revisa la decisión inicial de esta nota (no admitir la aprobada).

Carreras. La lectura va FOR SHARE dentro de la transacción del INSERT/UPDATE (la FK sola solo toma FOR KEY SHARE), y derogar re-cuenta los abiertos tras tomar su lock (si aparece alguno, 409 y rollback): ninguna orden de llegada deja un expediente abierto bajo una derogada. Si la apertura esperó el lock de la N mientras un relevo la derogaba y promovía la N+1, la búsqueda se reintenta una vez en la misma transacción y amarra la N+1 (sin 409 espurio).

D8. Carreras y asientos

La ratificación de una migrada lleva en su UPDATE rusd_convalidacion_declarada IS NULL OR = fecha: si entre la lectura y la escritura se registró un certificado con otra fecha, 409 de carrera. El asiento de una migrada sin certificado declara vigencia_desde: null y, aparte, registrada_desde: valid_from.

Alternativas para el invariante del motor

EXCLUDE USING gist Trigger (elegida)
Requisitos extensión btree_gist (nueva en el proyecto) ninguno
Datos viejos no admite NOT VALID: el demo con solapes reales falla la migración o queda sin la garantía valida solo lo que se escribe desde la 083: el pasado no se reescribe
Filas sin acto o invertidas daterange(lo, hi) con lo > hi lanza error comparaciones escalares: rango vacío o invertido no solapa con nada
Concurrencia nativa ux_trd_series_en_tramite + FOR UPDATE de la vigente

Consecuencias

  • Sin backfill: lo ya derogado conserva su valid_to de ejecución; tenant_demo no se recrea.
  • Contrato: ocho códigos nuevos (422/409), valid_to documentado como exclusivo y un cuerpo obligatorio en derogar ({acto_administrativo, fecha_acto, instancia}: cambio incompatible, sin rama de compatibilidad). Ningún campo de respuesta nuevo ni retirado.
  • Los asientos archive.trd_version_derogada/tvd_… llevan valid_to y vigencia_desde (y zona en la derogación explícita); los de convalidación, derogada_valid_to. Los rechazos (422/409) no asientan: no hubo acto.
  • Frontend: min/max en las fechas de acto, aviso de derogación definitiva, vigencia «desde … hasta … (ese día ya no rige)» en el detalle y los ocho códigos con su detail. MCP: solo descripciones; aprobar, convalidar y derogar siguen fuera.

Deuda declarada

  • Resuelto (no es deuda): la vigencia rige desde la convalidación (Ac. 001/2024 5.1.2.7), el RUSD habilita efectos (5.1.5.1) y el art. 65 de la Ley 1437/2011 (mod. Ley 2080/2021, art. 15) no aplica a este acto.
  • Retroactividad por causal (5.1.5.1, par. 1): no se modela una fecha de efectos distinta de la de convalidación.
  • La organización con una aprobada (5.1.2.6) se admite como clasificación provisional solo si el código no tiene vigente; no hay una vía para listar los expedientes provisionales (se ven por devolver).
  • Las actualizaciones sin convalidación que terminan en «concepto» (5.1.4.4, par. 2) no tienen camino propio.
  • Un certificado RUSD registrado con una fecha de convalidación declarada errónea no se corrige (es inmutable, ADR-054): el camino es relevar la versión con una nueva. Un camino de corrección con asiento propio sería un acto nuevo.
  • Un expediente cerrado tras el fin de vigencia se señala; su resolución (mantener la versión o re-amarrar) quedó resuelta en ADR-061.
  • ~~Zona por tenant~~: saldada por ADR-062; TZ_RADICACION ya no existe.
  • El stub de trd_series de document-service no reproduce los invariantes de archive (deliberado).
  • Pares solapados ya existentes en tenant_demo: quedan como están.