Skip to main content

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

  • tzdata is 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 its next_fire_at.
  • A tzdata upgrade triggers a guarded recompute of next_fire_at for schedules in affected zones only; a schedule whose cron/timezone becomes uncomputable transitions to disabled_invalid with 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_version per 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=interval at 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.