ADR 0035 — Cron Semantics: 1-Minute Granularity, IANA Timezones, DST Rules, tzdata Pinning
Status: Accepted Context date: 2026-08-06 Builds on: ADR 0032 (service boundary), ADR 0034 (execution semantics)
Context
Cron expression semantics are public API: once tenants build schedules on them, changing granularity, timezone handling, or DST behaviour is a breaking change to running production jobs. Every rule therefore has to be decided now, stated as a contract, and frozen. The hard cases are the ones cron notation is silent about: seconds precision, what "03:00 Addis time" means when tzdata changes, and what happens on the two DST edges (a local time that never exists, and one that exists twice).
Decision
1. Minimum granularity is 1 minute
Schedules use 5-field standard cron — no seconds field — and interval_seconds >= 60. The internal tick loop is 1 s, so a run due at 12:00:00 is delivered within ~1 s plus jitter (ADR 0037); the contract is minute precision.
2. Timezones are IANA names, resolved per schedule
Each schedule carries an IANA timezone (default UTC). Occurrence computation happens in that zone; the resulting instant is stored as timestamptz (next_fire_at, scheduled_for).
3. DST rules (contractual)
- Non-existent local time (spring-forward gap): fire at the first valid instant after the gap.
- Ambiguous local time (fall-back repeat): fire once, at the first occurrence.
4. tzdata is pinned, versioned, and upgrade-guarded
tzdatais pinned in the container image and asserted at startup — a distroless base image without tzdata silently degrades every non-UTC schedule to UTC, which is a correctness bug, not a warning.- Each schedule records
tzdata_version— the tzdata used to compute itsnext_fire_at. - A tzdata upgrade triggers a guarded recompute of
next_fire_atfor schedules in affected zones only; a schedule whose cron/timezone becomes uncomputable transitions todisabled_invalidwith an event — never a crash-loop (the planner isolates per-schedule computation errors).
Alternatives Considered
- 6-field cron with seconds. Rejected: it multiplies worst-case fire volume ~60×, drags the delivery SLO into sub-second territory the claim-queue design does not promise (ADR 0033), and almost every "seconds" use case is better served by
kind=interval. Adding seconds later is additive; removing them would be breaking — so start without. - Quartz-style extended syntax (
L,W,#). Rejected for v1: each extension is more frozen public API; standard 5-field covers the product need and extensions remain additive later. - UTC-only schedules. Rejected: "every weekday at 03:00 local time" is the product's core use case; pushing DST arithmetic onto every tenant guarantees they get it wrong once a year.
- Firing both occurrences on ambiguous (fall-back) times, or skipping the gap entirely on spring-forward. Rejected: both violate the tenant's mental model of "once per scheduled time"; fire-once-at-first-instant is the least surprising rule and matches the exactly-once-materialisation stance (ADR 0034).
- Unpinned tzdata from the base image. Rejected: silent behaviour drift on rebuild, with no record of which rules computed a stored
next_fire_at.
Consequences
- Positive: The rules are simple enough to state in tenant docs in four sentences, and every edge case has a deterministic, testable answer.
- Positive:
tzdata_versionper schedule makes tzdata upgrades a bounded, auditable recompute instead of a silent global behaviour change. - Negative / accepted: No sub-minute schedules in v1; tenants needing tighter loops use
kind=intervalat 60 s or run their own timer against our API. - Negative / accepted: tzdata upgrades become an operational event (image rebuild + guarded recompute) rather than an invisible OS patch. That cost is the price of correct "03:00 local" semantics.
- Frozen: These rules are breaking-change-frozen. A future change requires a new ADR and a versioned opt-in (per-schedule flag), never a reinterpretation of existing schedules.