The geo-interoperability KB draws a hard line between what a buyer requested (a polygon, a radius, an address) and what a system executed (a set of H3 cells, a circumscribed circle). This page ports that doctrine to time. A local wall time — "8:00 PM in Chicago" — is a request. The canonical UTC slot it resolves to is the execution. Most of the time the two agree so cleanly that the distinction feels academic. Twice a year, for one hour each, they cannot both be honored exactly, and that is precisely when a system's honesty is tested.
Why they can diverge
Daylight saving time transitions create two structurally different
failure modes, both handled by localToSlot and surfaced end-to-end by
resolveLocalToCanonical in lib/time/:
Spring-forward gap. At the DST-start transition, clocks jump forward
an hour and a whole range of wall times simply never occurs. America/New_York
skips from 01:59:59 directly to 03:00:00 on 2026-03-08, so 02:30 that
morning is not a request that can be executed as-written — it names a
moment that does not exist on that clock. There is exactly one adjacent
valid instant (03:30 EDT, the same clock position measured forward), and
the system's only honest choices are: resolve forward to it, resolve
backward to the pre-gap instant, or reject the request outright.
Fall-back fold. At the DST-end transition, clocks repeat an hour, so
a wall time names two different instants. America/New_York repeats
01:00–01:59 on 2026-11-01, once at UTC-4 (EDT) and once at UTC-5 (EST).
01:30 that morning is ambiguous between two UTC instants an hour apart
— and, because slots are hour granular, potentially two different slots.
A system that picks a resolution for a gap or fold without a declared
policy, and without recording that it did so, has quietly converted an
ambiguous or invalid request into a false-precision answer. The record
must carry wasNonexistent / wasAmbiguous and the policy applied —
never just the resolved instant.
The policy parameter
Both hazards are resolved by a declared disambiguation policy, not a
default buried in a library:
- earliest
- Fold: the first (pre-transition, larger-offset) occurrence. Gap: the single valid post-transition instant.
- latest
- Fold: the second (post-transition, smaller-offset) occurrence. Gap: the same single valid instant as earliest (there is only one).
- reject
- Throw rather than guess, for either hazard, when silent resolution is unacceptable.
Other zone examples clarify the two hazards further: 05:30 in
Asia/Kolkata never touches a DST transition at all — India has
observed a fixed +5:30 offset since 1945 — so it resolves unambiguously
to 00:00 UTC, which is slot 0. A zone inferred from geography (see
inferred-timezone-from-geo) inherits whichever policy its wall time
requires only if the zone itself is correctly resolved first — a wrong
zone produces a confidently wrong slot with no wasAmbiguous flag to
catch it, because the ambiguity was in the zone lookup, not the clock
arithmetic.
Worked example
import { resolveLocalToCanonical } from "@/lib/time/provenance";
// Fall-back fold: 2026-11-01 01:30 America/New_York occurs twice.
const earliest = resolveLocalToCanonical(
{ year: 2026, month: 11, day: 1, hour: 1, minute: 30 },
"America/New_York",
"earliest",
);
// earliest.executed.utc -> "2026-11-01T05:30:00.000Z"
// earliest.executed.wasAmbiguous -> true
// earliest.executed.lossless -> false
const latest = resolveLocalToCanonical(
{ year: 2026, month: 11, day: 1, hour: 1, minute: 30 },
"America/New_York",
"latest",
);
// latest.executed.utc -> "2026-11-01T06:30:00.000Z"
// same requested wall time, one hour and one slot apart
// Spring-forward gap: 2026-03-08 02:30 America/New_York never occurs.
const gap = resolveLocalToCanonical(
{ year: 2026, month: 3, day: 8, hour: 2, minute: 30 },
"America/New_York",
"earliest",
);
// gap.executed.utc -> "2026-03-08T07:30:00.000Z"
// gap.executed.wasNonexistent -> true
// gap.executed.lossless -> false
resolveLocalToCanonical returns a requested object (the wall time,
zone, grain, and disambiguation policy exactly as asked) and an executed
object (the resolved slot, week-slot key, UTC instant, offset, which
disambiguation was actually applied, and a lossless boolean that is
false whenever wasNonexistent or wasAmbiguous is true). No field is
overwritten or dropped — a caller who only reads executed.slot gets a
correct answer; a caller who needs to know whether that answer required a
judgment call reads executed.lossless and provenance alongside it.
Never silently rolled up
The same discipline extends past DST into grain: if a caller asks for
hour-of-week slot delivery and the system can only report at a coarser
grain — day or week — that coarsening must be a declared, requested
operation, never a silent default (no-silent-temporal-rollup). A report
that says "daily" when the caller asked for "hourly" has the same shape of
dishonesty as a slot resolved from a fold without recording which
occurrence was chosen: both replace an exact answer to the question asked
with an approximate answer to a different, unstated question. See
Resolution and grain for the decision
guide on choosing a grain up front, and
Timestamp to slot for the full conversion
this page's doctrine governs.
