What changed, and why
W6 is the daily budget guard: per category, project month-end spend by run-rate and email an alert for anything already over or trending over. The decision logic and the orchestration already existed and were tested — the pure projection (assess/should_alert in budget/overspend.py) and the OverspendMonitorWorkflow — and the daily Temporal Schedule is scaffolded in infra. What was missing was the I/O: three of the monitor's four activity ports were stubs that raised NotImplementedError.
This PR wires those three ports and adds the one piece they require: a durable place to remember what was last alerted, so the per-period dedupe survives between daily passes. It is v1 notify-only — the cover/balancing move is W7 and stays out of scope. Nothing about the workflow contract changes, so its existing test is untouched.
The orchestration that drives the new ports (unchanged here, shown for context): each pass fetches the category figures, runs the pure assess, and for anything off track that survives should_alert against the prior alert, sends and then records. The three activities this PR fills in are exactly the reads and writes in that loop.
The existing per-pass loop: assess → load prior → should_alert → send → save.
src/ynab_agent/workflow/monitor_workflow.py · 89 lines
⋯ 36 lines hidden (lines 1–36)
⋯ 6 lines hidden (lines 84–89)
The dedup memory — budget/ledger.py
should_alert compares the current assessment against the last alert raised this period, so that prior alert has to persist across daily runs. This new pure module is that memory, kept to one entry per category: the verdict and projected month-end it last alerted at, stamped with the budget period as "YYYY-MM".
One frozen entry per category; the whole table is a tuple carried as Temporal state.
src/ynab_agent/budget/ledger.py · 82 lines
⋯ 27 lines hidden (lines 1–27)
⋯ 35 lines hidden (lines 48–82)
prior returns an entry only when its stored period matches the one asked for, and record drops any existing entry for the category before appending the new one. Those two choices do the real work: the period match makes a new month read as empty (so the first flag of a period always fires), and the drop-then-append keeps the table at one row per category — bounded without any pruning logic.
prior() scopes by period; record() upserts one-per-category.
src/ynab_agent/budget/ledger.py · 82 lines
⋯ 49 lines hidden (lines 1–49)
The alert wording and the idempotency label — budget/message.py
The alert email is deterministic: every figure and the verdict are already decided upstream, so this is plain templating, not a model call. Keeping it in its own pure module makes the subject, body, and label unit-testable without Temporal or AgentMail. The body reads like Dining is trending over budget: $250.00 spent of $400.00, 6 days left, projected ~$500.00 by month-end.
The body, and the thread label that keys send-idempotency.
src/ynab_agent/budget/message.py · 69 lines
⋯ 38 lines hidden (lines 1–38)
The durable singleton — OverspendLedgerWorkflow
The ledger is held by a long-lived singleton workflow, born on the first alert via signal-with-start and living forever through continue-as-new. It is a thin shell over the pure folds: a record signal folds an alert in, a prior query reads one back. This is the third instance of a pattern already in the codebase (RuleRegistryWorkflow over learn.registry, AlertLedgerWorkflow over alert.ledger).
Pure folds passed through the sandbox; signal folds, query reads.
src/ynab_agent/workflow/overspend_ledger_workflow.py · 61 lines
⋯ 28 lines hidden (lines 1–28)
Wiring the ports — monitor_activities
With the store in place, the three stubs become thin glue. load_prior_alert queries the ledger; save_alert signal-with-starts it; send_overspend_alert opens the alert's own email thread. The period and "days left" are read from real time here, in the activity, and passed into the pure query/fold — which is why the workflow stays clock-free. Heavy clients (Temporal, mail) are imported lazily inside the bodies so they never cross into the workflow sandbox.
The real-clock helpers, the load (query + hydrate), and the send + save writes.
src/ynab_agent/workflow/monitor_activities.py · 153 lines
⋯ 29 lines hidden (lines 1–29)
⋯ 16 lines hidden (lines 39–54)
Registration and how it's verified
The new singleton is registered in the worker (the four monitor activities were already listed; only the workflow class is added). Everything else is tests.
OverspendLedgerWorkflow joins WORKFLOWS; the count test moves 8 → 9.
src/ynab_agent/workflow/runtime.py · 88 lines
⋯ 40 lines hidden (lines 1–40)
⋯ 37 lines hidden (lines 52–88)
Coverage tracks the change at every layer: the pure folds and the formatter/label are unit-tested; the ledger workflow has a round-trip test on the time-skipping server; and test_monitor_activities drives save_alert → load_prior_alert through a real ledger over the pydantic converter, asserting the result is a real PriorAlert (the hydration guard above). That last test pre-starts the singleton's run, because in production the ledger is always already running when a later daily pass loads from it.