The code didn't change. The schedule did.
There's a batch job on a US East Coast server that runs every morning at 9:00. It has been
running fine for months — until the second Sunday of March, when it quietly starts running
at 10:00 every morning. Nobody deployed anything.
The cause usually looks like this:
next = prev + 24 * 60 * 60 * 1000; // "one day later"
The US East Coast switches to daylight saving time at 02:00 on the second Sunday of March.
The clock jumps forward one hour, so that civil day is only 23 hours long. "Previous
time + 86,400 seconds" is physically exactly 24 hours later — but read on a wall clock,
it's an hour off. And it stays off until the clocks fall back in November.
The interesting part is not how to fix the bug. It's the fact that the phrase "one day"
names two different quantities:
- A civil day — one square on the calendar. It sticks to the wall clock, and on a transition day it is 23 or 25 hours long.
- 24 elapsed hours — 86,400 physical seconds, regardless of what the wall clock does.
Both are legitimate readings of "one day"; which one you want depends on the requirement.
A daily 9:00 report wants the former. "Inspect the equipment every 24 hours of runtime"
wants the latter. The accident starts the moment your code holds both in the same type.
In Kairos, they are different literals
Kairos is a schedule definition language I've
been writing about (in Japanese) for a while. It separates the two quantities at the level
of width literals: 1d is a civil day, 24h is elapsed time. Here is "every day from
9:00" written both ways, evaluated across the 2026 spring-forward date (March 8) in
America/New_York:
premise NY {
calendar-system: Gregorian
tz: "America/New_York"
wkst: Mon
}
@NY
everyInstant |> strideBy(1d, from: 2026-03-06T09:00)
everyInstant |> strideBy(24h, from: 2026-03-06T09:00)
# expression 1 (4 points)
2026-03-06T09:00
2026-03-07T09:00
2026-03-08T09:00
2026-03-09T09:00
# expression 2 (4 points)
2026-03-06T09:00
2026-03-07T09:00
2026-03-08T10:00
2026-03-09T10:00
Expression 1 (1d) sticks to 09:00 every day. Expression 2 (24h) drifts to 10:00 on
the transition day — and stays there. That's the batch job from the opening,
reproduced. Neither expression is wrong: they are different questions, and the
language returns different answers.
And if you try to write a width that mixes the two:
字句エラー(8:26): 市民時と経過時間の幅は混合できない: 1d12h(ADR-28)
— a lexical error: "civil-time and elapsed-time widths cannot be mixed: 1d12h".
It is rejected at parse time. "One day and twelve hours" is a quantity whose length
is undefined until you know how the civil day stretches; if you could write it, its
behavior on transition days would be implementation-defined. So you can't write it.
Writing a time that doesn't exist
A transition day also has a wall-clock hole: 02:00–03:00 never happens that morning.
Write 02:30 of that day as data — a named, literal time — and:
x = [2026-03-08T02:30] covering: 2026..2026
x
存在しない時刻: 2026-03-08T02:30(tz "America/New_York" の DST の隙間に落ちる——実在の壁時計で書く。ADR-33)
— "nonexistent time: 2026-03-08T02:30 (falls into the DST gap of tz "America/New_York" —
write a wall-clock time that exists)".
No silent forward-shift to 03:30, no silent drop. You named a specific wall-clock time;
that time doesn't exist; therefore your data is wrong — and the language says so.
Contrast this with points that are derived by evaluation (say, a "daily at 02:30" rule
hitting the transition day): those resolve deterministically to "the first instant after
the gap", by convention. Named times are strict; derived times follow conventions.
Nothing you didn't write ever runs; nothing you did write is silently rewritten.
"We don't have DST here"
Half true. Japan (where I live) hasn't had DST since 1951; our civil days are always
24 hours. But "the server is on UTC, the business runs on Tokyo mornings" is an everyday
setup, and inside it someone is doing the civil-day ↔ elapsed-time conversion — every
single day. A cron line 0 9 * * * means "9:00 wherever the server is", and its meaning
changes the day you migrate the box. Same species of bug: not "which quantity", but
"whose wall clock" left unstated.
That's why a Kairos premise requires the timezone declaration (tz: "America/New_York"
above): no schedule definition can be written without saying whose wall clock it reads.
Move the server; the definition means what it always meant.
cron's minefield — I've written before about what 0 9 13 * 5 actually does — is rarely
a lack of power. It's polysemy: "one day" that names two quantities, "9:00" that
doesn't say whose. What a language can do is split the ambiguous words into distinct
spellings, and make the confusion unwritable.
Kairos is a schedule definition language in RC, developed documentation-first: the spec,
operator reference, and stdlib guides are canonical in Japanese with a full
English mirror, and every
example in the docs is executed and verified against the reference implementation in CI.
The Playground runs that same reference
implementation in your browser (nothing leaves the page); evaluator error messages are the
implementation's canonical Japanese, as you saw above — the English spec defines their exact
semantics. If you came for the "schedules cron and RRULE can't express" angle, the
recipes walk the greatest hits — last business day
of the month, Easter as pure arithmetic, the 4-4-5 fiscal calendar — one page per
requirement, each with a runnable Playground link.
Top comments (0)