rgoussu@goussu: ~/library/fundamentals/exercises
~/library/fundamentals/exercises cat dst-safe-scheduler-subject.md

DST-safe scheduler — subject

# The work statement for a hand-built scheduling core — RFC 3339 parsing rules, the instant/civil/zoned type contract, gap and overlap resolution policies, the tzdb-update drill, and the hostile-zone acceptance fixtures.

Subjectsaved 2026-08-09source #time#timezones#datetime#tzdb#rrule#fundamentals#exercise#subject

Brief

A meeting scheduled for 09:00 fires at 10:00. A daily job runs twice one night in November and not at all one night in March. A calendar invite made in January is an hour off by June because a government changed its mind. Every one of these is the same bug — treating time as one type when it is three — and you are going to build the small scheduling core where that bug cannot survive: a parser, a type model, an event store, and a recurrence expander, each with the failure mode turned into a test.

Instructions

RFC 3339 parser — string → (civil components, offset)

Hand-parse the date-time production of RFC 3339 — full-date "T" full-time, e.g. 2026-08-08T10:00:00+02:00 — without your language's datetime parser:

  • Accept: Z and ±hh:mm offsets, optional fractional seconds, lowercase t/z (the grammar allows them).
  • Validate the calendar: month 01–12, day valid for month and leap year, hour ≤ 23, minute ≤ 59, second ≤ 60 (60 only as a leap second: parse it, then document your policy for it).
  • Reject with position and reason: bad separators, out-of-range fields, missing offset.

Then demonstrate the round-trip loss: parse 2026-08-08T10:00:00+02:00, serialize it back, and show the result pinpoints the same instant but cannot tell you whether the event lives in Europe/Paris or Africa/Cairo — an offset is a moment's fact, not a rule set.

The type model — three kinds of time, two kinds of "later"

Build distinct types with no implicit conversion between them:

  • Instant — a point on the physical timeline (epoch seconds + nanos).
  • LocalDateTime — civil wall-clock components with no zone; it does not identify an instant.
  • ZonedDateTime — a LocalDateTime paired with an IANA zone ID (Europe/Paris, never a fixed offset, never an abbreviation like CET).
  • Duration (physical seconds) and Period (calendar units: days, months) — with arithmetic that keeps them apart: Instant + Duration, ZonedDateTime + Period.

Conversions run against a zone-rules interface you define and inject (the language's tzdb may back the production implementation; tests may hand it scripted rules). The core contract is local + zone → instant:

  • Unique (the normal case) — one instant.
  • Gap (spring-forward: the local time doesn't exist) — zero candidates. The API must demand a policy: Reject or ShiftForward (by the gap's length).
  • Overlap (fall-back: the local time exists twice) — two candidates. The API must demand a policy: Earlier or Later offset.

No conversion path may silently pick a winner; the policy is an explicit argument.

The future meeting — store civil, not UTC

Implement an event store for future civil events ("09:00 in Paris on 2027-06-12"):

  • Persist local time + IANA zone; compute the instant on read, through the rules interface.
  • The tzdb-update drill: run with rule set A (Paris observes DST), then swap in rule set B (France abolishes DST, stays on winter time year-round) — a real proposal, and the kind of change the tzdb ships several times a year. Your stored meeting must still resolve to 09:00 on the wall in Paris. Store the same event as a baked UTC instant and show it now renders 10:00 — the silent bug your storage rule prevents.
  • Past events are the opposite: they are facts, stored as instants (UTC). The store should make both representations available and document which is for what.

The recurrence expander — RRULE across the edges

Implement a subset of RFC 5545 recurrence: FREQ=DAILY and FREQ=WEEKLY (with BYDAY), at a given local time-of-day in a given zone, expanding to a stream of instants. Then take it through the fire:

  • Spring-forward gap: "daily at 02:30 America/New_York" across 2026-03-08 — 02:30 doesn't exist that night. Expansion applies the gap policy (Skip or ShiftForward), and the choice is visible in the output.
  • Fall-back overlap: the same rule across 2026-11-01 — 01:30 happens twice. The overlap policy (Earlier/Later) decides which one fires; the job must fire once.
  • Period ≠ duration: compute "next occurrence" both ways across a transition — instant + 24h lands at 03:30 local the next day; civil + 1 day keeps 02:30. The expander must use calendar arithmetic and a test must pin the difference.

Clock injection & the hostile-zone suite

  • All "now" access goes through an injected Clock; nothing in the core calls the system clock directly. Tests fix the clock.
  • Acceptance fixtures must run in zones chosen to break assumptions: Pacific/Kiritimati (UTC+14 — already "tomorrow" relative to UTC), Asia/Kolkata (+05:30 — half-hour offset), America/New_York and Europe/Paris (different DST dates from each other: US switches 2026-03-08/11-01, EU 2026-03-29/10-25).
  • The capstone fixture: expand "daily at 02:30 America/New_York" across all of 2026 — exactly one firing per calendar day under the chosen policies, no drift, no double-fire, no crash on 2026-03-08.

Examples

Conversions the model must get right (rule sets as of tzdb 2026a):

Input (local + zone) Kind Result
2026-06-12 09:00 @ Europe/Paris unique 2026-06-12T07:00:00Z
2026-03-08 02:30 @ America/New_York gap policy: Reject errors; ShiftForward03:30-04:00
2026-11-01 01:30 @ America/New_York overlap policy: Earlier01:30-04:00; Later01:30-05:00
2026-06-12 09:00 @ Asia/Kolkata unique 2026-06-12T03:30:00Z (half-hour offset)
2026-06-12 09:00 @ Pacific/Kiritimati unique 2026-06-11T19:00:00Z (note the date change)

Constraints

  • Do not use the language's datetime parsing or formatting for milestone 1 — the RFC 3339 grammar is yours to implement. The built-in may serve as an oracle in tests.
  • The language's tzdb access (zone rules lookup) is allowed as the production implementation behind your rules interface — shipping your own tz compiler is not the exercise. High-level conveniences that hide the gap/overlap decision (a one-call "parse this string in this zone") are not.
  • No recurrence library: the RRULE expansion logic is hand-built.
  • Every gap/overlap decision in the codebase is an explicit named policy — a silent default anywhere fails the exercise.

Acceptance

  • Milestone 1 (parser): valid RFC 3339 strings parse to the right components and offset; invalid ones are rejected with position and reason; the round-trip demo shows offset preserved, zone unrecoverable. Agrees with the language parser on a fixtures file.
  • Milestone 2 (types): the conversion table above passes; gap and overlap inputs cannot be converted without a policy argument (a compile error or a required parameter, not a runtime surprise).
  • Milestone 3 (future meeting): under the rule-set swap, the civil-stored meeting still resolves to 09:00 wall time and the UTC-baked copy is demonstrably an hour wrong — both asserted by tests.
  • Milestone 4 (expander): across 2026-03-08 and 2026-11-01 in America/New_York, each policy produces exactly the documented firings; the period-vs-duration test pins the 24h-drift difference.
  • Milestone 5 (suite): the full fixture set is green with an injected clock; the year-long expansion yields exactly one firing per day; no test depends on the machine's zone or wall clock.

Related