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
src/ynab_agent/policy/auto_action_ledger.py89 lines ยท Python
โ‹ฏ 25 lines hidden (lines 1โ€“25)
1"""The auto-action ledger's pure state and folds โ€” the circuit-breaker memory.
2 
3The hard floor's per-run / per-day circuit breaker (SPEC ยง0.6 Layer 1) caps how
4many auto-actions the agent may take, bounding a runaway poller or a mis-blessed
5rule regardless of the model. That cap can only bind if the counts are *real*:
6this module is the durable memory those counts derive from โ€” an append-only tail
7of recent auto-actions, pruned to a day, that :func:`counters` reads into the
8:class:`~ynab_agent.policy.floor.AutoActionCounters` the floor checks. Without
9it the counters are always zero and the breaker can never trip.
10 
11A "run" has no shared identity across the per-transaction workflows, so it is
12approximated as a short rolling window โ€” about one poll cycle's burst (the
13poller defaults to hourly). "Today" is the trailing 24 h. Both are rate limits
14the floor compares to its caps. Entries are keyed by ``ynab_id`` and
15deduplicated, so a retried record (or re-enriched transaction) counts once.
16"""
17 
18from __future__ import annotations
19 
20import datetime
21 
22from ynab_agent.domain.base import Frozen
23from ynab_agent.policy.floor import AutoActionCounters
24 
25# "This run" has no shared id across per-txn workflows, so approximate it as a
26# short rolling window โ€” about one poll cycle (the poller defaults to hourly).
27RUN_WINDOW = datetime.timedelta(hours=1)
28# "Today" โ€” the trailing day the per-day cap bounds, and the prune horizon, so
29# the carried tail never grows without limit.
30DAY_WINDOW = datetime.timedelta(hours=24)
31 
32 
33class AutoActionEntry(Frozen):
34 """One auto-action that landed: its transaction id and when it landed."""
35 
36 ynab_id: str
37 at: datetime.datetime
38 
39 
40class AutoActionLedgerState(Frozen):
41 """The pruned tail of recent auto-actions โ€” the whole breaker memory.
42 
43 Held as Temporal workflow state and carried across continue-as-new, so it is
44 a frozen value like the rest of the domain.
45 """
46 
47 entries: tuple[AutoActionEntry, ...] = ()
48 
49 
50def record(
51 state: AutoActionLedgerState,
52 ynab_id: str,
53 now: datetime.datetime,
54 *,
55 retention: datetime.timedelta = DAY_WINDOW,
56) -> AutoActionLedgerState:
57 """Record an auto-action for ``ynab_id``; dedup it and prune. Pure.
58 
59 Any existing entry for the same ``ynab_id`` is dropped before appending,
60 so a retried record or a re-enriched transaction counts exactly once.
61 Entries older than ``retention`` are pruned, so the carried tail stays
62 bounded.
63 """
64 kept = tuple(
65 entry
66 for entry in state.entries
67 if entry.ynab_id != ynab_id and now - entry.at < retention
68 )
69 return AutoActionLedgerState(
70 entries=(*kept, AutoActionEntry(ynab_id=ynab_id, at=now))
71 )
72 
73 
74def counters(
75 state: AutoActionLedgerState,
76 now: datetime.datetime,
77 *,
78 run_window: datetime.timedelta = RUN_WINDOW,
79 day_window: datetime.timedelta = DAY_WINDOW,
80) -> AutoActionCounters:
81 """Project the tail into the floor's counters at ``now``. Pure.
82 
83 ``this_run`` is the count within ``run_window`` (the recent burst);
84 ``today`` is the count within ``day_window``. The floor (``check_floor``)
85 compares these to its per-run / per-day caps.
86 """
87 this_run = sum(1 for entry in state.entries if now - entry.at < run_window)
88 today = sum(1 for entry in state.entries if now - entry.at < day_window)
89 return AutoActionCounters(this_run=this_run, today=today)

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
src/ynab_agent/workflow/auto_action_ledger_workflow.py60 lines ยท Python
โ‹ฏ 35 lines hidden (lines 1โ€“35)
1"""The durable auto-action circuit-breaker ledger (SPEC ยง0.6 Layer 1).
2 
3A singleton, long-lived workflow (id :data:`AUTO_ACTION_LEDGER_WORKFLOW_ID`)
4holding the recent-auto-action tail as Temporal state โ€” the memory that makes
5the hard floor's per-run / per-day breaker actually *bind*. Without it the
6counters are always zero and the cap can never trip (SPEC ยง0.6). Born on the
7first auto-action โ€” ``record_auto_action`` does a signal-with-start โ€” and
8lives forever, continuing-as-new to keep its history bounded. A thin durable
9shell over the pure :mod:`ynab_agent.policy.auto_action_ledger` folds, exactly
10like ``alert_ledger_workflow`` over ``alert.ledger``:
11 
12* the ``counters`` query projects the tail into the floor's counters at a given
13 ``now`` without mutating anything;
14* the ``record`` signal folds a landed auto-action into the tail.
15 
16``now`` travels in with the query (a query cannot read the wall clock); the
17signal stamps ``workflow.now`` so replay stays deterministic.
18"""
19 
20from __future__ import annotations
21 
22from temporalio import workflow
23 
24from ynab_agent.workflow.auto_action_types import CountersRequest, LedgerParams
25 
26with workflow.unsafe.imports_passed_through():
27 from ynab_agent.policy.auto_action_ledger import (
28 AutoActionLedgerState,
29 counters,
30 record,
31 )
32 from ynab_agent.policy.floor import AutoActionCounters
33 
34 
35@workflow.defn
36class AutoActionLedgerWorkflow:
37 """The deployment's one durable auto-action tail (one fold per action)."""
38 
39 def __init__(self) -> None:
40 """Start empty; the run method adopts any carried-forward state."""
41 self._state = AutoActionLedgerState()
42 
43 @workflow.run
44 async def run(self, params: LedgerParams) -> None:
45 """Hold the tail, folding signals until history wants rolling."""
46 self._state = params.state
47 await workflow.wait_condition(
48 lambda: workflow.info().is_continue_as_new_suggested()
49 )
50 workflow.continue_as_new(LedgerParams(state=self._state))
51 
52 @workflow.signal
53 def record(self, ynab_id: str) -> None:
54 """Record that an auto-action for ``ynab_id`` just landed."""
55 self._state = record(self._state, ynab_id, now=workflow.now())
56 
57 @workflow.query
58 def counters(self, request: CountersRequest) -> AutoActionCounters:
59 """Project the tail into the floor's counters at ``request.now``."""
โ‹ฏ 1 line hidden (lines 60โ€“60)
60 return counters(self._state, request.now)

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
src/ynab_agent/workflow/activities.py607 lines ยท Python
โ‹ฏ 97 lines hidden (lines 1โ€“97)
1"""The I/O ports of the transaction lifecycle, as Temporal activities.
2 
3Every side effect the workflow performs โ€” reading YNAB, committing a write,
4sending email, the agentic enrichment/interpretation/converge steps โ€” is an
5activity, so neither the model's nor the spine's I/O runs in workflow code
6(SPEC ยง0.5). These are *stubs*: typed signatures with no body. The real
7implementations (YNAB/AgentMail MCP, Pydantic AI) are wired in a later step; the
8workflow tests register mock implementations.
9"""
10 
11from __future__ import annotations
12 
13from typing import TYPE_CHECKING
14 
15from temporalio import activity
16 
17from ynab_agent.domain.allocations import (
18 ProposedCategory,
19 ProposedSplit,
20 ResolvedCategory,
22from ynab_agent.domain.effects import FeedRuleLearning, MessagePurpose
23from ynab_agent.domain.events import ConvergeOutcome, EnrichmentOutcome
24from ynab_agent.domain.ids import CategoryId
25from ynab_agent.domain.proposal import Decision, Proposal
26from ynab_agent.domain.signals import InboundSignal
27from ynab_agent.domain.transaction import YnabSnapshot
28from ynab_agent.policy.converge import TargetState
29from ynab_agent.workflow.types import ReplyOutcome
30 
31if TYPE_CHECKING:
32 import datetime
33 
34 # Annotation-only: never imported at runtime, so the agentic/model stack
35 # (pydantic-ai) never enters the workflow sandbox. The enrich body imports
36 # it lazily, inside the activity, where it runs outside the sandbox.
37 from ynab_agent.agentic.enrich import CandidateCategory
38 from ynab_agent.budget.overspend import CategorySpend
39 from ynab_agent.domain.rule import Rule
40 from ynab_agent.policy.floor import AutoActionCounters
41 
42 
43@activity.defn
44async def fetch_snapshot(ynab_id: str) -> YnabSnapshot | None:
45 """Read the current YNAB snapshot, or ``None`` if not yet imported.
46 
47 The YNAB client is imported lazily (keeping httpx out of the sandbox) and
48 its blocking call runs off the event loop.
49 """
50 import asyncio
51 
52 from ynab_agent.ynab.client import YnabClient
53 
54 client = YnabClient.from_env()
55 return await asyncio.to_thread(client.snapshot, ynab_id)
56 
57 
58def _candidates_from_spends(
59 spends: tuple[CategorySpend, ...],
60) -> tuple[CandidateCategory, ...]:
61 """Map the budget's live categories to the agent's candidate choices."""
62 from ynab_agent.agentic.enrich import CandidateCategory
63 
64 return tuple(
65 CandidateCategory(id=str(spend.category), name=spend.name)
66 for spend in spends
67 )
68 
69 
70async def _load_payee_rules(payee: str) -> list[Rule]:
71 """Query the durable rule registry for this payee's rules (W5, SPEC ยง14).
72 
73 Returns ``[]`` when the registry has not been started yet (no learning has
74 happened) or is unreachable โ€” the conservative fallback, which routes the
75 transaction to ASK rather than risking an auto-apply on stale knowledge.
76 """
77 from temporalio.service import RPCError
78 
79 from ynab_agent.domain.rule import Rule
80 from ynab_agent.workflow.registry_types import REGISTRY_WORKFLOW_ID
81 from ynab_agent.workflow.temporal_client import client
82 
83 temporal = await client()
84 handle = temporal.get_workflow_handle(REGISTRY_WORKFLOW_ID)
85 try:
86 # ``result_type`` is load-bearing: the pydantic data converter decodes a
87 # query payload to plain ``dict``s without it, and the gate reads real
88 # ``Rule`` objects (``rule.match``) โ€” a dict there raises AttributeError
89 # and the enrich activity retries forever (SPEC ยง14 gate-load path).
90 rules = await handle.query(
91 "payee_rules", payee, result_type=tuple[Rule, ...]
92 )
93 except RPCError:
94 return []
95 return list(rules)
96 
97 
98async def _load_auto_action_counters(
99 now: datetime.datetime,
100) -> AutoActionCounters:
101 """Read the live circuit-breaker counts from the durable ledger (SPEC ยง0.6).
102 
103 Returns zeros when the ledger has not been started yet (no auto-action has
104 ever happened) or is unreachable โ€” the correct conservative default *here*,
105 since a never-started ledger genuinely means zero auto-actions and the
106 breaker must not trip on its own absence (contrast ``_load_payee_rules``,
107 which fails *closed* to ASK because an unknown rule must never auto-apply).
108 """
109 from temporalio.service import RPCError
110 
111 from ynab_agent.policy.floor import AutoActionCounters
112 from ynab_agent.workflow.auto_action_types import (
113 AUTO_ACTION_LEDGER_WORKFLOW_ID,
114 CountersRequest,
115 )
116 from ynab_agent.workflow.temporal_client import client
117 
118 temporal = await client()
119 handle = temporal.get_workflow_handle(AUTO_ACTION_LEDGER_WORKFLOW_ID)
120 try:
121 result: AutoActionCounters = await handle.query(
122 "counters",
123 CountersRequest(now=now),
124 result_type=AutoActionCounters,
125 )
126 except RPCError:
127 return AutoActionCounters()
128 return result
129 
130 
131async def _load_enrichment_inputs(
132 snapshot: YnabSnapshot,
133 now: datetime.datetime,
134) -> tuple[tuple[CandidateCategory, ...], list[Rule], AutoActionCounters]:
135 """Fetch the candidate categories, in-scope rules, and live auto counters.
136 
137 The candidates are the budget's live categories, read from YNAB (the source
138 of truth); the rules come from the durable registry, keyed on the payee; the
139 auto-action counters come from the durable circuit-breaker ledger (SPEC
140 ยง0.6), so the per-run / per-day cap reads real counts and can trip. The gate
141 (SPEC ยง4.2, ยง14) decides auto-vs-ask over the loaded rules โ€” auto-applying
142 only a blessed one, still bounded by the floor.
143 """
144 import asyncio
145 
146 from ynab_agent.ynab.client import YnabClient
147 
148 client = YnabClient.from_env()
149 spends = await asyncio.to_thread(client.category_spends)
150 rules = await _load_payee_rules(snapshot.payee)
151 counters = await _load_auto_action_counters(now)
152 return _candidates_from_spends(spends), rules, counters
153 
โ‹ฏ 454 lines hidden (lines 154โ€“607)
154 
155@activity.defn
156async def enrich(snapshot: YnabSnapshot) -> EnrichmentOutcome:
157 """Assemble the proposal and route via the gate (the agentic middle).
158 
159 The model stack is imported lazily, here in the activity body, so it never
160 enters the workflow sandbox. The gate decides autonomy from the rules; the
161 agent runs only to produce the proposal a human is asked to confirm.
162 """
163 from datetime import UTC, datetime
164 
165 from ynab_agent.agentic.enrich import decide_enrichment
166 
167 now = datetime.now(UTC)
168 candidates, rules, counters = await _load_enrichment_inputs(snapshot, now)
169 return await decide_enrichment(
170 snapshot, candidates, rules, counters, now=now
171 )
172 
173 
174@activity.defn
175async def commit_to_ynab(ynab_id: str, decision: Decision) -> None:
176 """Commit a decision to YNAB (the deterministic write).
177 
178 Lazy YNAB client (httpx stays out of the sandbox); the blocking write runs
179 off the event loop.
180 """
181 import asyncio
182 
183 from ynab_agent.ynab.client import YnabClient
184 
185 client = YnabClient.from_env()
186 await asyncio.to_thread(client.commit, ynab_id, decision)
187 
188 
189@activity.defn
190async def read_back(ynab_id: str) -> TargetState | None:
191 """Read the post-write end-state for verification, or ``None`` if unread."""
192 import asyncio
193 
194 from ynab_agent.ynab.client import YnabClient
195 
196 client = YnabClient.from_env()
197 return await asyncio.to_thread(client.read_back, ynab_id)
198 
199 
200def _txn_label(ynab_id: str) -> str:
201 """The per-transaction idempotency label (open-thread dedup; SPEC ยง5)."""
202 return f"yatxn-{ynab_id}"
203 
204 
205def _seq_label(ynab_id: str, action_seq: int) -> str:
206 """The per-action idempotency label (send dedup; SPEC ยง3)."""
207 return f"yaseq-{ynab_id}-{action_seq}"
208 
209 
210def _subject(snapshot: YnabSnapshot, category: str | None) -> str:
211 """The thread's subject: payee + amount, and the suggested category.
212 
213 No ``[YNAB]`` prefix โ€” the sender address is a known contact. Naming the
214 proposed category lets the owner act from the subject line alone.
215 """
216 base = f"{snapshot.payee} โ€” {snapshot.amount}"
217 return f"{base} ยท {category}?" if category else base
218 
219 
220def _date_display(day: datetime.date) -> str:
221 """A short, friendly transaction date, e.g. ``May 29`` (no leading zero)."""
222 return f"{day.strftime('%b')} {day.day}"
223 
224 
225def _allocation_display(
226 allocation: ProposedCategory | ProposedSplit, names: dict[str, str]
227) -> str:
228 """A human display of the proposed allocation, by category name."""
229 if isinstance(allocation, ProposedCategory):
230 return names.get(str(allocation.category), str(allocation.category))
231 return " + ".join(
232 names.get(str(line.category), str(line.category))
233 for line in allocation.lines
234 )
235 
236 
237async def _read_for_compose(
238 ynab_id: str,
239) -> tuple[YnabSnapshot, dict[str, str]]:
240 """Re-read the YNAB snapshot and a category-idโ†’name map for composing.
241 
242 Both come from YNAB (the source of truth) at send time โ€” there is no stored
243 copy of the facts or the category names (SPEC ยง0.5, store-free).
244 """
245 import asyncio
246 
247 from ynab_agent.ynab.client import YnabClient
248 
249 client = YnabClient.from_env()
250 snapshot = await asyncio.to_thread(client.snapshot, ynab_id)
251 if snapshot is None:
252 msg = f"transaction {ynab_id} not found in YNAB at compose time"
253 raise RuntimeError(msg)
254 spends = await asyncio.to_thread(client.category_spends)
255 names = {str(spend.category): spend.name for spend in spends}
256 return snapshot, names
257 
258 
259def _render_message(
260 snapshot: YnabSnapshot,
261 proposal: Proposal | None,
262 purpose: MessagePurpose,
263 names: dict[str, str],
264) -> str:
265 """Lay out one message body for the thread (deterministic; SPEC ยง5).
266 
267 The proposal's category + alternatives (model-chosen upstream) are resolved
268 to names here and handed to the template โ€” no model call at send time.
269 """
270 from ynab_agent.agentic.compose import ComposeRequest, render_body
271 
272 proposed = (
273 _allocation_display(proposal.allocation, names)
274 if proposal is not None
275 else None
276 )
277 alternatives = (
278 tuple(names.get(str(alt), str(alt)) for alt in proposal.alternatives)
279 if proposal is not None
280 else ()
281 )
282 request = ComposeRequest(
283 purpose=purpose.value,
284 payee=snapshot.payee,
285 amount_display=str(snapshot.amount),
286 txn_date=_date_display(snapshot.txn_date),
287 memo=snapshot.memo,
288 proposed_category=proposed,
289 alternatives=alternatives,
290 rationale=proposal.rationale if proposal is not None else None,
291 )
292 return render_body(request)
293 
294 
295@activity.defn
296async def open_thread(ynab_id: str, proposal: Proposal | None) -> str:
297 """Open the AgentMail thread by sending the proposal; returns its id.
298 
299 A thread starts on its first send (AgentMail has no empty-thread create), so
300 this composes + sends the proposal as the opening email. ``proposal`` is the
301 current best guess (carried from workflow state); the txn facts + category
302 names are re-read from YNAB (the source of truth) at compose time. The open
303 is idempotent on the per-transaction label, so a retry re-finds the thread
304 rather than sending a duplicate.
305 """
306 import asyncio
307 
308 from ynab_agent.mail.client import MailClient
309 from ynab_agent.settings import Settings
310 
311 settings = Settings()
312 snapshot, names = await _read_for_compose(ynab_id)
313 body = _render_message(snapshot, proposal, MessagePurpose.PROPOSAL, names)
314 proposed = (
315 _allocation_display(proposal.allocation, names)
316 if proposal is not None
317 else None
318 )
319 mail = MailClient.from_env()
320 return await asyncio.to_thread(
321 mail.open_thread,
322 inbox_id=settings.inbox,
323 to=list(settings.owners),
324 subject=_subject(snapshot, proposed),
325 body=body,
326 txn_label=_txn_label(ynab_id),
327 )
328 
329 
330@activity.defn
331async def send_thread_message(
332 ynab_id: str,
333 thread_id: str | None,
334 purpose: MessagePurpose,
335 action_seq: int,
336 proposal: Proposal | None,
337) -> None:
338 """Send a follow-up message on the transaction's thread.
339 
340 ``action_seq`` is the per-transaction idempotency key: the send dedups on it
341 so a retry never double-sends (SPEC ยง3). ``proposal`` is the current best
342 guess where the purpose needs it (re-proposal); other purposes derive their
343 content from a re-read of the YNAB snapshot.
344 """
345 import asyncio
346 
347 from ynab_agent.mail.client import MailClient
348 from ynab_agent.settings import Settings
349 
350 if thread_id is None:
351 msg = f"cannot send {purpose.value} for {ynab_id}: no thread open yet"
352 raise RuntimeError(msg)
353 settings = Settings()
354 snapshot, names = await _read_for_compose(ynab_id)
355 body = _render_message(snapshot, proposal, purpose, names)
356 mail = MailClient.from_env()
357 await asyncio.to_thread(
358 mail.send_on_thread,
359 inbox_id=settings.inbox,
360 thread_id=thread_id,
361 body=body,
362 seq_label=_seq_label(ynab_id, action_seq),
363 )
364 
365 
366def _proposed_category_id(proposal: Proposal | None) -> CategoryId | None:
367 """The single proposed category id, or None (a split is not approvable)."""
368 if proposal is None:
369 return None
370 allocation = proposal.allocation
371 if isinstance(allocation, ProposedCategory):
372 return allocation.category
373 return None
374 
375 
376@activity.defn
377async def interpret_inbound(
378 signal: InboundSignal,
379 snapshot: YnabSnapshot,
380 proposal: Proposal | None,
381) -> ReplyOutcome:
382 """Interpret a human reply into an answer or a question (SPEC ยง3, ยง5).
383 
384 Free-form: the model reads whether the reply approves the proposal, names a
385 different category, or asks a question. The spine โ€” not the model โ€” stamps
386 the human + time into the resulting Decision. A non-reply inbound, or a
387 missing/split proposal, can't be answered directly, so ask rather than guess
388 a write. Names + candidates are re-read from YNAB at interpret time.
389 """
390 import asyncio
391 from datetime import UTC, datetime
392 
393 from ynab_agent.agentic.interpret import (
394 InterpretRequest,
395 interpret,
396 to_reply_outcome,
397 )
398 from ynab_agent.domain.signals import ReplySignal
399 from ynab_agent.workflow.types import ClarifyOutcome
400 from ynab_agent.ynab.client import YnabClient
401 
402 proposed_id = _proposed_category_id(proposal)
403 if not isinstance(signal, ReplySignal) or proposed_id is None:
404 return ClarifyOutcome(
405 question="Could you say which category this should be?"
406 )
407 
408 client = YnabClient.from_env()
409 spends = await asyncio.to_thread(client.category_spends)
410 names = {str(spend.category): spend.name for spend in spends}
411 request = InterpretRequest(
412 reply_text=signal.text,
413 payee=snapshot.payee,
414 amount_display=str(snapshot.amount),
415 proposed_category_name=names.get(str(proposed_id), str(proposed_id)),
416 candidates=_candidates_from_spends(spends),
417 )
418 interpretation = await interpret(request)
419 return to_reply_outcome(
420 interpretation,
421 proposed_category=proposed_id,
422 decided_at=datetime.now(UTC),
423 )
424 
425 
426def _target_summary(target: TargetState | None, names: dict[str, str]) -> str:
427 """A short human description of an end-state, for a divergence note."""
428 if target is None:
429 return "(could not read)"
430 allocation = target.allocation
431 if isinstance(allocation, ResolvedCategory):
432 label = names.get(str(allocation.category), str(allocation.category))
433 else:
434 label = "a split"
435 return f"{label} โ€” {target.memo}" if target.memo else label
436 
437 
438@activity.defn
439async def converge(
440 snapshot: YnabSnapshot, instruction: InboundSignal
441) -> ConvergeOutcome:
442 """Converge a REVISING run to its target and verify it (SPEC ยง3).
443 
444 The agent reads the revision instruction into a target (retarget / memo /
445 no-change); the spine commits, then reads back and classifies the result. A
446 reconciled or closed-month transaction, a non-reply instruction, or anything
447 the model under-specifies routes to a human rather than a silent edit.
448 """
449 import asyncio
450 from datetime import UTC, datetime
451 
452 from ynab_agent.agentic.converge import (
453 RevisionRequest,
454 interpret_revision,
455 to_revision_plan,
456 )
457 from ynab_agent.domain.enums import DecidedBy
458 from ynab_agent.domain.events import (
459 CouldNotConfirm,
460 Diverged,
461 NeedsHuman,
462 NoChange,
463 Reapplied,
464 VerifyOutcome,
465 )
466 from ynab_agent.domain.signals import ReplySignal
467 from ynab_agent.policy.converge import (
468 classify_verify,
469 reconciliation_blocks,
470 target_of,
471 )
472 from ynab_agent.ynab.client import YnabClient
473 
474 if reconciliation_blocks(snapshot):
475 return NeedsHuman(
476 reason="reconciled or closed-month โ€” propose, don't silently edit"
477 )
478 if not isinstance(instruction, ReplySignal):
479 return NeedsHuman(reason="non-reply revision instruction unsupported")
480 
481 client = YnabClient.from_env()
482 spends = await asyncio.to_thread(client.category_spends)
483 names = {str(spend.category): spend.name for spend in spends}
484 current_name = (
485 names.get(str(snapshot.category_id), str(snapshot.category_id))
486 if snapshot.category_id is not None
487 else "(uncategorized)"
488 )
489 target = await interpret_revision(
490 RevisionRequest(
491 instruction=instruction.text,
492 current_category_name=current_name,
493 candidates=_candidates_from_spends(spends),
494 current_memo=snapshot.memo,
495 )
496 )
497 plan = to_revision_plan(target)
498 if not plan.changes:
499 return NoChange()
500 
501 category = (
502 CategoryId(plan.category_id)
503 if plan.category_id is not None
504 else snapshot.category_id
505 )
506 if category is None:
507 return NeedsHuman(reason="revision did not resolve a category")
508 decision = Decision(
509 allocation=ResolvedCategory(category=category),
510 memo=plan.memo if plan.memo is not None else snapshot.memo,
511 approved=True,
512 decided_by=DecidedBy.HUMAN,
513 decided_at=datetime.now(UTC),
514 )
515 await asyncio.to_thread(client.commit, snapshot.ynab_id, decision)
516 read = await asyncio.to_thread(client.read_back, snapshot.ynab_id)
517 verdict = classify_verify(read, target_of(decision))
518 if verdict is VerifyOutcome.MATCH:
519 return Reapplied(decision=decision)
520 if verdict is VerifyOutcome.COULD_NOT_CONFIRM:
521 return CouldNotConfirm()
522 return Diverged(
523 ynab_summary=_target_summary(read, names),
524 requested_summary=_target_summary(target_of(decision), names),
525 )
526 
527 
528@activity.defn
529async def feed_rule_learning(feed: FeedRuleLearning) -> None:
530 """Persist a confirm/correct event into the durable rule registry (W5).
531 
532 Signal-with-start on the singleton :class:`RuleRegistryWorkflow`: the first
533 learning event creates it, every later one just delivers the signal, and the
534 workflow folds the event into the rule table the autonomy gate reads (SPEC
535 ยง9, ยง14). The conflict policy reuses the running singleton rather than
536 starting a second registry.
537 """
538 from temporalio.common import WorkflowIDConflictPolicy
539 
540 from ynab_agent.workflow.registry_types import (
541 REGISTRY_WORKFLOW_ID,
542 RegistryParams,
543 )
544 from ynab_agent.workflow.temporal_client import client, task_queue
545 
546 temporal = await client()
547 await temporal.start_workflow(
548 "RuleRegistryWorkflow",
549 RegistryParams(),
550 id=REGISTRY_WORKFLOW_ID,
551 task_queue=task_queue(),
552 id_conflict_policy=WorkflowIDConflictPolicy.USE_EXISTING,
553 start_signal="record",
554 start_signal_args=[feed],
555 )
556 
557 
558@activity.defn
559async def record_auto_action(ynab_id: str) -> None:
560 """Record a landed auto-action in the durable breaker ledger (SPEC ยง0.6).
561 
562 Signal-with-start on the singleton ledger: the first auto-action creates it,
563 every later one just delivers the signal, and the ledger folds it into the
564 counts the hard floor reads (mirrors ``feed_rule_learning`` talking to the
565 registry). Best-effort โ€” a ledger hiccup must never block or fail the
566 categorization it bounds, so any error is logged and swallowed. The breaker
567 tolerates an occasional missed count; the per-txn ceiling still binds, and
568 the ``ynab_id`` key dedups a retry.
569 """
570 from temporalio.common import WorkflowIDConflictPolicy
571 
572 from ynab_agent.workflow.auto_action_types import (
573 AUTO_ACTION_LEDGER_WORKFLOW_ID,
574 LedgerParams,
575 )
576 from ynab_agent.workflow.temporal_client import client, task_queue
577 
578 try:
579 temporal = await client()
580 await temporal.start_workflow(
581 "AutoActionLedgerWorkflow",
582 LedgerParams(),
583 id=AUTO_ACTION_LEDGER_WORKFLOW_ID,
584 task_queue=task_queue(),
585 id_conflict_policy=WorkflowIDConflictPolicy.USE_EXISTING,
586 start_signal="record",
587 start_signal_args=[ynab_id],
588 )
589 except Exception:
590 activity.logger.warning(
591 "auto-action ledger record failed (best-effort): %s", ynab_id
592 )
593 
594 
595@activity.defn
596async def close_thread(thread_id: str) -> None:
597 """Label and close the AgentMail thread on archive."""
598 import asyncio
599 
600 from ynab_agent.mail.client import MailClient
601 from ynab_agent.settings import Settings
602 
603 settings = Settings()
604 mail = MailClient.from_env()
605 await asyncio.to_thread(
606 mail.close, inbox_id=settings.inbox, thread_id=thread_id
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
src/ynab_agent/domain/effects.py140 lines ยท Python
โ‹ฏ 103 lines hidden (lines 1โ€“103)
1"""Effects: data describing what the spine should do after a transition.
2 
3The state machine is pure and performs no I/O. It returns *effects* โ€” commit a
4write, send a thread message, set or cancel a timer, feed rule learning. The
5Temporal spine interprets and executes them (commitโ†’verify, idempotent sends,
6durable timers). Effects are values, so a transition is fully testable without
7touching YNAB or email.
8"""
9 
10from __future__ import annotations
11 
12import datetime
13from enum import StrEnum
14from typing import Annotated, Literal
15 
16from pydantic import Field
17 
18from ynab_agent.domain.base import Frozen
19from ynab_agent.domain.proposal import Decision
20from ynab_agent.domain.signals import InboundSignal
21 
22 
23class MessagePurpose(StrEnum):
24 """Why a thread message is being sent (shapes the prose; SPEC ยง3, ยง5)."""
25 
26 PROPOSAL = "proposal"
27 FYI = "fyi"
28 CONFIRM = "confirm"
29 CLARIFY = "clarify"
30 HANDOFF = "handoff"
31 REVISE_SUMMARY = "revise_summary"
32 POSSIBLY_INCONSISTENT = "possibly_inconsistent"
33 DIVERGED_READBACK = "diverged_readback"
34 ARCHIVE_NOTICE = "archive_notice"
35 OVERRIDE_NOTICE = "override_notice"
36 
37 
38class TimerKind(StrEnum):
39 """The lifecycle timers (SPEC ยง3)."""
40 
41 AMAZON_HOLD = "amazon_hold"
42 PATIENCE = "patience"
43 ARCHIVE = "archive"
44 
45 
46class RuleLearningKind(StrEnum):
47 """The human-decision events rule learning consumes (SPEC ยง9)."""
48 
49 CONFIRM = "confirm"
50 CORRECT = "correct"
51 
52 
53class OpenThread(Frozen):
54 """Create the AgentMail thread for this transaction (SPEC ยง5)."""
55 
56 kind: Literal["open_thread"] = "open_thread"
57 
58 
59class SendThreadMessage(Frozen):
60 """Send a message on the transaction's thread."""
61 
62 kind: Literal["send_message"] = "send_message"
63 purpose: MessagePurpose
64 
65 
66class CommitToYnab(Frozen):
67 """Commit a decision to YNAB (the spine does commitโ†’verify; SPEC ยง0.5)."""
68 
69 kind: Literal["commit_to_ynab"] = "commit_to_ynab"
70 decision: Decision
71 
72 
73class SetTimer(Frozen):
74 """Arm a durable timer with an absolute deadline."""
75 
76 kind: Literal["set_timer"] = "set_timer"
77 timer: TimerKind
78 deadline: datetime.datetime
79 
80 
81class CancelTimer(Frozen):
82 """Cancel a previously armed timer."""
83 
84 kind: Literal["cancel_timer"] = "cancel_timer"
85 timer: TimerKind
86 
87 
88class FeedRuleLearning(Frozen):
89 """Feed a confirm/correct event to rule learning (W5; SPEC ยง9).
90 
91 ``payee`` is carried so W5 can key the rule's match on it without re-reading
92 the snapshot. For a correction, ``prior`` carries the decision being
93 overturned, so W5 can demote *the rule that produced the prior decision*
94 (ยง3 rule 6), not the new one.
95 """
96 
97 kind: Literal["feed_rule_learning"] = "feed_rule_learning"
98 event: RuleLearningKind
99 payee: str
100 decision: Decision | None = None
101 prior: Decision | None = None
102 
103 
104class RecordAutoAction(Frozen):
105 """Record a landed auto-action in the circuit-breaker ledger (SPEC ยง0.6).
106 
107 Emitted alongside the commit when a blessed rule auto-applies, so the hard
108 floor's per-run / per-day counts are real and the breaker can trip. Keyed by
109 ``ynab_id`` so a retry or re-enrichment counts the transaction once.
110 """
111 
112 kind: Literal["record_auto_action"] = "record_auto_action"
113 ynab_id: str
114 
โ‹ฏ 26 lines hidden (lines 115โ€“140)
115 
116class ReplayBuffered(Frozen):
117 """Re-deliver the signals buffered while in DISCOVERED (SPEC ยง3)."""
118 
119 kind: Literal["replay_buffered"] = "replay_buffered"
120 signals: tuple[InboundSignal, ...]
121 
122 
123class CloseThread(Frozen):
124 """Label and close the AgentMail thread on archive (SPEC ยง13)."""
125 
126 kind: Literal["close_thread"] = "close_thread"
127 
128 
129Effect = Annotated[
130 OpenThread
131 | SendThreadMessage
132 | CommitToYnab
133 | SetTimer
134 | CancelTimer
135 | FeedRuleLearning
136 | RecordAutoAction
137 | ReplayBuffered
138 | CloseThread,
139 Field(discriminator="kind"),

Emitted on Enriching โ†’ AutoApplied, beside the commit. It records the attempt, keyed by ynab_id.

src/ynab_agent/domain/state_machine.py ยท 630 lines
src/ynab_agent/domain/state_machine.py630 lines ยท Python
โ‹ฏ 230 lines hidden (lines 1โ€“230)
1"""The transaction lifecycle state machine (SPEC ยง3).
2 
3``advance(txn, event, now, policy)`` is a pure function: given the current
4transaction, a decided event, the current time, and the timer policy, it returns
5a :class:`Transition` โ€” the next transaction plus the effects the spine should
6execute. It performs no I/O and reads no clock; ``now`` is passed in, so replay
7is deterministic.
8 
9Dispatch is per-state (one handler each), and ``advance`` ends in
10``assert_never`` so mypy proves all ten states are handled. An event a state
11does not expect yields a ``REJECTED`` transition (no state change), never an
12exception, so the spine decides how to handle the surprise.
13 
14Interpretation notes (where the SPEC's diagram is collapsed for a pure model):
15 
16* The REVISING converge step already bundles commit *and* verify (SPEC ยง3 rules
17 3-4), so its ``Reapplied`` outcome lands directly in ``OPEN`` (re-approved and
18 verified), collapsing the degenerate ``APPLIED โ†’ OPEN`` hop the diagram draws.
19* ``AWAITING_HUMAN`` reached from a verify failure carries a ``flag`` and no
20 proposal; per the SPEC such flagged entries do *not* lapse into the generic
21 hand-off note, so ``PatienceExpired`` there is ignored (the ยง13 sweep tracks
22 it).
23"""
24 
25from __future__ import annotations
26 
27from enum import StrEnum
28from typing import TYPE_CHECKING, assert_never
29 
30from ynab_agent.domain.base import Frozen
31from ynab_agent.domain.effects import (
32 CancelTimer,
33 CloseThread,
34 CommitToYnab,
35 Effect,
36 FeedRuleLearning,
37 MessagePurpose,
38 OpenThread,
39 RecordAutoAction,
40 ReplayBuffered,
41 RuleLearningKind,
42 SendThreadMessage,
43 SetTimer,
44 TimerKind,
46from ynab_agent.domain.enums import AwaitingFlag, RevisingOrigin
47from ynab_agent.domain.events import (
48 AnswerReceived,
49 ArchiveWindowReached,
50 AskHuman,
51 AutoApply,
52 ClarifyRequested,
53 Converged,
54 CouldNotConfirm,
55 Diverged,
56 Enriched,
57 HoldDeadlineReached,
58 HoldResolved,
59 InboundReceived,
60 LifecycleEvent,
61 NeedsHuman,
62 NoChange,
63 OverrideDetected,
64 PatienceExpired,
65 Reapplied,
66 SnapshotMaterialized,
67 SnapshotUnavailable,
68 VerifyOutcome,
69 WriteVerified,
71from ynab_agent.domain.signals import ReplySignal
72from ynab_agent.domain.transaction import (
73 Applied,
74 Archived,
75 AutoApplied,
76 AwaitingHuman,
77 Discovered,
78 Enriching,
79 HoldAmazon,
80 Lapsed,
81 Open,
82 Revising,
83 Transaction,
84 TxnCore,
86 
87if TYPE_CHECKING:
88 import datetime
89 
90 from ynab_agent.domain.config import LifecyclePolicy
91 from ynab_agent.domain.proposal import Decision
92 
93 
94class TransitionKind(StrEnum):
95 """The disposition of an ``advance`` call."""
96 
97 ADVANCED = "advanced"
98 IGNORED = "ignored"
99 REJECTED = "rejected"
100 
101 
102class Transition(Frozen):
103 """The result of advancing a transaction.
104 
105 Attributes:
106 kind: ``ADVANCED`` (state and/or effects), ``IGNORED`` (legal no-op), or
107 ``REJECTED`` (the event is not valid in this state).
108 next: The resulting transaction (unchanged for IGNORED/REJECTED).
109 effects: The effects the spine should execute, in order.
110 reason: A short explanation for IGNORED/REJECTED.
111 """
112 
113 kind: TransitionKind
114 next: Transaction
115 effects: tuple[Effect, ...] = ()
116 reason: str | None = None
117 
118 
119def _advanced(nxt: Transaction, *effects: Effect) -> Transition:
120 return Transition(kind=TransitionKind.ADVANCED, next=nxt, effects=effects)
121 
122 
123def _ignored(txn: Transaction, reason: str) -> Transition:
124 return Transition(kind=TransitionKind.IGNORED, next=txn, reason=reason)
125 
126 
127def _rejected(txn: Transaction, event: LifecycleEvent) -> Transition:
128 reason = f"{type(event).__name__} is not valid in {txn.state}"
129 return Transition(kind=TransitionKind.REJECTED, next=txn, reason=reason)
130 
131 
132def advance(
133 txn: Transaction,
134 event: LifecycleEvent,
135 *,
136 now: datetime.datetime,
137 policy: LifecyclePolicy,
138) -> Transition:
139 """Advance a transaction by one event. Pure; see the module docstring."""
140 match txn:
141 case Discovered():
142 return _from_discovered(txn, event, now=now, policy=policy)
143 case HoldAmazon():
144 return _from_hold_amazon(txn, event)
145 case Enriching():
146 return _from_enriching(txn, event, now=now, policy=policy)
147 case AutoApplied():
148 return _from_auto_applied(txn, event, now=now, policy=policy)
149 case AwaitingHuman():
150 return _from_awaiting_human(txn, event, now=now, policy=policy)
151 case Applied():
152 return _from_applied(txn, event, now=now, policy=policy)
153 case Open():
154 return _from_open(txn, event)
155 case Lapsed():
156 return _from_lapsed(txn, event)
157 case Revising():
158 return _from_revising(txn, event, now=now, policy=policy)
159 case Archived():
160 return _from_archived(txn, event)
161 assert_never(txn)
162 
163 
164def _from_discovered(
165 txn: Discovered,
166 event: LifecycleEvent,
167 *,
168 now: datetime.datetime,
169 policy: LifecyclePolicy,
170) -> Transition:
171 match event:
172 case SnapshotMaterialized(snapshot=snap, hold_for_amazon=hold):
173 core = TxnCore(snapshot=snap, thread_id=txn.thread_id)
174 replay: tuple[Effect, ...] = (
175 (ReplayBuffered(signals=txn.pending),) if txn.pending else ()
176 )
177 if hold:
178 deadline = now + policy.amazon_hold
179 return _advanced(
180 HoldAmazon(core=core, amazon_deadline=deadline),
181 SetTimer(timer=TimerKind.AMAZON_HOLD, deadline=deadline),
182 *replay,
183 )
184 return _advanced(Enriching(core=core), *replay)
185 case SnapshotUnavailable():
186 return _ignored(txn, "snapshot not yet in YNAB; staying DISCOVERED")
187 case InboundReceived(signal=sig):
188 thread_id = txn.thread_id
189 if thread_id is None and isinstance(sig, ReplySignal):
190 thread_id = sig.thread_id
191 buffered = Discovered(
192 ynab_id=txn.ynab_id,
193 thread_id=thread_id,
194 pending=(*txn.pending, sig),
195 )
196 return _advanced(buffered)
197 case _:
198 return _rejected(txn, event)
199 
200 
201def _from_hold_amazon(txn: HoldAmazon, event: LifecycleEvent) -> Transition:
202 match event:
203 case HoldResolved(snapshot=snap):
204 core = txn.core.model_copy(update={"snapshot": snap})
205 return _advanced(
206 Enriching(core=core),
207 CancelTimer(timer=TimerKind.AMAZON_HOLD),
208 )
209 case HoldDeadlineReached():
210 # Deadline hit: enrich and fall back to asking (SPEC ยง3).
211 return _advanced(Enriching(core=txn.core))
212 case InboundReceived(signal=sig):
213 # A matched receipt (or reply) short-circuits the hold (SPEC ยง6).
214 return _advanced(
215 Enriching(core=txn.core),
216 CancelTimer(timer=TimerKind.AMAZON_HOLD),
217 ReplayBuffered(signals=(sig,)),
218 )
219 case _:
220 return _rejected(txn, event)
221 
222 
223def _from_enriching(
224 txn: Enriching,
225 event: LifecycleEvent,
226 *,
227 now: datetime.datetime,
228 policy: LifecyclePolicy,
229) -> Transition:
230 match event:
231 case Enriched(outcome=outcome):
232 match outcome:
233 case AutoApply(decision=decision):
234 return _advanced(
235 AutoApplied(core=txn.core, decision=decision),
236 CommitToYnab(decision=decision),
237 RecordAutoAction(
238 ynab_id=str(txn.core.snapshot.ynab_id)
239 ),
240 )
241 case AskHuman(proposal=proposal):
242 deadline = now + policy.patience_window
243 # First contact: open_thread sends the proposal as the
244 # thread's opening email (AgentMail starts a thread on its
245 # first send), so no separate send is emitted. A re-proposal
โ‹ฏ 385 lines hidden (lines 246โ€“630)
246 # (the thread is already open) sends it as a reply.
247 send_effect: tuple[Effect, ...] = (
248 (OpenThread(),)
249 if txn.core.thread_id is None
250 else (
251 SendThreadMessage(purpose=MessagePurpose.PROPOSAL),
252 )
253 )
254 return _advanced(
255 AwaitingHuman(
256 core=txn.core,
257 proposal=proposal,
258 patience_deadline=deadline,
259 ),
260 *send_effect,
261 SetTimer(timer=TimerKind.PATIENCE, deadline=deadline),
262 )
263 assert_never(outcome)
264 case InboundReceived(signal=sig):
265 # Re-feed so enrichment incorporates the new signal; stay ENRICHING.
266 return Transition(
267 kind=TransitionKind.ADVANCED,
268 next=txn,
269 effects=(ReplayBuffered(signals=(sig,)),),
270 )
271 case _:
272 return _rejected(txn, event)
273 
274 
275def _from_auto_applied(
276 txn: AutoApplied,
277 event: LifecycleEvent,
278 *,
279 now: datetime.datetime,
280 policy: LifecyclePolicy,
281) -> Transition:
282 match event:
283 case WriteVerified(outcome=outcome):
284 return _resolve_write_verify(
285 core=txn.core,
286 decision=txn.decision,
287 outcome=outcome,
288 applied_message=MessagePurpose.FYI,
289 learning=None,
290 now=now,
291 policy=policy,
292 )
293 case _:
294 return _rejected(txn, event)
295 
296 
297def _from_applied(
298 txn: Applied,
299 event: LifecycleEvent,
300 *,
301 now: datetime.datetime,
302 policy: LifecyclePolicy,
303) -> Transition:
304 match event:
305 case WriteVerified(outcome=outcome):
306 return _resolve_write_verify(
307 core=txn.core,
308 decision=txn.decision,
309 outcome=outcome,
310 applied_message=MessagePurpose.CONFIRM,
311 learning=RuleLearningKind.CONFIRM,
312 now=now,
313 policy=policy,
314 )
315 case _:
316 return _rejected(txn, event)
317 
318 
319def _resolve_write_verify(
320 *,
321 core: TxnCore,
322 decision: Decision,
323 outcome: VerifyOutcome,
324 applied_message: MessagePurpose,
325 learning: RuleLearningKind | None,
326 now: datetime.datetime,
327 policy: LifecyclePolicy,
328) -> Transition:
329 """Shared AUTO_APPLIED/APPLIED โ†’ OPEN-or-AWAITING handling (SPEC ยง3)."""
330 match outcome:
331 case VerifyOutcome.MATCH:
332 archive = now + policy.archive_window
333 effects: tuple[Effect, ...] = (
334 SendThreadMessage(purpose=applied_message),
335 SetTimer(timer=TimerKind.ARCHIVE, deadline=archive),
336 )
337 if learning is not None:
338 effects = (
339 FeedRuleLearning(
340 event=learning,
341 payee=core.snapshot.payee,
342 decision=decision,
343 ),
344 *effects,
345 )
346 return _advanced(Open(core=core, decision=decision), *effects)
347 case VerifyOutcome.COULD_NOT_CONFIRM:
348 return _enter_flagged_awaiting(
349 core,
350 AwaitingFlag.POSSIBLY_INCONSISTENT,
351 MessagePurpose.POSSIBLY_INCONSISTENT,
352 now=now,
353 policy=policy,
354 )
355 case VerifyOutcome.DIVERGED:
356 return _enter_flagged_awaiting(
357 core,
358 AwaitingFlag.DIVERGED,
359 MessagePurpose.DIVERGED_READBACK,
360 now=now,
361 policy=policy,
362 )
363 assert_never(outcome)
364 
365 
366def _enter_flagged_awaiting(
367 core: TxnCore,
368 flag: AwaitingFlag,
369 message: MessagePurpose,
370 *,
371 now: datetime.datetime,
372 policy: LifecyclePolicy,
373) -> Transition:
374 """Route a verify failure to a flagged AWAITING_HUMAN with a read-back."""
375 deadline = now + policy.patience_window
376 return _advanced(
377 AwaitingHuman(core=core, patience_deadline=deadline, flag=flag),
378 SendThreadMessage(purpose=message),
379 SetTimer(timer=TimerKind.PATIENCE, deadline=deadline),
380 )
381 
382 
383def _from_awaiting_human(
384 txn: AwaitingHuman,
385 event: LifecycleEvent,
386 *,
387 now: datetime.datetime,
388 policy: LifecyclePolicy,
389) -> Transition:
390 match event:
391 case AnswerReceived(decision=decision):
392 return _advanced(
393 Applied(core=txn.core, decision=decision),
394 CommitToYnab(decision=decision),
395 CancelTimer(timer=TimerKind.PATIENCE),
396 )
397 case ClarifyRequested():
398 deadline = now + policy.patience_window
399 return _advanced(
400 AwaitingHuman(
401 core=txn.core,
402 proposal=txn.proposal,
403 patience_deadline=deadline,
404 flag=txn.flag,
405 ),
406 SendThreadMessage(purpose=MessagePurpose.CLARIFY),
407 SetTimer(timer=TimerKind.PATIENCE, deadline=deadline),
408 )
409 case PatienceExpired():
410 if txn.flag is not AwaitingFlag.NONE:
411 # Flagged (inconsistent/diverged) entries do not generic-lapse.
412 return _ignored(txn, "flagged entry does not lapse (SPEC ยง3)")
413 archive = now + policy.archive_window
414 return _advanced(
415 Lapsed(core=txn.core, proposal=txn.proposal),
416 SendThreadMessage(purpose=MessagePurpose.HANDOFF),
417 SetTimer(timer=TimerKind.ARCHIVE, deadline=archive),
418 )
419 case InboundReceived(signal=sig):
420 # Re-feed for interpretation into an answer/clarify; keep waiting.
421 return Transition(
422 kind=TransitionKind.ADVANCED,
423 next=txn,
424 effects=(ReplayBuffered(signals=(sig,)),),
425 )
426 case _:
427 return _rejected(txn, event)
428 
429 
430def _from_open(txn: Open, event: LifecycleEvent) -> Transition:
431 match event:
432 case InboundReceived(signal=sig):
433 return _advanced(
434 Revising(
435 core=txn.core,
436 instruction=sig,
437 origin=RevisingOrigin.APPLIED,
438 prior=txn.decision,
439 ),
440 CancelTimer(timer=TimerKind.ARCHIVE),
441 )
442 case ArchiveWindowReached():
443 if not txn.core.snapshot.reconciled:
444 return _ignored(txn, "not reconciled yet; staying OPEN")
445 return _advanced(
446 Archived(core=txn.core, final=txn.decision),
447 CloseThread(),
448 )
449 case OverrideDetected(decision=human_decision):
450 # The owner recategorized in YNAB directly: a silent correction.
451 # Demote the driving rule back to Observe (CORRECT, carrying the
452 # prior agent decision so the *right* rule is demoted, ยง14.2) and
453 # tell them we noticed and backed off. Close the book if the txn is
454 # reconciled; otherwise adopt their value and wait (ARCHIVED needs a
455 # reconciled snapshot), so we never archive prematurely.
456 demote = FeedRuleLearning(
457 event=RuleLearningKind.CORRECT,
458 payee=txn.core.snapshot.payee,
459 decision=human_decision,
460 prior=txn.decision,
461 )
462 notice = SendThreadMessage(purpose=MessagePurpose.OVERRIDE_NOTICE)
463 if not txn.core.snapshot.reconciled:
464 return _advanced(
465 Open(core=txn.core, decision=human_decision),
466 demote,
467 notice,
468 )
469 return _advanced(
470 Archived(core=txn.core, final=human_decision),
471 demote,
472 notice,
473 CloseThread(),
474 )
475 case _:
476 return _rejected(txn, event)
477 
478 
479def _from_lapsed(txn: Lapsed, event: LifecycleEvent) -> Transition:
480 match event:
481 case InboundReceived(signal=sig):
482 return _advanced(
483 Revising(
484 core=txn.core,
485 instruction=sig,
486 origin=RevisingOrigin.LAPSED,
487 ),
488 CancelTimer(timer=TimerKind.ARCHIVE),
489 )
490 case ArchiveWindowReached():
491 snap = txn.core.snapshot
492 if not snap.reconciled:
493 return _ignored(txn, "not reconciled yet; staying LAPSED")
494 if not snap.categorized:
495 # Don't go silent: warn, then let the ยง13 sweep keep tracking.
496 return Transition(
497 kind=TransitionKind.ADVANCED,
498 next=txn,
499 effects=(
500 SendThreadMessage(
501 purpose=MessagePurpose.ARCHIVE_NOTICE
502 ),
503 ),
504 reason="uncategorized; warned before archiving (SPEC ยง3)",
505 )
506 return _advanced(
507 Archived(core=txn.core),
508 CloseThread(),
509 )
510 case _:
511 return _rejected(txn, event)
512 
513 
514def _from_revising(
515 txn: Revising,
516 event: LifecycleEvent,
517 *,
518 now: datetime.datetime,
519 policy: LifecyclePolicy,
520) -> Transition:
521 match event:
522 case Converged(outcome=outcome):
523 match outcome:
524 case Reapplied(decision=decision):
525 return _reapply(txn, decision, now=now, policy=policy)
526 case NoChange():
527 return _resolve_no_change(txn, now=now, policy=policy)
528 case CouldNotConfirm():
529 return _enter_flagged_awaiting(
530 txn.core,
531 AwaitingFlag.POSSIBLY_INCONSISTENT,
532 MessagePurpose.POSSIBLY_INCONSISTENT,
533 now=now,
534 policy=policy,
535 )
536 case Diverged():
537 return _enter_flagged_awaiting(
538 txn.core,
539 AwaitingFlag.DIVERGED,
540 MessagePurpose.DIVERGED_READBACK,
541 now=now,
542 policy=policy,
543 )
544 case NeedsHuman():
545 deadline = now + policy.patience_window
546 return _advanced(
547 AwaitingHuman(
548 core=txn.core, patience_deadline=deadline
549 ),
550 SendThreadMessage(purpose=MessagePurpose.PROPOSAL),
551 SetTimer(timer=TimerKind.PATIENCE, deadline=deadline),
552 )
553 assert_never(outcome)
554 case InboundReceived(signal=sig):
555 # Newest instruction wins (SPEC ยง3 rule 1): retain the correction
556 # and re-target the in-flight converge, rather than dropping it.
557 return Transition(
558 kind=TransitionKind.ADVANCED,
559 next=txn.model_copy(update={"instruction": sig}),
560 effects=(ReplayBuffered(signals=(sig,)),),
561 )
562 case _:
563 return _rejected(txn, event)
564 
565 
566def _reapply(
567 txn: Revising,
568 decision: Decision,
569 *,
570 now: datetime.datetime,
571 policy: LifecyclePolicy,
572) -> Transition:
573 """Re-applied revision: open, summarize, and feed learning (SPEC ยง3, ยง9).
574 
575 A real category change demotes the prior rule (CORRECT, carrying the prior
576 decision so W5 demotes the *right* rule); a same-category revision โ€” a
577 receipt adding a memo, or a late first answer from LAPSED โ€” only confirms.
578 """
579 archive = now + policy.archive_window
580 is_correction = (
581 txn.prior is not None and txn.prior.allocation != decision.allocation
582 )
583 payee = txn.core.snapshot.payee
584 if is_correction:
585 learning = FeedRuleLearning(
586 event=RuleLearningKind.CORRECT,
587 payee=payee,
588 decision=decision,
589 prior=txn.prior,
590 )
591 else:
592 learning = FeedRuleLearning(
593 event=RuleLearningKind.CONFIRM, payee=payee, decision=decision
594 )
595 return _advanced(
596 Open(core=txn.core, decision=decision),
597 learning,
598 SendThreadMessage(purpose=MessagePurpose.REVISE_SUMMARY),
599 SetTimer(timer=TimerKind.ARCHIVE, deadline=archive),
600 )
601 
602 
603def _resolve_no_change(
604 txn: Revising,
605 *,
606 now: datetime.datetime,
607 policy: LifecyclePolicy,
608) -> Transition:
609 """No-change exit depends on history (SPEC ยง3 rule 5).
610 
611 Already applied โ†’ OPEN (resting). Entered from LAPSED (never applied) โ†’
612 AWAITING_HUMAN, re-arming patience, so an unhandled txn is not mislabeled.
613 """
614 if txn.origin is RevisingOrigin.APPLIED and txn.prior is not None:
615 archive = now + policy.archive_window
616 return _advanced(
617 Open(core=txn.core, decision=txn.prior),
618 SetTimer(timer=TimerKind.ARCHIVE, deadline=archive),
619 )
620 deadline = now + policy.patience_window
621 return _advanced(
622 AwaitingHuman(core=txn.core, patience_deadline=deadline),
623 SetTimer(timer=TimerKind.PATIENCE, deadline=deadline),
624 )
625 
626 
627def _from_archived(txn: Archived, event: LifecycleEvent) -> Transition:
628 # Terminal. A late edit is handled by signal-with-start re-instantiating a
629 # fresh REVISING run under the same logical id, not by a transition here.
630 return _rejected(txn, event)

Signal-with-start on the singleton, mirroring feed_rule_learning. Swallows its own errors.

src/ynab_agent/workflow/activities.py ยท 607 lines
src/ynab_agent/workflow/activities.py607 lines ยท Python
โ‹ฏ 558 lines hidden (lines 1โ€“558)
1"""The I/O ports of the transaction lifecycle, as Temporal activities.
2 
3Every side effect the workflow performs โ€” reading YNAB, committing a write,
4sending email, the agentic enrichment/interpretation/converge steps โ€” is an
5activity, so neither the model's nor the spine's I/O runs in workflow code
6(SPEC ยง0.5). These are *stubs*: typed signatures with no body. The real
7implementations (YNAB/AgentMail MCP, Pydantic AI) are wired in a later step; the
8workflow tests register mock implementations.
9"""
10 
11from __future__ import annotations
12 
13from typing import TYPE_CHECKING
14 
15from temporalio import activity
16 
17from ynab_agent.domain.allocations import (
18 ProposedCategory,
19 ProposedSplit,
20 ResolvedCategory,
22from ynab_agent.domain.effects import FeedRuleLearning, MessagePurpose
23from ynab_agent.domain.events import ConvergeOutcome, EnrichmentOutcome
24from ynab_agent.domain.ids import CategoryId
25from ynab_agent.domain.proposal import Decision, Proposal
26from ynab_agent.domain.signals import InboundSignal
27from ynab_agent.domain.transaction import YnabSnapshot
28from ynab_agent.policy.converge import TargetState
29from ynab_agent.workflow.types import ReplyOutcome
30 
31if TYPE_CHECKING:
32 import datetime
33 
34 # Annotation-only: never imported at runtime, so the agentic/model stack
35 # (pydantic-ai) never enters the workflow sandbox. The enrich body imports
36 # it lazily, inside the activity, where it runs outside the sandbox.
37 from ynab_agent.agentic.enrich import CandidateCategory
38 from ynab_agent.budget.overspend import CategorySpend
39 from ynab_agent.domain.rule import Rule
40 from ynab_agent.policy.floor import AutoActionCounters
41 
42 
43@activity.defn
44async def fetch_snapshot(ynab_id: str) -> YnabSnapshot | None:
45 """Read the current YNAB snapshot, or ``None`` if not yet imported.
46 
47 The YNAB client is imported lazily (keeping httpx out of the sandbox) and
48 its blocking call runs off the event loop.
49 """
50 import asyncio
51 
52 from ynab_agent.ynab.client import YnabClient
53 
54 client = YnabClient.from_env()
55 return await asyncio.to_thread(client.snapshot, ynab_id)
56 
57 
58def _candidates_from_spends(
59 spends: tuple[CategorySpend, ...],
60) -> tuple[CandidateCategory, ...]:
61 """Map the budget's live categories to the agent's candidate choices."""
62 from ynab_agent.agentic.enrich import CandidateCategory
63 
64 return tuple(
65 CandidateCategory(id=str(spend.category), name=spend.name)
66 for spend in spends
67 )
68 
69 
70async def _load_payee_rules(payee: str) -> list[Rule]:
71 """Query the durable rule registry for this payee's rules (W5, SPEC ยง14).
72 
73 Returns ``[]`` when the registry has not been started yet (no learning has
74 happened) or is unreachable โ€” the conservative fallback, which routes the
75 transaction to ASK rather than risking an auto-apply on stale knowledge.
76 """
77 from temporalio.service import RPCError
78 
79 from ynab_agent.domain.rule import Rule
80 from ynab_agent.workflow.registry_types import REGISTRY_WORKFLOW_ID
81 from ynab_agent.workflow.temporal_client import client
82 
83 temporal = await client()
84 handle = temporal.get_workflow_handle(REGISTRY_WORKFLOW_ID)
85 try:
86 # ``result_type`` is load-bearing: the pydantic data converter decodes a
87 # query payload to plain ``dict``s without it, and the gate reads real
88 # ``Rule`` objects (``rule.match``) โ€” a dict there raises AttributeError
89 # and the enrich activity retries forever (SPEC ยง14 gate-load path).
90 rules = await handle.query(
91 "payee_rules", payee, result_type=tuple[Rule, ...]
92 )
93 except RPCError:
94 return []
95 return list(rules)
96 
97 
98async def _load_auto_action_counters(
99 now: datetime.datetime,
100) -> AutoActionCounters:
101 """Read the live circuit-breaker counts from the durable ledger (SPEC ยง0.6).
102 
103 Returns zeros when the ledger has not been started yet (no auto-action has
104 ever happened) or is unreachable โ€” the correct conservative default *here*,
105 since a never-started ledger genuinely means zero auto-actions and the
106 breaker must not trip on its own absence (contrast ``_load_payee_rules``,
107 which fails *closed* to ASK because an unknown rule must never auto-apply).
108 """
109 from temporalio.service import RPCError
110 
111 from ynab_agent.policy.floor import AutoActionCounters
112 from ynab_agent.workflow.auto_action_types import (
113 AUTO_ACTION_LEDGER_WORKFLOW_ID,
114 CountersRequest,
115 )
116 from ynab_agent.workflow.temporal_client import client
117 
118 temporal = await client()
119 handle = temporal.get_workflow_handle(AUTO_ACTION_LEDGER_WORKFLOW_ID)
120 try:
121 result: AutoActionCounters = await handle.query(
122 "counters",
123 CountersRequest(now=now),
124 result_type=AutoActionCounters,
125 )
126 except RPCError:
127 return AutoActionCounters()
128 return result
129 
130 
131async def _load_enrichment_inputs(
132 snapshot: YnabSnapshot,
133 now: datetime.datetime,
134) -> tuple[tuple[CandidateCategory, ...], list[Rule], AutoActionCounters]:
135 """Fetch the candidate categories, in-scope rules, and live auto counters.
136 
137 The candidates are the budget's live categories, read from YNAB (the source
138 of truth); the rules come from the durable registry, keyed on the payee; the
139 auto-action counters come from the durable circuit-breaker ledger (SPEC
140 ยง0.6), so the per-run / per-day cap reads real counts and can trip. The gate
141 (SPEC ยง4.2, ยง14) decides auto-vs-ask over the loaded rules โ€” auto-applying
142 only a blessed one, still bounded by the floor.
143 """
144 import asyncio
145 
146 from ynab_agent.ynab.client import YnabClient
147 
148 client = YnabClient.from_env()
149 spends = await asyncio.to_thread(client.category_spends)
150 rules = await _load_payee_rules(snapshot.payee)
151 counters = await _load_auto_action_counters(now)
152 return _candidates_from_spends(spends), rules, counters
153 
154 
155@activity.defn
156async def enrich(snapshot: YnabSnapshot) -> EnrichmentOutcome:
157 """Assemble the proposal and route via the gate (the agentic middle).
158 
159 The model stack is imported lazily, here in the activity body, so it never
160 enters the workflow sandbox. The gate decides autonomy from the rules; the
161 agent runs only to produce the proposal a human is asked to confirm.
162 """
163 from datetime import UTC, datetime
164 
165 from ynab_agent.agentic.enrich import decide_enrichment
166 
167 now = datetime.now(UTC)
168 candidates, rules, counters = await _load_enrichment_inputs(snapshot, now)
169 return await decide_enrichment(
170 snapshot, candidates, rules, counters, now=now
171 )
172 
173 
174@activity.defn
175async def commit_to_ynab(ynab_id: str, decision: Decision) -> None:
176 """Commit a decision to YNAB (the deterministic write).
177 
178 Lazy YNAB client (httpx stays out of the sandbox); the blocking write runs
179 off the event loop.
180 """
181 import asyncio
182 
183 from ynab_agent.ynab.client import YnabClient
184 
185 client = YnabClient.from_env()
186 await asyncio.to_thread(client.commit, ynab_id, decision)
187 
188 
189@activity.defn
190async def read_back(ynab_id: str) -> TargetState | None:
191 """Read the post-write end-state for verification, or ``None`` if unread."""
192 import asyncio
193 
194 from ynab_agent.ynab.client import YnabClient
195 
196 client = YnabClient.from_env()
197 return await asyncio.to_thread(client.read_back, ynab_id)
198 
199 
200def _txn_label(ynab_id: str) -> str:
201 """The per-transaction idempotency label (open-thread dedup; SPEC ยง5)."""
202 return f"yatxn-{ynab_id}"
203 
204 
205def _seq_label(ynab_id: str, action_seq: int) -> str:
206 """The per-action idempotency label (send dedup; SPEC ยง3)."""
207 return f"yaseq-{ynab_id}-{action_seq}"
208 
209 
210def _subject(snapshot: YnabSnapshot, category: str | None) -> str:
211 """The thread's subject: payee + amount, and the suggested category.
212 
213 No ``[YNAB]`` prefix โ€” the sender address is a known contact. Naming the
214 proposed category lets the owner act from the subject line alone.
215 """
216 base = f"{snapshot.payee} โ€” {snapshot.amount}"
217 return f"{base} ยท {category}?" if category else base
218 
219 
220def _date_display(day: datetime.date) -> str:
221 """A short, friendly transaction date, e.g. ``May 29`` (no leading zero)."""
222 return f"{day.strftime('%b')} {day.day}"
223 
224 
225def _allocation_display(
226 allocation: ProposedCategory | ProposedSplit, names: dict[str, str]
227) -> str:
228 """A human display of the proposed allocation, by category name."""
229 if isinstance(allocation, ProposedCategory):
230 return names.get(str(allocation.category), str(allocation.category))
231 return " + ".join(
232 names.get(str(line.category), str(line.category))
233 for line in allocation.lines
234 )
235 
236 
237async def _read_for_compose(
238 ynab_id: str,
239) -> tuple[YnabSnapshot, dict[str, str]]:
240 """Re-read the YNAB snapshot and a category-idโ†’name map for composing.
241 
242 Both come from YNAB (the source of truth) at send time โ€” there is no stored
243 copy of the facts or the category names (SPEC ยง0.5, store-free).
244 """
245 import asyncio
246 
247 from ynab_agent.ynab.client import YnabClient
248 
249 client = YnabClient.from_env()
250 snapshot = await asyncio.to_thread(client.snapshot, ynab_id)
251 if snapshot is None:
252 msg = f"transaction {ynab_id} not found in YNAB at compose time"
253 raise RuntimeError(msg)
254 spends = await asyncio.to_thread(client.category_spends)
255 names = {str(spend.category): spend.name for spend in spends}
256 return snapshot, names
257 
258 
259def _render_message(
260 snapshot: YnabSnapshot,
261 proposal: Proposal | None,
262 purpose: MessagePurpose,
263 names: dict[str, str],
264) -> str:
265 """Lay out one message body for the thread (deterministic; SPEC ยง5).
266 
267 The proposal's category + alternatives (model-chosen upstream) are resolved
268 to names here and handed to the template โ€” no model call at send time.
269 """
270 from ynab_agent.agentic.compose import ComposeRequest, render_body
271 
272 proposed = (
273 _allocation_display(proposal.allocation, names)
274 if proposal is not None
275 else None
276 )
277 alternatives = (
278 tuple(names.get(str(alt), str(alt)) for alt in proposal.alternatives)
279 if proposal is not None
280 else ()
281 )
282 request = ComposeRequest(
283 purpose=purpose.value,
284 payee=snapshot.payee,
285 amount_display=str(snapshot.amount),
286 txn_date=_date_display(snapshot.txn_date),
287 memo=snapshot.memo,
288 proposed_category=proposed,
289 alternatives=alternatives,
290 rationale=proposal.rationale if proposal is not None else None,
291 )
292 return render_body(request)
293 
294 
295@activity.defn
296async def open_thread(ynab_id: str, proposal: Proposal | None) -> str:
297 """Open the AgentMail thread by sending the proposal; returns its id.
298 
299 A thread starts on its first send (AgentMail has no empty-thread create), so
300 this composes + sends the proposal as the opening email. ``proposal`` is the
301 current best guess (carried from workflow state); the txn facts + category
302 names are re-read from YNAB (the source of truth) at compose time. The open
303 is idempotent on the per-transaction label, so a retry re-finds the thread
304 rather than sending a duplicate.
305 """
306 import asyncio
307 
308 from ynab_agent.mail.client import MailClient
309 from ynab_agent.settings import Settings
310 
311 settings = Settings()
312 snapshot, names = await _read_for_compose(ynab_id)
313 body = _render_message(snapshot, proposal, MessagePurpose.PROPOSAL, names)
314 proposed = (
315 _allocation_display(proposal.allocation, names)
316 if proposal is not None
317 else None
318 )
319 mail = MailClient.from_env()
320 return await asyncio.to_thread(
321 mail.open_thread,
322 inbox_id=settings.inbox,
323 to=list(settings.owners),
324 subject=_subject(snapshot, proposed),
325 body=body,
326 txn_label=_txn_label(ynab_id),
327 )
328 
329 
330@activity.defn
331async def send_thread_message(
332 ynab_id: str,
333 thread_id: str | None,
334 purpose: MessagePurpose,
335 action_seq: int,
336 proposal: Proposal | None,
337) -> None:
338 """Send a follow-up message on the transaction's thread.
339 
340 ``action_seq`` is the per-transaction idempotency key: the send dedups on it
341 so a retry never double-sends (SPEC ยง3). ``proposal`` is the current best
342 guess where the purpose needs it (re-proposal); other purposes derive their
343 content from a re-read of the YNAB snapshot.
344 """
345 import asyncio
346 
347 from ynab_agent.mail.client import MailClient
348 from ynab_agent.settings import Settings
349 
350 if thread_id is None:
351 msg = f"cannot send {purpose.value} for {ynab_id}: no thread open yet"
352 raise RuntimeError(msg)
353 settings = Settings()
354 snapshot, names = await _read_for_compose(ynab_id)
355 body = _render_message(snapshot, proposal, purpose, names)
356 mail = MailClient.from_env()
357 await asyncio.to_thread(
358 mail.send_on_thread,
359 inbox_id=settings.inbox,
360 thread_id=thread_id,
361 body=body,
362 seq_label=_seq_label(ynab_id, action_seq),
363 )
364 
365 
366def _proposed_category_id(proposal: Proposal | None) -> CategoryId | None:
367 """The single proposed category id, or None (a split is not approvable)."""
368 if proposal is None:
369 return None
370 allocation = proposal.allocation
371 if isinstance(allocation, ProposedCategory):
372 return allocation.category
373 return None
374 
375 
376@activity.defn
377async def interpret_inbound(
378 signal: InboundSignal,
379 snapshot: YnabSnapshot,
380 proposal: Proposal | None,
381) -> ReplyOutcome:
382 """Interpret a human reply into an answer or a question (SPEC ยง3, ยง5).
383 
384 Free-form: the model reads whether the reply approves the proposal, names a
385 different category, or asks a question. The spine โ€” not the model โ€” stamps
386 the human + time into the resulting Decision. A non-reply inbound, or a
387 missing/split proposal, can't be answered directly, so ask rather than guess
388 a write. Names + candidates are re-read from YNAB at interpret time.
389 """
390 import asyncio
391 from datetime import UTC, datetime
392 
393 from ynab_agent.agentic.interpret import (
394 InterpretRequest,
395 interpret,
396 to_reply_outcome,
397 )
398 from ynab_agent.domain.signals import ReplySignal
399 from ynab_agent.workflow.types import ClarifyOutcome
400 from ynab_agent.ynab.client import YnabClient
401 
402 proposed_id = _proposed_category_id(proposal)
403 if not isinstance(signal, ReplySignal) or proposed_id is None:
404 return ClarifyOutcome(
405 question="Could you say which category this should be?"
406 )
407 
408 client = YnabClient.from_env()
409 spends = await asyncio.to_thread(client.category_spends)
410 names = {str(spend.category): spend.name for spend in spends}
411 request = InterpretRequest(
412 reply_text=signal.text,
413 payee=snapshot.payee,
414 amount_display=str(snapshot.amount),
415 proposed_category_name=names.get(str(proposed_id), str(proposed_id)),
416 candidates=_candidates_from_spends(spends),
417 )
418 interpretation = await interpret(request)
419 return to_reply_outcome(
420 interpretation,
421 proposed_category=proposed_id,
422 decided_at=datetime.now(UTC),
423 )
424 
425 
426def _target_summary(target: TargetState | None, names: dict[str, str]) -> str:
427 """A short human description of an end-state, for a divergence note."""
428 if target is None:
429 return "(could not read)"
430 allocation = target.allocation
431 if isinstance(allocation, ResolvedCategory):
432 label = names.get(str(allocation.category), str(allocation.category))
433 else:
434 label = "a split"
435 return f"{label} โ€” {target.memo}" if target.memo else label
436 
437 
438@activity.defn
439async def converge(
440 snapshot: YnabSnapshot, instruction: InboundSignal
441) -> ConvergeOutcome:
442 """Converge a REVISING run to its target and verify it (SPEC ยง3).
443 
444 The agent reads the revision instruction into a target (retarget / memo /
445 no-change); the spine commits, then reads back and classifies the result. A
446 reconciled or closed-month transaction, a non-reply instruction, or anything
447 the model under-specifies routes to a human rather than a silent edit.
448 """
449 import asyncio
450 from datetime import UTC, datetime
451 
452 from ynab_agent.agentic.converge import (
453 RevisionRequest,
454 interpret_revision,
455 to_revision_plan,
456 )
457 from ynab_agent.domain.enums import DecidedBy
458 from ynab_agent.domain.events import (
459 CouldNotConfirm,
460 Diverged,
461 NeedsHuman,
462 NoChange,
463 Reapplied,
464 VerifyOutcome,
465 )
466 from ynab_agent.domain.signals import ReplySignal
467 from ynab_agent.policy.converge import (
468 classify_verify,
469 reconciliation_blocks,
470 target_of,
471 )
472 from ynab_agent.ynab.client import YnabClient
473 
474 if reconciliation_blocks(snapshot):
475 return NeedsHuman(
476 reason="reconciled or closed-month โ€” propose, don't silently edit"
477 )
478 if not isinstance(instruction, ReplySignal):
479 return NeedsHuman(reason="non-reply revision instruction unsupported")
480 
481 client = YnabClient.from_env()
482 spends = await asyncio.to_thread(client.category_spends)
483 names = {str(spend.category): spend.name for spend in spends}
484 current_name = (
485 names.get(str(snapshot.category_id), str(snapshot.category_id))
486 if snapshot.category_id is not None
487 else "(uncategorized)"
488 )
489 target = await interpret_revision(
490 RevisionRequest(
491 instruction=instruction.text,
492 current_category_name=current_name,
493 candidates=_candidates_from_spends(spends),
494 current_memo=snapshot.memo,
495 )
496 )
497 plan = to_revision_plan(target)
498 if not plan.changes:
499 return NoChange()
500 
501 category = (
502 CategoryId(plan.category_id)
503 if plan.category_id is not None
504 else snapshot.category_id
505 )
506 if category is None:
507 return NeedsHuman(reason="revision did not resolve a category")
508 decision = Decision(
509 allocation=ResolvedCategory(category=category),
510 memo=plan.memo if plan.memo is not None else snapshot.memo,
511 approved=True,
512 decided_by=DecidedBy.HUMAN,
513 decided_at=datetime.now(UTC),
514 )
515 await asyncio.to_thread(client.commit, snapshot.ynab_id, decision)
516 read = await asyncio.to_thread(client.read_back, snapshot.ynab_id)
517 verdict = classify_verify(read, target_of(decision))
518 if verdict is VerifyOutcome.MATCH:
519 return Reapplied(decision=decision)
520 if verdict is VerifyOutcome.COULD_NOT_CONFIRM:
521 return CouldNotConfirm()
522 return Diverged(
523 ynab_summary=_target_summary(read, names),
524 requested_summary=_target_summary(target_of(decision), names),
525 )
526 
527 
528@activity.defn
529async def feed_rule_learning(feed: FeedRuleLearning) -> None:
530 """Persist a confirm/correct event into the durable rule registry (W5).
531 
532 Signal-with-start on the singleton :class:`RuleRegistryWorkflow`: the first
533 learning event creates it, every later one just delivers the signal, and the
534 workflow folds the event into the rule table the autonomy gate reads (SPEC
535 ยง9, ยง14). The conflict policy reuses the running singleton rather than
536 starting a second registry.
537 """
538 from temporalio.common import WorkflowIDConflictPolicy
539 
540 from ynab_agent.workflow.registry_types import (
541 REGISTRY_WORKFLOW_ID,
542 RegistryParams,
543 )
544 from ynab_agent.workflow.temporal_client import client, task_queue
545 
546 temporal = await client()
547 await temporal.start_workflow(
548 "RuleRegistryWorkflow",
549 RegistryParams(),
550 id=REGISTRY_WORKFLOW_ID,
551 task_queue=task_queue(),
552 id_conflict_policy=WorkflowIDConflictPolicy.USE_EXISTING,
553 start_signal="record",
554 start_signal_args=[feed],
555 )
556 
557 
558@activity.defn
559async def record_auto_action(ynab_id: str) -> None:
560 """Record a landed auto-action in the durable breaker ledger (SPEC ยง0.6).
561 
562 Signal-with-start on the singleton ledger: the first auto-action creates it,
563 every later one just delivers the signal, and the ledger folds it into the
564 counts the hard floor reads (mirrors ``feed_rule_learning`` talking to the
565 registry). Best-effort โ€” a ledger hiccup must never block or fail the
566 categorization it bounds, so any error is logged and swallowed. The breaker
567 tolerates an occasional missed count; the per-txn ceiling still binds, and
568 the ``ynab_id`` key dedups a retry.
569 """
570 from temporalio.common import WorkflowIDConflictPolicy
571 
572 from ynab_agent.workflow.auto_action_types import (
573 AUTO_ACTION_LEDGER_WORKFLOW_ID,
574 LedgerParams,
575 )
576 from ynab_agent.workflow.temporal_client import client, task_queue
577 
578 try:
579 temporal = await client()
580 await temporal.start_workflow(
581 "AutoActionLedgerWorkflow",
582 LedgerParams(),
583 id=AUTO_ACTION_LEDGER_WORKFLOW_ID,
584 task_queue=task_queue(),
585 id_conflict_policy=WorkflowIDConflictPolicy.USE_EXISTING,
586 start_signal="record",
587 start_signal_args=[ynab_id],
588 )
589 except Exception:
590 activity.logger.warning(
591 "auto-action ledger record failed (best-effort): %s", ynab_id
592 )
โ‹ฏ 15 lines hidden (lines 593โ€“607)
593 
594 
595@activity.defn
596async def close_thread(thread_id: str) -> None:
597 """Label and close the AgentMail thread on archive."""
598 import asyncio
599 
600 from ynab_agent.mail.client import MailClient
601 from ynab_agent.settings import Settings
602 
603 settings = Settings()
604 mail = MailClient.from_env()
605 await asyncio.to_thread(
606 mail.close, inbox_id=settings.inbox, thread_id=thread_id
607 )

The dispatch also swallows an ActivityError โ€” a timeout the activity body can't catch.

src/ynab_agent/workflow/txn_workflow.py ยท 567 lines
src/ynab_agent/workflow/txn_workflow.py567 lines ยท Python
โ‹ฏ 302 lines hidden (lines 1โ€“302)
1"""W2 ยท the Transaction Lifecycle workflow (SPEC ยง3, ยง0.5).
2 
3One durable workflow per ``ynab_id``. It is a thin *driver* around the pure
4:func:`~ynab_agent.domain.state_machine.advance` core: each step produces one
5event โ€” from an activity (fetch / enrich / commit+verify / converge) or from a
6signal or an absolute-deadline timer โ€” feeds it to ``advance``, then dispatches
7the emitted effects back out through activities. All nondeterminism lives in
8activities; the workflow uses only pure state and Temporal APIs
9(``workflow.now`` for time), so it replays deterministically. Long-lived
10transactions survive via ``continue-as-new`` from a resting state, carrying
11their state forward.
12 
13Deferred (a cohesive subsystem for its own step, SPEC ยง3, ยง9): the externalized,
14append-only **audit log**. ``TxnCore.audit_log_ref`` and the
15``_action_seq`` outbound-dedup key are plumbed here, but no audit entries are
16written yet โ€” Temporal's own event history provides replay-safety in the
17meantime, and ``_action_seq`` is the idempotency key the real send activity will
18use so a retry never double-emails.
19"""
20 
21from __future__ import annotations
22 
23import datetime
24from collections import deque
25from datetime import timedelta
26from typing import TYPE_CHECKING
27 
28from temporalio import workflow
29from temporalio.common import SearchAttributeKey
30from temporalio.exceptions import ActivityError
31 
32if TYPE_CHECKING:
33 from collections.abc import Callable
34 
35 from ynab_agent.domain.proposal import Proposal
36 
37# The reply-routing reverse index: each workflow stamps its AgentMail thread_id
38# here on open_thread, so the dispatcher resolves an inbound reply's thread back
39# to this workflow with a Temporal visibility query (no separate store, ยง5a).
40# Registered on the namespace by manage/search-attributes.yaml.
41_TXN_THREAD_ID = SearchAttributeKey.for_keyword("TxnThreadId")
42 
43with workflow.unsafe.imports_passed_through():
44 from ynab_agent.domain.config import DEFAULT_POLICY
45 from ynab_agent.domain.effects import (
46 CancelTimer,
47 CloseThread,
48 CommitToYnab,
49 Effect,
50 FeedRuleLearning,
51 OpenThread,
52 RecordAutoAction,
53 ReplayBuffered,
54 SendThreadMessage,
55 SetTimer,
56 TimerKind,
57 )
58 from ynab_agent.domain.enums import DecidedBy
59 from ynab_agent.domain.events import (
60 AnswerReceived,
61 ArchiveWindowReached,
62 ClarifyRequested,
63 Converged,
64 Enriched,
65 HoldDeadlineReached,
66 HoldResolved,
67 InboundReceived,
68 LifecycleEvent,
69 OverrideDetected,
70 PatienceExpired,
71 SnapshotMaterialized,
72 SnapshotUnavailable,
73 WriteVerified,
74 )
75 from ynab_agent.domain.ids import ThreadId, YnabTransactionId
76 from ynab_agent.domain.proposal import Decision
77 from ynab_agent.domain.signals import InboundSignal
78 from ynab_agent.domain.state_machine import advance
79 from ynab_agent.domain.transaction import (
80 Applied,
81 Archived,
82 AutoApplied,
83 AwaitingHuman,
84 Discovered,
85 Enriching,
86 HoldAmazon,
87 Lapsed,
88 Open,
89 Revising,
90 Transaction,
91 YnabSnapshot,
92 born,
93 )
94 from ynab_agent.policy.converge import classify_verify, target_of
95 from ynab_agent.workflow import activities, alert_activities
96 from ynab_agent.workflow.alerting import build_failure_alert
97 from ynab_agent.workflow.constants import (
98 ACTIVITY_RETRY,
99 ACTIVITY_TIMEOUT,
100 ALERT_BUDGET,
101 ALERT_RETRY,
102 ALERT_TIMEOUT,
103 )
104 from ynab_agent.workflow.types import AnswerOutcome, TransactionParams
105 
106# History-length ceiling before a resting workflow continues-as-new. High enough
107# that ordinary flows never trip it; long-lived (30-45 day) transactions do.
108_CONTINUE_AS_NEW_AFTER = 4_000
109 
110# Resting states the workflow may sit in for days while waiting on signals or
111# timers; continue-as-new fires only from one of these. DISCOVERED counts: a
112# transaction born from a signal can wait here for a slow YNAB import (SPEC ยง3).
113_RESTING = (Discovered, AwaitingHuman, Open, Lapsed, HoldAmazon)
114 
115 
116def _is_amazon(payee: str) -> bool:
117 return "amazon" in payee.lower()
118 
119 
120def _hold_for_amazon(snapshot: YnabSnapshot) -> bool:
121 """Whether to hold for Amazon item detail: Amazon-ish and no memo yet."""
122 return _is_amazon(snapshot.payee) and not snapshot.has_memo
123 
124 
125@workflow.defn
126class TransactionWorkflow:
127 """The durable per-transaction lifecycle."""
128 
129 def __init__(self) -> None:
130 """Initialize empty state; ``run`` populates it from the params."""
131 self._txn: Transaction
132 self._ynab_id: str = ""
133 self._thread_id: str | None = None
134 self._deadlines: dict[TimerKind, datetime.datetime] = {}
135 self._inbound: deque[InboundSignal] = deque()
136 self._snapshot_ready: YnabSnapshot | None = None
137 # Monotonic per-transaction counter: the outbound-send idempotency key
138 # so a replay/retry never double-emails (SPEC ยง3 outbound dedup).
139 self._action_seq: int = 0
140 
141 # โ”€โ”€ Signals (the external world pushes in) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
142 @workflow.signal
143 def submit_inbound(self, signal: InboundSignal) -> None:
144 """A reply or matched receipt arrived (W3/W4)."""
145 self._inbound.append(signal)
146 
147 @workflow.signal
148 def notify_snapshot(self, snapshot: YnabSnapshot) -> None:
149 """W1 materialized (or backfilled the memo of) the YNAB snapshot."""
150 self._snapshot_ready = snapshot
151 
152 @workflow.query
153 def state(self) -> str:
154 """The current lifecycle state (for observability)."""
155 return self._txn.state.value
156 
157 # โ”€โ”€ The durable loop โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
158 @workflow.run
159 async def run(self, params: TransactionParams) -> None:
160 """Drive the lifecycle until the transaction is archived (SPEC ยง3)."""
161 self._ynab_id = params.ynab_id
162 self._thread_id = params.thread_id
163 self._deadlines = dict(params.resume_deadlines)
164 self._inbound.extend(params.resume_inbound)
165 self._action_seq = params.resume_action_seq
166 self._txn = params.resume_txn or born(
167 YnabTransactionId(params.ynab_id), params.thread_id
168 )
169 self._sync_thread_id()
170 
171 try:
172 while not isinstance(self._txn, Archived):
173 await self._step()
174 if (
175 isinstance(self._txn, _RESTING)
176 and not self._inbound # drain pending signals (SPEC ยง0.5)
177 and workflow.info().get_current_history_length()
178 > _CONTINUE_AS_NEW_AFTER
179 ):
180 # continue_as_new raises to restart; nothing runs after it.
181 # (ContinueAsNewError is not an ActivityError, so it escapes
182 # the failure hook below untouched.)
183 workflow.continue_as_new(self._resume_params())
184 except ActivityError as exc:
185 # A terminal activity failure: a non-retryable bug (the constants
186 # denylist) or the schedule_to_close budget elapsing. Page the owner
187 # once โ€” deduped, best-effort โ€” then re-raise so the transaction
188 # still fails and stays visible in Temporal (SPEC ยง13).
189 await workflow.execute_activity(
190 alert_activities.alert_failure,
191 build_failure_alert(
192 key=self._ynab_id,
193 context=self._alert_context(),
194 exc=exc,
195 ),
196 start_to_close_timeout=ALERT_TIMEOUT,
197 schedule_to_close_timeout=ALERT_BUDGET,
198 retry_policy=ALERT_RETRY,
199 )
200 raise
201 
202 def _alert_context(self) -> str:
203 """A human locator for a failure alert: payee + txn id when known."""
204 st = self._txn
205 if isinstance(st, Discovered):
206 return f"txn {self._ynab_id}"
207 return f"{st.core.snapshot.payee} (txn {self._ynab_id})"
208 
209 def _resume_params(self) -> TransactionParams:
210 return TransactionParams(
211 ynab_id=YnabTransactionId(self._ynab_id),
212 thread_id=ThreadId(self._thread_id)
213 if self._thread_id is not None
214 else None,
215 resume_txn=self._txn,
216 resume_deadlines=dict(self._deadlines),
217 resume_inbound=tuple(self._inbound),
218 resume_action_seq=self._action_seq,
219 )
220 
221 async def _step(self) -> None:
222 st = self._txn
223 if isinstance(st, Discovered):
224 await self._on_discovered()
225 elif isinstance(st, Enriching):
226 await self._on_enriching(st)
227 elif isinstance(st, HoldAmazon):
228 await self._on_hold(st)
229 elif isinstance(st, AwaitingHuman):
230 await self._on_awaiting(st)
231 elif isinstance(st, Open):
232 await self._on_open(st)
233 elif isinstance(st, Lapsed):
234 await self._on_lapsed()
235 elif isinstance(st, Revising):
236 await self._on_revising(st)
237 elif isinstance(st, (AutoApplied, Applied, Archived)):
238 # Transient (handled via the commitโ†’verify follow-up) or terminal.
239 return
240 
241 # โ”€โ”€ apply + effect dispatch โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
242 async def _dispatch(self, event: LifecycleEvent) -> None:
243 transition = advance(
244 self._txn, event, now=workflow.now(), policy=DEFAULT_POLICY
245 )
246 self._txn = transition.next
247 # Keep the thread-id mirror in step with the state machine, which can
248 # adopt a reply's thread on its own (e.g. a reply in DISCOVERED).
249 self._sync_thread_id()
250 for effect in transition.effects:
251 followup = await self._execute(effect)
252 if followup is not None:
253 await self._dispatch(followup)
254 
255 async def _execute(self, effect: Effect) -> LifecycleEvent | None:
256 if isinstance(effect, CommitToYnab):
257 await workflow.execute_activity(
258 activities.commit_to_ynab,
259 args=[self._ynab_id, effect.decision],
260 start_to_close_timeout=ACTIVITY_TIMEOUT,
261 retry_policy=ACTIVITY_RETRY,
262 )
263 read = await workflow.execute_activity(
264 activities.read_back,
265 self._ynab_id,
266 start_to_close_timeout=ACTIVITY_TIMEOUT,
267 retry_policy=ACTIVITY_RETRY,
268 )
269 return WriteVerified(
270 outcome=classify_verify(read, target_of(effect.decision))
271 )
272 if isinstance(effect, OpenThread):
273 tid = await workflow.execute_activity(
274 activities.open_thread,
275 args=[self._ynab_id, self._proposal()],
276 start_to_close_timeout=ACTIVITY_TIMEOUT,
277 retry_policy=ACTIVITY_RETRY,
278 )
279 self._set_thread_id(tid)
280 # Index this workflow by its thread for reply routing (ยง5a).
281 workflow.upsert_search_attributes([_TXN_THREAD_ID.value_set(tid)])
282 elif isinstance(effect, SendThreadMessage):
283 self._action_seq += 1
284 await workflow.execute_activity(
285 activities.send_thread_message,
286 args=[
287 self._ynab_id,
288 self._thread_id,
289 effect.purpose,
290 self._action_seq,
291 self._proposal(),
292 ],
293 start_to_close_timeout=ACTIVITY_TIMEOUT,
294 retry_policy=ACTIVITY_RETRY,
295 )
296 elif isinstance(effect, FeedRuleLearning):
297 await workflow.execute_activity(
298 activities.feed_rule_learning,
299 effect,
300 start_to_close_timeout=ACTIVITY_TIMEOUT,
301 retry_policy=ACTIVITY_RETRY,
302 )
303 elif isinstance(effect, RecordAutoAction):
304 # Bound the auto-action in the durable breaker ledger (SPEC ยง0.6).
305 # Best-effort: a ledger hiccup โ€” even an activity timeout, which the
306 # body's own try/except cannot catch โ€” must never fail the
307 # categorization it counts, so swallow it rather than trip the ยง13
308 # failure hook. The per-txn ceiling still binds; the next tick
309 # re-counts from the durable ledger.
310 try:
311 await workflow.execute_activity(
312 activities.record_auto_action,
313 effect.ynab_id,
314 start_to_close_timeout=ACTIVITY_TIMEOUT,
315 retry_policy=ACTIVITY_RETRY,
316 )
317 except ActivityError:
318 workflow.logger.warning(
319 "auto-action ledger record failed; continuing"
320 )
321 elif isinstance(effect, CloseThread):
322 if self._thread_id is not None:
โ‹ฏ 245 lines hidden (lines 323โ€“567)
323 await workflow.execute_activity(
324 activities.close_thread,
325 self._thread_id,
326 start_to_close_timeout=ACTIVITY_TIMEOUT,
327 retry_policy=ACTIVITY_RETRY,
328 )
329 elif isinstance(effect, SetTimer):
330 self._deadlines[effect.timer] = effect.deadline
331 elif isinstance(effect, CancelTimer):
332 self._deadlines.pop(effect.timer, None)
333 elif isinstance(effect, ReplayBuffered):
334 self._inbound.extendleft(reversed(effect.signals))
335 return None
336 
337 def _set_thread_id(self, tid: str) -> None:
338 self._thread_id = tid
339 st = self._txn
340 if not isinstance(st, Discovered):
341 new_core = st.core.model_copy(update={"thread_id": ThreadId(tid)})
342 self._txn = st.model_copy(update={"core": new_core})
343 
344 def _sync_thread_id(self) -> None:
345 """Mirror the current transaction's thread id (SM may adopt it)."""
346 st = self._txn
347 tid = st.thread_id if isinstance(st, Discovered) else st.core.thread_id
348 if tid is not None:
349 self._thread_id = str(tid)
350 
351 def _proposal(self) -> Proposal | None:
352 """The current best-guess proposal, for states that carry one.
353 
354 Passed to the mail activities so the proposal email can name the guess +
355 alternatives; ``None`` for purposes whose content derives from YNAB.
356 """
357 st = self._txn
358 if isinstance(st, (Enriching, AwaitingHuman, Lapsed)):
359 return st.proposal
360 return None
361 
362 # โ”€โ”€ per-state steps โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
363 async def _on_discovered(self) -> None:
364 snapshot = await workflow.execute_activity(
365 activities.fetch_snapshot,
366 self._ynab_id,
367 start_to_close_timeout=ACTIVITY_TIMEOUT,
368 retry_policy=ACTIVITY_RETRY,
369 )
370 if snapshot is not None:
371 await self._dispatch(
372 SnapshotMaterialized(
373 snapshot=snapshot,
374 hold_for_amazon=_hold_for_amazon(snapshot),
375 )
376 )
377 return
378 # Signal beat the poll: wait for materialization or buffer an inbound.
379 await self._dispatch(SnapshotUnavailable())
380 await workflow.wait_condition(
381 lambda: self._snapshot_ready is not None or len(self._inbound) > 0
382 )
383 if self._snapshot_ready is not None:
384 snap = self._snapshot_ready
385 self._snapshot_ready = None
386 await self._dispatch(
387 SnapshotMaterialized(
388 snapshot=snap, hold_for_amazon=_hold_for_amazon(snap)
389 )
390 )
391 else:
392 await self._dispatch(
393 InboundReceived(signal=self._inbound.popleft())
394 )
395 
396 async def _on_enriching(self, st: Enriching) -> None:
397 outcome = await workflow.execute_activity(
398 activities.enrich,
399 st.core.snapshot,
400 start_to_close_timeout=ACTIVITY_TIMEOUT,
401 retry_policy=ACTIVITY_RETRY,
402 )
403 await self._dispatch(Enriched(outcome=outcome))
404 
405 async def _on_hold(self, st: HoldAmazon) -> None:
406 ready = await self._wait_until(
407 st.amazon_deadline,
408 lambda: self._snapshot_ready is not None or len(self._inbound) > 0,
409 )
410 if not ready:
411 await self._dispatch(HoldDeadlineReached())
412 elif self._snapshot_ready is not None:
413 snap = self._snapshot_ready
414 self._snapshot_ready = None
415 await self._dispatch(HoldResolved(snapshot=snap))
416 else:
417 await self._dispatch(
418 InboundReceived(signal=self._inbound.popleft())
419 )
420 
421 async def _on_awaiting(self, st: AwaitingHuman) -> None:
422 got_inbound = await self._wait_until(
423 st.patience_deadline, self._has_inbound
424 )
425 if not got_inbound:
426 before = type(self._txn)
427 await self._dispatch(PatienceExpired())
428 if type(self._txn) is not before:
429 return # lapsed (or otherwise transitioned)
430 # Flagged (verify-failure) entry: PatienceExpired is ignored and
431 # does not lapse (SPEC ยง3). Drop the passed timer and wait for an
432 # inbound instead of re-spinning the expired deadline.
433 self._deadlines.pop(TimerKind.PATIENCE, None)
434 await workflow.wait_condition(self._has_inbound)
435 await self._interpret_inbound(st.core.snapshot)
436 
437 async def _interpret_inbound(self, snapshot: YnabSnapshot) -> None:
438 signal = self._inbound.popleft()
439 interpretation = await workflow.execute_activity(
440 activities.interpret_inbound,
441 args=[signal, snapshot, self._proposal()],
442 start_to_close_timeout=ACTIVITY_TIMEOUT,
443 retry_policy=ACTIVITY_RETRY,
444 )
445 if isinstance(interpretation, AnswerOutcome):
446 await self._dispatch(
447 AnswerReceived(decision=interpretation.decision)
448 )
449 else:
450 await self._dispatch(
451 ClarifyRequested(question=interpretation.question)
452 )
453 
454 async def _on_open(self, st: Open) -> None:
455 deadline = self._deadlines.get(TimerKind.ARCHIVE)
456 got_inbound = (
457 await self._wait_until(deadline, self._has_inbound)
458 if deadline is not None
459 else await self._wait_forever(self._has_inbound)
460 )
461 if got_inbound:
462 await self._dispatch(
463 InboundReceived(signal=self._inbound.popleft())
464 )
465 return
466 # The archive window elapsed. Before closing the book, re-read YNAB to
467 # catch a silent manual recategorization โ€” an out-of-band correction
468 # that must demote the driving rule (SPEC ยง14.2).
469 event = await self._archive_or_override(st)
470 before = type(self._txn)
471 await self._dispatch(event)
472 if type(self._txn) is before:
473 # Archive blocked (not reconciled) and no override: drop the stale
474 # timer and wait for an inbound rather than busy-looping (SPEC ยง3).
475 self._deadlines.pop(TimerKind.ARCHIVE, None)
476 await workflow.wait_condition(self._has_inbound)
477 await self._dispatch(
478 InboundReceived(signal=self._inbound.popleft())
479 )
480 
481 async def _archive_or_override(self, st: Open) -> LifecycleEvent:
482 """At archive time, detect a manual YNAB edit (SPEC ยง14.2).
483 
484 Re-reads the current end-state and, if its category no longer matches
485 the agent's applied decision, returns an ``OverrideDetected`` carrying
486 the human's choice (the spine then demotes the rule); otherwise the
487 ordinary ``ArchiveWindowReached``. A memo-only change is not an override
488 โ€” only the allocation is compared.
489 """
490 read = await workflow.execute_activity(
491 activities.read_back,
492 self._ynab_id,
493 start_to_close_timeout=ACTIVITY_TIMEOUT,
494 retry_policy=ACTIVITY_RETRY,
495 )
496 if read is not None and read.allocation != st.decision.allocation:
497 human = Decision(
498 allocation=read.allocation,
499 memo=read.memo,
500 approved=read.approved,
501 decided_by=DecidedBy.HUMAN,
502 decided_at=workflow.now(),
503 )
504 return OverrideDetected(decision=human)
505 return ArchiveWindowReached()
506 
507 async def _on_lapsed(self) -> None:
508 await self._wait_then_revise_or_archive()
509 
510 def _has_inbound(self) -> bool:
511 return len(self._inbound) > 0
512 
513 async def _wait_then_revise_or_archive(self) -> None:
514 deadline = self._deadlines.get(TimerKind.ARCHIVE)
515 got_inbound = (
516 await self._wait_until(deadline, self._has_inbound)
517 if deadline is not None
518 else await self._wait_forever(self._has_inbound)
519 )
520 if got_inbound:
521 await self._dispatch(
522 InboundReceived(signal=self._inbound.popleft())
523 )
524 return
525 # The archive window elapsed. Attempt to archive; if it is blocked (not
526 # reconciled / not categorized) the state is unchanged, so drop the
527 # now-stale timer and wait for an inbound rather than busy-looping on
528 # the already-passed deadline.
529 before = type(self._txn)
530 await self._dispatch(ArchiveWindowReached())
531 if type(self._txn) is before:
532 self._deadlines.pop(TimerKind.ARCHIVE, None)
533 await workflow.wait_condition(self._has_inbound)
534 await self._dispatch(
535 InboundReceived(signal=self._inbound.popleft())
536 )
537 
538 async def _wait_forever(self, predicate: Callable[[], bool]) -> bool:
539 await workflow.wait_condition(predicate)
540 return True
541 
542 async def _on_revising(self, st: Revising) -> None:
543 outcome = await workflow.execute_activity(
544 activities.converge,
545 args=[st.core.snapshot, st.instruction],
546 start_to_close_timeout=ACTIVITY_TIMEOUT,
547 retry_policy=ACTIVITY_RETRY,
548 )
549 await self._dispatch(Converged(outcome=outcome))
550 
551 async def _wait_until(
552 self,
553 deadline: datetime.datetime,
554 predicate: Callable[[], bool],
555 ) -> bool:
556 """Wait for ``predicate`` until an absolute deadline.
557 
558 Returns ``True`` if the predicate fired first, ``False`` on timeout.
559 """
560 timeout = deadline - workflow.now()
561 if timeout < timedelta(0):
562 timeout = timedelta(0)
563 try:
564 await workflow.wait_condition(predicate, timeout=timeout)
565 except TimeoutError:
566 return False
567 return True

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
tests/policy/test_auto_action_ledger.py52 lines ยท Python
โ‹ฏ 23 lines hidden (lines 1โ€“23)
1"""Tests for the auto-action circuit-breaker ledger folds (SPEC ยง0.6)."""
2 
3from __future__ import annotations
4 
5import datetime
6 
7from ynab_agent.policy.auto_action_ledger import (
8 AutoActionLedgerState,
9 counters,
10 record,
12 
13_NOW = datetime.datetime(2026, 6, 8, 12, 0, tzinfo=datetime.UTC)
14 
15 
16def _ago(**kw: float) -> datetime.datetime:
17 return _NOW - datetime.timedelta(**kw)
18 
19 
20def test_record_appends_an_entry() -> None:
21 state = record(AutoActionLedgerState(), "t1", _NOW)
22 assert [entry.ynab_id for entry in state.entries] == ["t1"]
23 
24 
25def test_record_dedups_the_same_transaction() -> None:
26 # A retried record or a re-enriched txn must count once, not twice.
27 state = record(AutoActionLedgerState(), "t1", _ago(minutes=30))
28 state = record(state, "t1", _NOW)
29 assert len(state.entries) == 1
30 assert state.entries[0].at == _NOW # the latest timestamp wins
31 
32 
33def test_record_prunes_entries_older_than_retention() -> None:
34 state = record(AutoActionLedgerState(), "old", _ago(hours=25))
35 state = record(state, "t2", _NOW)
36 assert [entry.ynab_id for entry in state.entries] == ["t2"]
37 
38 
39def test_counters_split_run_and_day_windows() -> None:
40 state = AutoActionLedgerState()
41 state = record(state, "t1", _ago(minutes=10)) # within run + day
42 state = record(state, "t2", _ago(minutes=50)) # within run + day
43 state = record(state, "t3", _ago(hours=5)) # within day only (run = 1 h)
44 result = counters(state, _NOW)
45 assert result.this_run == 2
46 assert result.today == 3
โ‹ฏ 6 lines hidden (lines 47โ€“52)
47 
48 
49def test_counters_empty_ledger_is_zero() -> None:
50 result = counters(AutoActionLedgerState(), _NOW)
51 assert result.this_run == 0
52 assert result.today == 0

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.

tests/workflow/test_auto_action_ledger_workflow.py ยท 68 lines
tests/workflow/test_auto_action_ledger_workflow.py68 lines ยท Python
โ‹ฏ 45 lines hidden (lines 1โ€“45)
1"""The durable auto-action circuit-breaker ledger on the time-skipping server.
2 
3Verifies the wiring around the pure folds (tested in ``tests/policy``): the
4``record`` signal folds an auto-action in, and the ``counters`` query reads the
5per-run / per-day counts back out. The query carries ``now`` so the window maths
6line up with the ``workflow.now()`` the signal records at.
7"""
8 
9from __future__ import annotations
10 
11import datetime
12 
13from temporalio.testing import WorkflowEnvironment
14from temporalio.worker import Worker
15 
16from ynab_agent.workflow.auto_action_ledger_workflow import (
17 AutoActionLedgerWorkflow,
19from ynab_agent.workflow.auto_action_types import (
20 AUTO_ACTION_LEDGER_WORKFLOW_ID,
21 CountersRequest,
22 LedgerParams,
24from ynab_agent.workflow.runtime import DATA_CONVERTER
25 
26_TASK_QUEUE = "auto-action-ledger-test"
27 
28 
29async def test_record_then_count_and_window() -> None:
30 async with (
31 await WorkflowEnvironment.start_time_skipping(
32 data_converter=DATA_CONVERTER
33 ) as env,
34 Worker(
35 env.client,
36 task_queue=_TASK_QUEUE,
37 workflows=[AutoActionLedgerWorkflow],
38 ),
39 ):
40 handle = await env.client.start_workflow(
41 AutoActionLedgerWorkflow.run,
42 LedgerParams(),
43 id=AUTO_ACTION_LEDGER_WORKFLOW_ID,
44 task_queue=_TASK_QUEUE,
45 )
46 now = await env.get_current_time()
47 
48 # Fresh ledger โ†’ zero counts.
49 empty = await handle.query(
50 AutoActionLedgerWorkflow.counters, CountersRequest(now=now)
51 )
52 assert (empty.this_run, empty.today) == (0, 0)
53 
54 # Two auto-actions land; the same txn signalled twice counts once.
55 await handle.signal(AutoActionLedgerWorkflow.record, "txn-1")
56 await handle.signal(AutoActionLedgerWorkflow.record, "txn-2")
57 await handle.signal(AutoActionLedgerWorkflow.record, "txn-1")
58 counts = await handle.query(
59 AutoActionLedgerWorkflow.counters, CountersRequest(now=now)
60 )
61 assert (counts.this_run, counts.today) == (2, 2)
62 
63 # Two hours on, the run window has rolled off but the day window holds.
64 later = now + datetime.timedelta(hours=2)
65 rolled = await handle.query(
66 AutoActionLedgerWorkflow.counters, CountersRequest(now=later)
67 )
68 assert (rolled.this_run, rolled.today) == (0, 2)