# When does a wall-clock schedule fire twice or zero times on a DST transition? **Date of Record:** 2026 November 1 **Scope:** Applies to recurring jobs scheduled on local wall-clock time (daily at 02:30) in a timezone that observes daylight saving. Excludes UTC-scheduled jobs and instant-based schedulers. ## Core Distinction **Predicate.** A wall-clock schedule is a total function from date to instant if and only if every date maps to exactly one instant. On a spring-forward transition, local times in the skipped hour do not exist and map to zero instants. On a fall-back transition, local times in the repeated hour map to two instants. A schedule is sound only if it is expressed in UTC or in monotonic offsets with an explicit DST policy. **Failure.** The scheduler evaluates a wall-clock time and receives zero or two candidate instants. Depending on the library, it fires zero times (spring), twice (fall), or once at an arbitrary choice of the two. Billing runs, report generation, and retention sweeps inherit the ambiguity. **Asymmetry.** A missed run is noticed. A double run is not, because both executions succeed and the second is indistinguishable from an idempotent retry, so it silently doubles non-idempotent effects. ## What Was Observed **Signature.** Duplicate output for a daily job on one specific date per year, or a missing day. The date matches the timezone's DST transition. Re-running the job manually for the affected date does not reproduce the duplicate, which makes it look like a one-off. **Trace.** A job scheduled for 02:30 local. On fall-back, 02:30 occurs twice, an hour apart. A scheduler that triggers on wall-clock equality fires at both. A scheduler that fires on next-occurrence fires at the first only if it stores absolute instants, and at both if it recomputes from the wall clock each tick. A daily billing run at that hour charges twice for one day. **Why monitoring stays green.** Both runs report success. Row counts look plausible. The duplicate is only visible as a reconciliation delta against the expected daily count. ## Condition of Validity A recurring schedule is unambiguous only while at least one holds: 1. **UTC.** The schedule is expressed in UTC and the local-time intent is accepted to drift by an hour twice a year. 2. **Explicit policy.** The scheduler stores absolute instants and has a declared rule for nonexistent and repeated local times (skip, shift, or fire-once). 3. **Idempotence.** The job is idempotent per (schedule, logical date), so a double fire is absorbed. Condition 3 does not fix the schedule; it makes the failure harmless for that job only. ## Decision Boundary - UTC schedule: fires exactly once per period; local time shifts. - Wall-clock with stored absolute instants and a declared policy: fires exactly once by construction. - Wall-clock with recomputation each tick: double or zero fire is guaranteed on transitions. - Wall-clock plus idempotent job: safe for that job, unsafe for any new job added to the same scheduler. ## Operational Effect The defect is concentrated in two instants per year and therefore invisible to any test that does not cross a transition. Remediation is to store next-fire instants rather than recompute wall-clock times, and to make the logical date explicit in the job payload. The trap is to move the schedule to a time outside the transition window. That works until a timezone changes its transition rules, which happens more often than most schedules are revised. ```python from dataclasses import dataclass class ScheduleAmbiguityError(RuntimeError): """Raised when a wall-clock schedule is not a total function to instants across DST transitions.""" @dataclass(frozen=True) class ScheduleGate: basis: str # "utc", "wall-clock", "absolute-instant" dst_policy: str # "skip", "shift", "fire-once", "none" job_idempotent: bool = False def verify(self) -> bool: if self.job_idempotent: return True if self.basis == "utc": return True if self.basis == "absolute-instant" and self.dst_policy in ("skip", "shift", "fire-once"): return True if self.basis == "wall-clock" and self.dst_policy == "none": raise ScheduleAmbiguityError( "wall-clock schedule with no DST policy fires twice or zero times at transitions" ) raise ScheduleAmbiguityError( f"basis {self.basis!r} with policy {self.dst_policy!r} is not unambiguous" ) def dispatch_schedule(gate: ScheduleGate) -> str: try: gate.verify() except ScheduleAmbiguityError: return "utc-with-logical-date" return "absolute-instant" ```