Skip to content

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_parser sigue acotando Date: en UTC (cota técnica, no fecha de negocio); reserva_meses_entre en 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 demo ya existente recibe America/Bogota por el DEFAULT de 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 guarda due_at como 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).