What changed, and why
SPEC §13 requires one declared household timezone for the budget month, the run-rate, and day boundaries; §11 left which timezone open. No timezone existed anywhere in the code, so the decision was silently resolved to UTC. For a US Central household, UTC is 5–6 hours ahead: near midnight — and especially at month boundaries — the W6 monitor bucketed an alert into the wrong month's thread and dedupe key, the run-rate divided by the wrong day-of-month, and the alert's "days left" and the dashboard's "as of" stamp read a day off.
This change declares America/Chicago once and derives every W6 day/month boundary in it. The interesting part is where the conversion runs — a hang forced the right design.
§11's open decision, resolved explicitly: one declared zone, used for bucketing and display — never as a wall clock.
src/ynab_agent/domain/config.py · 37 lines
⋯ 28 lines hidden (lines 1–28)
The pure conversion
period_and_clock takes the deterministic UTC instant and converts it to household time before reading the day and month. It returns the YYYY-MM period and the MonthClock the run-rate divides by — the two values that were silently wrong near boundaries.
Convert first, then derive. Pure and unit-testable.
src/ynab_agent/budget/overspend.py · 183 lines
⋯ 101 lines hidden (lines 1–101)
⋯ 66 lines hidden (lines 118–183)
The discriminating case: Jun 1 03:00 UTC is still May 31 in Central — period 2026-05, day 31.
tests/budget/test_overspend.py · 145 lines
⋯ 34 lines hidden (lines 1–34)
⋯ 102 lines hidden (lines 44–145)
Why the conversion lives in an activity
The first draft called period_and_clock(workflow.now()) inside the workflow. The W6 test then hung — not failed, hung. The mechanism: an in-sandbox astimezone(ZoneInfo) tripped Temporal's 2-second deadlock detector, which fails the workflow task; the server retries it; the task trips again — an infinite retry loop that looks exactly like a frozen test. The diagnosis came from an accident worth recording: each retry re-imports the workflow file from disk, so when the refactor landed mid-run, the stuck process picked it up and finally errored (current_period ... not registered) instead of spinning — proof of both the loop and the fix.
So the conversion moved to a tiny activity — the standard Temporal pattern for environment-dependent computation. It runs outside the sandbox, and its result is recorded in history, so replay stays deterministic and one period still reaches every activity in the pass (the original no-drift guarantee holds).
The activity: real clock in, household period out, result recorded.
src/ynab_agent/workflow/monitor_activities.py · 177 lines
⋯ 39 lines hidden (lines 1–39)
⋯ 124 lines hidden (lines 54–177)
The workflow consumes the recorded result; zoneinfo never enters the sandbox.
src/ynab_agent/workflow/monitor_workflow.py · 104 lines
⋯ 38 lines hidden (lines 1–38)
⋯ 53 lines hidden (lines 52–104)
Display fixes riding along
Two cosmetic-but-visible UTC leaks convert too: the overspend alert's "days left" (an owner reads that in their own evening; near midnight UTC is a day ahead) and the dashboard masthead's "as of" stamp, which now renders CDT/CST.
Days-left in household time — an activity, so the direct conversion is safe here.
src/ynab_agent/workflow/monitor_activities.py · 177 lines
⋯ 130 lines hidden (lines 1–130)
⋯ 36 lines hidden (lines 142–177)
The operator reads the stamp in their own day.