What changed, and why
The hard floor (SPEC ยง0.6 Layer 1) is the deterministic cap that bounds a runaway poller or a mis-blessed rule regardless of the model โ the dumb layer that catches disasters. One of its three rules is a per-run / per-day circuit breaker on the number of auto-actions. It was inert.
The gate reads the breaker through check_floor(amount, counters). But the counters it got were always AutoActionCounters() โ literal zeros, hardcoded in _load_enrichment_inputs. So check_floor's TRIP_BREAKER branch could never fire. The per-transaction ceiling bounded each write's magnitude; nothing bounded the count. A single bad blessed rule could auto-apply across the whole backlog, each write under the ceiling, and the breaker would never trip.
This change gives the breaker real counts to read: a durable ledger of recent auto-actions, queried before the gate and recorded after each auto-apply. It mirrors the alert and overspend ledgers already in the codebase.
The ledger, as pure folds
The counting logic is pure: a frozen tail of (ynab_id, at) entries and two functions over it. record appends โ dropping any existing entry for the same ynab_id first, so a retry or a re-enriched transaction counts once โ and prunes to the day horizon so the carried state stays bounded. counters projects the tail into the AutoActionCounters the floor checks.
Two windows, two folds. record dedups by ynab_id and prunes; counters splits the tail into the run and day counts.
src/ynab_agent/policy/auto_action_ledger.py ยท 89 lines
โฏ 25 lines hidden (lines 1โ25)
The durable singleton that holds it
The tail lives as Temporal workflow state in a singleton ledger โ born on the first auto-action via signal-with-start, living forever via continue-as-new. A thin shell over the pure folds, identical in shape to AlertLedgerWorkflow: a record signal folds in, a counters query reads out. The signal stamps workflow.now; the query takes now in its request (a query can't read the wall clock).
Signal folds, query reads, continue-as-new bounds history.
src/ynab_agent/workflow/auto_action_ledger_workflow.py ยท 60 lines
โฏ 35 lines hidden (lines 1โ35)
โฏ 1 line hidden (lines 60โ60)
Reading real counts before the gate
The enrich activity now reads the live counts from the ledger and hands them to the gate, in place of the old zeros. The fallback matters: an unreachable or never-started ledger returns zeros โ which is correct here, because a ledger that has never started genuinely means zero auto-actions, so the breaker must not trip on its own absence. (Contrast _load_payee_rules, which fails closed to ASK, because an unknown rule must never auto-apply.)
_load_auto_action_counters queries the ledger (zeros on absence); _load_enrichment_inputs threads the counts into the gate inputs.
src/ynab_agent/workflow/activities.py ยท 607 lines
โฏ 97 lines hidden (lines 1โ97)
โฏ 454 lines hidden (lines 154โ607)
Recording an auto-action, through the layers
Recording follows the codebase's effect discipline rather than reaching out from the activity. The state machine emits a new RecordAutoAction effect when a blessed rule auto-applies (alongside the commit); the workflow dispatches it to a best-effort activity that signal-with-starts the ledger.
The new effect โ a value the pure state machine can emit and a test can assert.
src/ynab_agent/domain/effects.py ยท 140 lines
โฏ 103 lines hidden (lines 1โ103)
โฏ 26 lines hidden (lines 115โ140)
Emitted on Enriching โ AutoApplied, beside the commit. It records the attempt, keyed by ynab_id.
src/ynab_agent/domain/state_machine.py ยท 630 lines
โฏ 230 lines hidden (lines 1โ230)
โฏ 385 lines hidden (lines 246โ630)
Signal-with-start on the singleton, mirroring feed_rule_learning. Swallows its own errors.
src/ynab_agent/workflow/activities.py ยท 607 lines
โฏ 558 lines hidden (lines 1โ558)
โฏ 15 lines hidden (lines 593โ607)
The dispatch also swallows an ActivityError โ a timeout the activity body can't catch.
src/ynab_agent/workflow/txn_workflow.py ยท 567 lines
โฏ 302 lines hidden (lines 1โ302)
โฏ 245 lines hidden (lines 323โ567)
Tests
The pure folds pin the behaviour that's easy to get wrong: a re-recorded ynab_id counts once, old entries prune, and the run/day windows split a tail correctly.
Dedup, prune, and the split windows.
tests/policy/test_auto_action_ledger.py ยท 52 lines
โฏ 23 lines hidden (lines 1โ23)
The workflow test drives the singleton on the time-skipping server โ record, count, then a query two hours on showing the run window rolled off while the day window holds. And the W2 workflow test asserts the auto-apply's record reaches the activity end-to-end.
Record โ counts โ window roll-off, on the durable workflow.