ADR-062: Zona horaria por entidad (public.tenants.zona_horaria), congelada con el primer radicado¶
Estado: Aceptado
Fecha: 2026-11-03
Autores: Giampiero (mantenedor principal); salda la deuda declarada de ADR-059 y ADR-061 («zona por despliegue, no por tenant»)
Relacionados: ADR-035 (estado de la entidad en la puerta), ADR-044 (search_path), ADR-052 (variables retiradas sin alias)
Nota visible. La zona horaria no se puede cambiar después del primer radicado, porque cada radicado queda numerado y fechado con la hora local de su creación en esa zona, y los plazos ya corridos se contaron en ella. No hay excepción: ni para la entidad ni para la plataforma.
Contexto¶
La zona horaria decide el año del número de radicado ({YEAR}-{CODE}-{TIPO}-{SEQ}), la fecha base de los plazos, la
vigencia de la TRD/TVD y «una fecha de acto no puede ser futura». Hasta ahora era una variable de despliegue
(TZ_RADICACION), una por instalación: una entidad de Lima o de Madrid alojada en la misma instalación que una de Bogotá
numeraba y plazaba con el reloj equivocado. Además un nombre inválido degradaba a UTC con un warning, es decir, fallaba
en silencio con una zona equivocada.
Decisiones¶
D1. La zona vive en public.tenants (zona_horaria TEXT NOT NULL DEFAULT 'America/Bogota',
zona_horaria_fijada_at TIMESTAMPTZ; migración tenant-service global 007), como el code que ya forma el número. Viaja
en la misma consulta con la que la puerta de cada servicio ya comprueba el estado de la entidad (_fila_entidad):
cero consultas nuevas por petición. Un parámetro del schema del tenant costaría una consulta por petición a una tabla de
otro servicio; un claim del JWT no es atómico con el congelamiento (token de 5-15 min con la zona vieja).
D2. Dos llaves de validación que deben coincidir. Python (orpycamcp_common.zona.zona_valida): forma anclada
^(UTC|(Africa|America|Antarctica|Asia|Atlantic|Australia|Europe|Indian|Pacific)/…)$ y pertenencia a la tzdata del
proceso (paquete tzdata, no la de la imagen base). Motor: CHECK tenants_zona_forma + trigger contra
pg_timezone_names. Excluye Etc/GMT+5 (signo invertido), abreviaturas (EST), cadenas POSIX (UTC-5) y alias US/….
D3. Se congela con el primer radicado, sin escapatoria. Cambiar la zona mueve el año del radicado y la fecha base de
los plazos: con radicados emitidos, un cambio cerca del 31-dic daría un 2026-…-000123 posterior en el tiempo a un
2027-…-000001. La zona horaria no se puede cambiar después del primer radicado, porque cada radicado queda numerado y
fechado con la hora local de su creación en esa zona, y los plazos ya corridos se contaron en ella. Un nombre IANA absorbe los cambios legislativos de la hora (DST, huso)
dentro del mismo nombre, así que congelar el nombre no congela la regla horaria: nunca hace falta cambiarlo por ley.
Decisión del usuario (2026-11-03): sin puerta de escape ni para la plataforma. El trigger trg_tenants_zona_guard es la
llave final: tras fijar, un UPDATE de la zona da 23514 tenants_zona_fijada y zona_horaria_fijada_at no se desfija.
D4. Quién la fija. Al crear la entidad el operador de la plataforma (TenantCreate.zona_horaria, opcional, por
omisión America/Bogota; scripts/init_tenant.py --zona-horaria). Después, y solo mientras no haya radicados, la
entidad (PUT /api/v1/config/zona-horaria, USUA_PERM_ADMIN nivel 3, motivo ≥ 10, asiento
tenant.zona_horaria_cambiada, mismo valor = 200 sin asiento). Tras el primer radicado el PUT da
409 zona_horaria_fijada con la razón de arriba. El PATCH /tenants/{id} interno no la toca: un solo camino de cambio.
D5. Quién congela: document-service, dentro de la transacción del radicado y antes del consecutivo
(fijar_zona_al_radicar): UPDATE public.tenants SET zona_horaria_fijada_at = now() WHERE slug = $1 AND
zona_horaria_fijada_at IS NULL y, en sentencia aparte, la lectura de la zona (READ COMMITTED re-evalúa solo la fila
bloqueada: gana quien bloquea primero). Si la zona leída difiere de la que la puerta vio (carrera con el administrador
antes del congelamiento) se responde 409 zona_horaria_cambio y se reintenta: nunca se numera con el año de otra zona. El
acto de congelar muta public.tenants, así que deja el asiento tenant.zona_horaria_fijada en la misma transacción. La
radicación unitaria, el lote y la importación INT-05 (cuando inserta al menos un radicado) pasan por
congelar_zona_en_radicacion. El año del número sale del now() de esa misma transacción (el de registered_at)
convertido a la zona congelada, no del reloj de Python calculado antes de abrirla (podían caer a ambos lados del 31-dic).
Los procesos de fondo que radican (sondeo de correo) toman la conexión con acquire_tenant, que fija la zona.
D6. Cómo la obtiene cada servicio. La puerta (veredicto_entidad/abrir_entidad) lee la fila, fija el TimeZone de
esa conexión (set_config en el mismo SELECT, solo si la entidad es atendible) y deja la zona en un ContextVar;
hoy_entidad() y zona_nombre_actual() la leen. Al cerrar, SET search_path = public; RESET TimeZone. Los jobs
(acquire_tenant) usan zona_de_job. Las lecturas públicas (atencion_publica) también miden en la zona de la entidad.
archive retira server_settings.timezone del pool: un pool sirve a todas las entidades. Los cuatro sitios de document que
decidían «hoy» en UTC (fecha_documento no futura, fecha_acto no futura, F_HOY del documento producido) pasan a
hoy_entidad(); meses_desde(..., hoy=) deja de tener default.
D7. Fail-closed, nunca UTC. zona_de() y su degradación a UTC desaparecen. Sin contexto, zona_actual() levanta
ZonaNoEstablecida; una zona que PostgreSQL acepta y la tzdata del proceso no da 503 zona_entidad_invalida.
TZ_RADICACION se retira de la configuración, el compose de producción y .env.example sin alias.
Consecuencias¶
- Cada entidad numera y plaza con su reloj; el informe mensual de document y los vencimientos de workflow agrupan por día LOCAL (corrección, no regresión).
email_parsersigue acotandoDate:en UTC (cota técnica, no fecha de negocio);reserva_meses_entreen SQL sigue en UTC explícito.- Las fechas de negocio guardadas antes del primer radicado con otra zona no se recalculan (su asiento dice cuál se usó).
- El tenant
demoya existente recibeAmerica/Bogotapor elDEFAULTde la columna (no es un backfill de datos) y queda fijado en su próximo radicado.
Deuda declarada¶
- Mostrar las fechas en la zona de la entidad en el navegador (hoy usa la del navegador): exige exponer la zona en
/auth/me. - Los servicios sin «hoy» propio (storage, signature, notification, knowledge) solo reciben la zona en la sesión de la
puerta; sus jobs (
acquire_tenant) no la fijan todavía. workflow sí: su worker fija la zona y guardadue_atcomo el inicio del día siguiente al último día hábil, en la zona de la entidad. - Los festivos de Ley 51/1983 solo se siembran al provisionar una entidad en
America/Bogota; para otra zona se cargan por la administración y, mientras tanto, el cálculo de plazos falla cerrado (409 calendario_sin_festivos).