Uh oh!
There was an error while loading. Please reload this page.
Period engine — period keys, boundaries and due dates, timezone-correct - #25
Merged
Conversation
One module owns what a period IS, so the dispatcher, the backfill and the seed cannot disagree about it. Pure functions, no I/O, no runtime dependency. Boundaries are computed on calendar parts in the supplied IANA zone and converted to an instant exactly once. The inversion verifies its answer rather than trusting a second pass: on a zone that shifts AT midnight (America/Santiago, 2026-09-06) the naive two-pass converges on an instant whose local reading is 23:00 the previous day — a whole period early. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
os-warren
marked this pull request as ready for review
September 1, 2026 04:00
Uh oh!
There was an error while loading. Please reload this page.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for freeto join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes#1
One module owns what a period is, so the dispatcher, the backfill and the seed cannot disagree about it. Pure functions, no I/O, no platform imports beyond types, and no new runtime dependency —
Intl.DateTimeFormatwith atimeZonesupplies both the local calendar parts and, by comparing those parts against the instant, the zone's offset at that instant. That is everything the arithmetic needs.Files
src/functions/period.tstest/period.test.tssrc/functions/index.tsis untouched:dulyFunctionsis the map ascriptflow node resolves by name, no flow exists yet, and #2 imports the functions directly. Adding a name nothing resolves would be dead metadata and a needless collision point for the other three branches in flight.objectstack.config.tsis untouched.API
The key spelling table is implemented exactly as written on the issue,
2026-W04padding included, and every key is checked againstperiod_key'smaxLength: 16.The two decisions worth reviewing
1. The inversion verifies its answer instead of trusting a second pass. The textbook wall-clock-to-instant conversion (
guess - offsetAt(guess - offsetAt(guess))) is not merely imprecise on a zone that shifts at midnight — it lands in the wrong period. ForAmerica/Santiago2026-09-06, where local midnight does not exist, it converges on2026-09-06T03:00Z, whose local reading is 23:00 on the 5th. So candidates are drawn from the offsets a day either side of the target and then verified against the zone: an exact match wins (earliest, when a fall-back makes the reading happen twice), otherwise the answer resolves forward to the first instant past the requested reading. Measured, not assumed — ablating this back to the naive two-pass turns two tests red (below).2.
periodsBetweenreturns only keysperiodKeyForcould itself return. The first draft walked civil dates and emitted2011-12-30forPacific/Apia— a local day the zone skipped when it crossed the date line. A backfill would have filed a task for a day nobody lived through, whoseperiodBoundswindow is empty. The walk now carries each boundary instant forward and skips a period with no instants in it; it costs one zone inversion per period, not two, because neighbours share a boundary.Behaviour the tests pin
periodKeyFor(f, periodBounds(f, k, tz).start, tz) === kfor all seven frequencies ×UTC/Europe/Berlin/Asia/Shanghai× ten awkward instants (year boundaries, both DST transitions, month ends, a leap day), plus the containment check that the probe falls inside the window it named.2026-01-01→2026-W01,2027-01-01→2026-W53. 2020 and 2026 have 53 weeks;2021-W53is refused with the week count in the message.endequal to itsstart.period_start+ 30 on February is the 28th, or the 29th in a leap year, never 2 March;period_end- 90 on a month is the 1st. A table drives ±9999 on both anchors across all seven frequencies and asserts the result never leaves the period.periodsBetweenover the 2026→2027 boundary for each frequency: ascending, no duplicates, and contiguous by construction — each period'sendis asserted equal to the next period'sstart.2026-W4,2026-13,2026-Q5,2026-02-30, and an even fortnight key (whose message names the odd one you probably meant).duly_taskis unique on(duty, owner, period_key), so two spellings of one period would be two tasks for one obligation and nothing downstream could tell they were meant to be the same.Gates — all four green at
894686197 = the 89 added here plus the 8 existing invariant tests, which still pass untouched.
Reverse verification
Each mutation was confirmed on disk (injected marker present, removed anchor absent,
git diff --statnon-empty) before the run, and a shelltraprestored the tree after each leg; the tree was verified clean and marker-free afterwards. No build step is involved — vitest transformssrc/directly, so there is nodist/for a stale artifact to hide in.dueDateForperiodsBetweenfortnightly walkRaised, not changed
duly_duty.due_offset_days's description contradicts itself: "Days from the anchor" and"5" … = due on the 5thcannot both hold, since a monthly period starts on the 1st. This PR implements days from the anchor, with 0 meaning the anchor day, becausedefaultValue: 0has to mean something on both anchors andperiod_end+ 0 = the last day is the worked example induty.object.tsanddocs/product/data-model.mdalike. Both readings satisfy every clamped example in Period engine — period keys, boundaries and due dates, timezone-correct #1's acceptance criteria, so the gates cannot separate them — a person should confirm before Dispatcher job — idempotent task generation, backfill-capable #2 and Demo seed data — the product working on first boot #7 bake one in. If the other reading wins, it is a one-day shift indueDateForplus two tests.duly_duty.timezoneaccepts any string — a typo'd IANA zone validates clean and fails at dispatch #24 —duly_duty.timezoneaccepts any string, so a typo'd IANA zone validates clean and first throws inside the dispatcher. The engine refuses an unknown zone deliberately (a duty silently resolving in the wrong zone is a wrong due date nobody can see), which is why the check wants to exist at author time too.Neither is touched here: both live on
src/objects/, outside this card's file surface.Generated by Claude Code
Generated by Claude Code