What changed, and why

REVISING is the riskiest path in W2: it mutates an already-approved money record. SPEC §3 rule 3 spells out how it must write — converge to target: read the current YNAB state, write only if it differs, then verify. Rule 4 adds the guard for the dangerous case: if YNAB shows a different non-empty state than you expect (a spouse edited it directly), don't clobber it — ask.

The shipped code did neither. The converge activity committed blind: it wrote the target, then read back to classify. Two real harms followed. A no-op revision still issued a PATCH — re-stamping the agent review flag, able to overwrite a richer human memo — when nothing needed to change. And a concurrent edit was overwritten: divergence could only be noticed after the clobbering write had already landed, which is exactly when it's too late.

The pure helpers rule 3 calls for — needs_write, content_hash — already existed and were unit-tested. The write path simply never called them. This change routes the converge activity through them, deciding what to do before it writes.

The decision, made pure

The whole pre-write decision is one pure function — no I/O, fully testable, sitting in the policy layer next to the helpers it composes. Given the freshly read current state, the target the instruction asks for, and prior (what the agent last applied), it returns one of four actions.

The subtle case is ALREADY_TARGET. If current already equals target we skip the write — but we must distinguish a genuine no-op (current also equals the prior: nothing changed) from a converge whose write did land and is being retried (current equals the target but differs from the prior). The first is NO_CHANGE; the second is ALREADY_TARGET, which the activity reports as re-applied. Collapsing both to NO_CHANGE would revert the workflow's decision back to the stale prior on a retry — a state/YNAB mismatch.

The enum and the decision. prior is the baseline that separates a no-op from a landed retry, and that anchors divergence.

src/ynab_agent/policy/converge.py · 156 lines
src/ynab_agent/policy/converge.py156 lines · Python
⋯ 95 lines hidden (lines 1–95)
1"""Converge-to-target reconciliation for REVISING (SPEC §3 rules 2-4).
2 
3REVISING is the riskiest path: it mutates approved money records. The spine
4converges to a single target end-state — read current YNAB state, write only if
5it differs, then verify field-by-field. These pure helpers compute the three
6decisions that procedure needs: the dedup hash, whether a write is needed, and
7how a read-after-write compares to the target.
8"""
9 
10from __future__ import annotations
11 
12import hashlib
13from enum import StrEnum
14from typing import TYPE_CHECKING
15 
16from ynab_agent.domain.allocations import ResolvedAllocation
17from ynab_agent.domain.base import Frozen
18from ynab_agent.domain.events import VerifyOutcome
19 
20if TYPE_CHECKING:
21 from ynab_agent.domain.proposal import Decision
22 from ynab_agent.domain.transaction import YnabSnapshot
23 
24 
25class TargetState(Frozen):
26 """The normalized end-state a write converges to.
27 
28 Only the fields a write actually sets — the allocation, the memo, and the
29 approved flag — so two decisions that produce the same YNAB end-state hash
30 identically (and dedup), regardless of incidental metadata.
31 """
32 
33 allocation: ResolvedAllocation
34 memo: str | None = None
35 approved: bool = True
36 
37 
38def target_of(decision: Decision) -> TargetState:
39 """Project a decision onto its YNAB end-state."""
40 return TargetState(
41 allocation=decision.allocation,
42 memo=decision.memo,
43 approved=decision.approved,
44 )
45 
46 
47def content_hash(target: TargetState) -> str:
48 """A stable hash of the end-state, for ``(ynab_id, content_hash)`` dedup.
49 
50 Deterministic across processes (Pydantic JSON with fixed field order and
51 integer milliunits), so it is safe under Temporal replay.
52 """
53 return hashlib.sha256(target.model_dump_json().encode()).hexdigest()
54 
55 
56def needs_write(current: TargetState | None, target: TargetState) -> bool:
57 """Whether YNAB must be written (SPEC §3 rule 3).
58 
59 ``False`` only when the current state already equals the target — the
60 no-op exit that skips a needless intermediate write.
61 """
62 return current is None or content_hash(current) != content_hash(target)
63 
64 
65def classify_verify(
66 read_back: TargetState | None, target: TargetState
67) -> VerifyOutcome:
68 """Compare a read-after-write against the target (SPEC §3 rule 4).
69 
70 Args:
71 read_back: The post-write YNAB state, or ``None`` if it could not be
72 read after retries.
73 target: The intended end-state.
74 
75 Returns:
76 ``MATCH`` when they agree, ``COULD_NOT_CONFIRM`` when the read failed,
77 else ``DIVERGED`` (YNAB shows a different non-empty state).
78 """
79 if read_back is None:
80 return VerifyOutcome.COULD_NOT_CONFIRM
81 if content_hash(read_back) == content_hash(target):
82 return VerifyOutcome.MATCH
83 return VerifyOutcome.DIVERGED
84 
85 
86def reconciliation_blocks(snapshot: YnabSnapshot) -> bool:
87 """Whether the reconciliation guard forbids a silent edit (SPEC §3 rule 2).
88 
89 A transaction that is reconciled *or in a closed month* must not be silently
90 un-approved or edited; the spine should propose to the human (an additive
91 correction) instead.
92 """
93 return snapshot.reconciled or snapshot.month_closed
94 
95 
96class PrecommitAction(StrEnum):
97 """The pre-write decision for a converge run (SPEC §3 rules 3-4)."""
98 
99 WRITE = "write"
100 NO_CHANGE = "no_change"
101 ALREADY_TARGET = "already_target"
102 DIVERGED = "diverged"
103 
104 
105def precommit_action(
106 current: TargetState | None,
107 target: TargetState,
108 prior: TargetState | None,
109) -> PrecommitAction:
110 """Decide what to do *before* writing, from a fresh read of current state.
111 
112 This is SPEC §3 rule 3 (converge-to-target: read current YNAB state, write
113 only if it differs) plus the *pre-write* half of rule 4 (a spouse edited it
114 directly → don't clobber). Reading current state before the commit is what
115 lets the spine skip a needless write — and surface a divergence — instead of
116 overwriting it first and only noticing on the read-back.
117 
118 Args:
119 current: The freshly-read current end-state, or ``None`` when there
120 is no single category to read (a split, or uncategorized).
121 target: The end-state the instruction asks for.
122 prior: The end-state the agent last applied — the divergence baseline —
123 or ``None`` when the txn was never applied (a revision from LAPSED).
124 
125 Returns:
126 ``NO_CHANGE`` when YNAB already holds exactly the prior state and the
127 instruction asks for nothing new (no write, return to rest);
128 ``ALREADY_TARGET`` when the target is already in place but differs from
129 the prior — a retried converge whose write landed, or an edit that
130 happens to match — so adopt it as re-applied without rewriting;
131 ``DIVERGED`` when YNAB drifted to a different non-empty category
132 than the agent last applied (don't clobber, ask which wins);
133 otherwise ``WRITE``.
134 """
135 if not needs_write(current, target):
136 # YNAB already holds the target. A genuine no-op (unchanged from the
137 # prior) returns to rest; a target that differs from the prior is a
138 # write that already landed — adopt it rather than re-applying.
139 if (
140 current is not None
141 and prior is not None
142 and content_hash(current) == content_hash(prior)
143 ):
144 return PrecommitAction.NO_CHANGE
145 return PrecommitAction.ALREADY_TARGET
146 # A write is needed. Divergence is judged on the *allocation* (matching the
147 # silent-edit guard, §14.2): a spouse recategorising out of band leaves a
148 # different non-empty category than the agent last applied — surface it,
149 # don't overwrite. A memo-only drift is not a divergence.
150 if (
151 current is not None
152 and prior is not None
153 and current.allocation != prior.allocation
154 ):
155 return PrecommitAction.DIVERGED
156 return PrecommitAction.WRITE

Reading before writing

The activity now reads the current end-state first, asks precommit_action, and only reaches client.commit on the WRITE branch. NO_CHANGE, ALREADY_TARGET, and DIVERGED all return without touching YNAB. The write branch is unchanged from before — commit, read back, classify — so the verify path keeps its existing behavior.

The signature gains prior; the body decides before it writes. end_state/prior_state are the TargetState projections the pure helper compares.

src/ynab_agent/workflow/activities.py · 560 lines
src/ynab_agent/workflow/activities.py560 lines · Python
⋯ 402 lines hidden (lines 1–402)
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_enrichment_inputs(
99 snapshot: YnabSnapshot,
100) -> tuple[tuple[CandidateCategory, ...], list[Rule], AutoActionCounters]:
101 """Fetch the candidate categories, in-scope rules, and auto counters.
102 
103 The candidates are the budget's live categories, read from YNAB (the source
104 of truth); the rules come from the durable registry, keyed on the payee. The
105 auto-action counters start at zero in v1 (the per-run circuit breaker is a
106 later stage); the gate (SPEC §4.2, §14) decides auto-vs-ask over the loaded
107 rules — and auto-applies only a blessed one.
108 """
109 import asyncio
110 
111 from ynab_agent.policy.floor import AutoActionCounters
112 from ynab_agent.ynab.client import YnabClient
113 
114 client = YnabClient.from_env()
115 spends = await asyncio.to_thread(client.category_spends)
116 rules = await _load_payee_rules(snapshot.payee)
117 return _candidates_from_spends(spends), rules, AutoActionCounters()
118 
119 
120@activity.defn
121async def enrich(snapshot: YnabSnapshot) -> EnrichmentOutcome:
122 """Assemble the proposal and route via the gate (the agentic middle).
123 
124 The model stack is imported lazily, here in the activity body, so it never
125 enters the workflow sandbox. The gate decides autonomy from the rules; the
126 agent runs only to produce the proposal a human is asked to confirm.
127 """
128 from datetime import UTC, datetime
129 
130 from ynab_agent.agentic.enrich import decide_enrichment
131 
132 candidates, rules, counters = await _load_enrichment_inputs(snapshot)
133 return await decide_enrichment(
134 snapshot, candidates, rules, counters, now=datetime.now(UTC)
135 )
136 
137 
138@activity.defn
139async def commit_to_ynab(ynab_id: str, decision: Decision) -> None:
140 """Commit a decision to YNAB (the deterministic write).
141 
142 Lazy YNAB client (httpx stays out of the sandbox); the blocking write runs
143 off the event loop.
144 """
145 import asyncio
146 
147 from ynab_agent.ynab.client import YnabClient
148 
149 client = YnabClient.from_env()
150 await asyncio.to_thread(client.commit, ynab_id, decision)
151 
152 
153@activity.defn
154async def read_back(ynab_id: str) -> TargetState | None:
155 """Read the post-write end-state for verification, or ``None`` if unread."""
156 import asyncio
157 
158 from ynab_agent.ynab.client import YnabClient
159 
160 client = YnabClient.from_env()
161 return await asyncio.to_thread(client.read_back, ynab_id)
162 
163 
164def _txn_label(ynab_id: str) -> str:
165 """The per-transaction idempotency label (open-thread dedup; SPEC §5)."""
166 return f"yatxn-{ynab_id}"
167 
168 
169def _seq_label(ynab_id: str, action_seq: int) -> str:
170 """The per-action idempotency label (send dedup; SPEC §3)."""
171 return f"yaseq-{ynab_id}-{action_seq}"
172 
173 
174def _subject(snapshot: YnabSnapshot, category: str | None) -> str:
175 """The thread's subject: payee + amount, and the suggested category.
176 
177 No ``[YNAB]`` prefix — the sender address is a known contact. Naming the
178 proposed category lets the owner act from the subject line alone.
179 """
180 base = f"{snapshot.payee}{snapshot.amount}"
181 return f"{base} · {category}?" if category else base
182 
183 
184def _date_display(day: datetime.date) -> str:
185 """A short, friendly transaction date, e.g. ``May 29`` (no leading zero)."""
186 return f"{day.strftime('%b')} {day.day}"
187 
188 
189def _allocation_display(
190 allocation: ProposedCategory | ProposedSplit, names: dict[str, str]
191) -> str:
192 """A human display of the proposed allocation, by category name."""
193 if isinstance(allocation, ProposedCategory):
194 return names.get(str(allocation.category), str(allocation.category))
195 return " + ".join(
196 names.get(str(line.category), str(line.category))
197 for line in allocation.lines
198 )
199 
200 
201async def _read_for_compose(
202 ynab_id: str,
203) -> tuple[YnabSnapshot, dict[str, str]]:
204 """Re-read the YNAB snapshot and a category-id→name map for composing.
205 
206 Both come from YNAB (the source of truth) at send time — there is no stored
207 copy of the facts or the category names (SPEC §0.5, store-free).
208 """
209 import asyncio
210 
211 from ynab_agent.ynab.client import YnabClient
212 
213 client = YnabClient.from_env()
214 snapshot = await asyncio.to_thread(client.snapshot, ynab_id)
215 if snapshot is None:
216 msg = f"transaction {ynab_id} not found in YNAB at compose time"
217 raise RuntimeError(msg)
218 spends = await asyncio.to_thread(client.category_spends)
219 names = {str(spend.category): spend.name for spend in spends}
220 return snapshot, names
221 
222 
223def _render_message(
224 snapshot: YnabSnapshot,
225 proposal: Proposal | None,
226 purpose: MessagePurpose,
227 names: dict[str, str],
228) -> str:
229 """Lay out one message body for the thread (deterministic; SPEC §5).
230 
231 The proposal's category + alternatives (model-chosen upstream) are resolved
232 to names here and handed to the template — no model call at send time.
233 """
234 from ynab_agent.agentic.compose import ComposeRequest, render_body
235 
236 proposed = (
237 _allocation_display(proposal.allocation, names)
238 if proposal is not None
239 else None
240 )
241 alternatives = (
242 tuple(names.get(str(alt), str(alt)) for alt in proposal.alternatives)
243 if proposal is not None
244 else ()
245 )
246 request = ComposeRequest(
247 purpose=purpose.value,
248 payee=snapshot.payee,
249 amount_display=str(snapshot.amount),
250 txn_date=_date_display(snapshot.txn_date),
251 memo=snapshot.memo,
252 proposed_category=proposed,
253 alternatives=alternatives,
254 rationale=proposal.rationale if proposal is not None else None,
255 )
256 return render_body(request)
257 
258 
259@activity.defn
260async def open_thread(ynab_id: str, proposal: Proposal | None) -> str:
261 """Open the AgentMail thread by sending the proposal; returns its id.
262 
263 A thread starts on its first send (AgentMail has no empty-thread create), so
264 this composes + sends the proposal as the opening email. ``proposal`` is the
265 current best guess (carried from workflow state); the txn facts + category
266 names are re-read from YNAB (the source of truth) at compose time. The open
267 is idempotent on the per-transaction label, so a retry re-finds the thread
268 rather than sending a duplicate.
269 """
270 import asyncio
271 
272 from ynab_agent.mail.client import MailClient
273 from ynab_agent.settings import Settings
274 
275 settings = Settings()
276 snapshot, names = await _read_for_compose(ynab_id)
277 body = _render_message(snapshot, proposal, MessagePurpose.PROPOSAL, names)
278 proposed = (
279 _allocation_display(proposal.allocation, names)
280 if proposal is not None
281 else None
282 )
283 mail = MailClient.from_env()
284 return await asyncio.to_thread(
285 mail.open_thread,
286 inbox_id=settings.inbox,
287 to=list(settings.owners),
288 subject=_subject(snapshot, proposed),
289 body=body,
290 txn_label=_txn_label(ynab_id),
291 )
292 
293 
294@activity.defn
295async def send_thread_message(
296 ynab_id: str,
297 thread_id: str | None,
298 purpose: MessagePurpose,
299 action_seq: int,
300 proposal: Proposal | None,
301) -> None:
302 """Send a follow-up message on the transaction's thread.
303 
304 ``action_seq`` is the per-transaction idempotency key: the send dedups on it
305 so a retry never double-sends (SPEC §3). ``proposal`` is the current best
306 guess where the purpose needs it (re-proposal); other purposes derive their
307 content from a re-read of the YNAB snapshot.
308 """
309 import asyncio
310 
311 from ynab_agent.mail.client import MailClient
312 from ynab_agent.settings import Settings
313 
314 if thread_id is None:
315 msg = f"cannot send {purpose.value} for {ynab_id}: no thread open yet"
316 raise RuntimeError(msg)
317 settings = Settings()
318 snapshot, names = await _read_for_compose(ynab_id)
319 body = _render_message(snapshot, proposal, purpose, names)
320 mail = MailClient.from_env()
321 await asyncio.to_thread(
322 mail.send_on_thread,
323 inbox_id=settings.inbox,
324 thread_id=thread_id,
325 body=body,
326 seq_label=_seq_label(ynab_id, action_seq),
327 )
328 
329 
330def _proposed_category_id(proposal: Proposal | None) -> CategoryId | None:
331 """The single proposed category id, or None (a split is not approvable)."""
332 if proposal is None:
333 return None
334 allocation = proposal.allocation
335 if isinstance(allocation, ProposedCategory):
336 return allocation.category
337 return None
338 
339 
340@activity.defn
341async def interpret_inbound(
342 signal: InboundSignal,
343 snapshot: YnabSnapshot,
344 proposal: Proposal | None,
345) -> ReplyOutcome:
346 """Interpret a human reply into an answer or a question (SPEC §3, §5).
347 
348 Free-form: the model reads whether the reply approves the proposal, names a
349 different category, or asks a question. The spine — not the model — stamps
350 the human + time into the resulting Decision. A non-reply inbound, or a
351 missing/split proposal, can't be answered directly, so ask rather than guess
352 a write. Names + candidates are re-read from YNAB at interpret time.
353 """
354 import asyncio
355 from datetime import UTC, datetime
356 
357 from ynab_agent.agentic.interpret import (
358 InterpretRequest,
359 interpret,
360 to_reply_outcome,
361 )
362 from ynab_agent.domain.signals import ReplySignal
363 from ynab_agent.workflow.types import ClarifyOutcome
364 from ynab_agent.ynab.client import YnabClient
365 
366 proposed_id = _proposed_category_id(proposal)
367 if not isinstance(signal, ReplySignal) or proposed_id is None:
368 return ClarifyOutcome(
369 question="Could you say which category this should be?"
370 )
371 
372 client = YnabClient.from_env()
373 spends = await asyncio.to_thread(client.category_spends)
374 names = {str(spend.category): spend.name for spend in spends}
375 request = InterpretRequest(
376 reply_text=signal.text,
377 payee=snapshot.payee,
378 amount_display=str(snapshot.amount),
379 proposed_category_name=names.get(str(proposed_id), str(proposed_id)),
380 candidates=_candidates_from_spends(spends),
381 )
382 interpretation = await interpret(request)
383 return to_reply_outcome(
384 interpretation,
385 proposed_category=proposed_id,
386 decided_at=datetime.now(UTC),
387 )
388 
389 
390def _target_summary(target: TargetState | None, names: dict[str, str]) -> str:
391 """A short human description of an end-state, for a divergence note."""
392 if target is None:
393 return "(could not read)"
394 allocation = target.allocation
395 if isinstance(allocation, ResolvedCategory):
396 label = names.get(str(allocation.category), str(allocation.category))
397 else:
398 label = "a split"
399 return f"{label}{target.memo}" if target.memo else label
400 
401 
402@activity.defn
403async def converge(
404 snapshot: YnabSnapshot,
405 instruction: InboundSignal,
406 prior: Decision | None,
407) -> ConvergeOutcome:
⋯ 77 lines hidden (lines 408–484)
408 """Converge a REVISING run to its target and verify it (SPEC §3 rules 3-4).
409 
410 The agent reads the revision instruction into a target (retarget / memo /
411 no-change). The spine then converges to it: it reads the *current* YNAB
412 end-state first and, comparing against the agent's last-applied decision
413 (``prior``), skips a needless write (the no-op exit), adopts a write that
414 already landed, or surfaces a divergence (a spouse edited it directly) —
415 *before* overwriting it — rather than committing blind and only noticing on
416 the read-back. Only a genuine change writes, then verifies field-by-field. A
417 reconciled or closed-month transaction, a non-reply instruction, or anything
418 the model under-specifies routes to a human rather than a silent edit.
419 """
420 import asyncio
421 from datetime import UTC, datetime
422 
423 from ynab_agent.agentic.converge import (
424 RevisionRequest,
425 interpret_revision,
426 to_revision_plan,
427 )
428 from ynab_agent.domain.enums import DecidedBy
429 from ynab_agent.domain.events import (
430 CouldNotConfirm,
431 Diverged,
432 NeedsHuman,
433 NoChange,
434 Reapplied,
435 VerifyOutcome,
436 )
437 from ynab_agent.domain.signals import ReplySignal
438 from ynab_agent.policy.converge import (
439 PrecommitAction,
440 classify_verify,
441 precommit_action,
442 reconciliation_blocks,
443 target_of,
444 )
445 from ynab_agent.ynab.client import YnabClient
446 
447 if reconciliation_blocks(snapshot):
448 return NeedsHuman(
449 reason="reconciled or closed-month — propose, don't silently edit"
450 )
451 if not isinstance(instruction, ReplySignal):
452 return NeedsHuman(reason="non-reply revision instruction unsupported")
453 
454 client = YnabClient.from_env()
455 spends = await asyncio.to_thread(client.category_spends)
456 names = {str(spend.category): spend.name for spend in spends}
457 current_name = (
458 names.get(str(snapshot.category_id), str(snapshot.category_id))
459 if snapshot.category_id is not None
460 else "(uncategorized)"
461 )
462 target = await interpret_revision(
463 RevisionRequest(
464 instruction=instruction.text,
465 current_category_name=current_name,
466 candidates=_candidates_from_spends(spends),
467 current_memo=snapshot.memo,
468 )
469 )
470 plan = to_revision_plan(target)
471 if not plan.changes:
472 return NoChange()
473 
474 category = (
475 CategoryId(plan.category_id)
476 if plan.category_id is not None
477 else snapshot.category_id
478 )
479 if category is None:
480 return NeedsHuman(reason="revision did not resolve a category")
481 decision = Decision(
482 allocation=ResolvedCategory(category=category),
483 memo=plan.memo if plan.memo is not None else snapshot.memo,
484 approved=True,
485 decided_by=DecidedBy.HUMAN,
486 decided_at=datetime.now(UTC),
487 )
488 end_state = target_of(decision)
489 prior_state = target_of(prior) if prior is not None else None
490 
491 # SPEC §3 r3-4: read the current end-state *before* writing, then decide.
492 current = await asyncio.to_thread(client.read_back, snapshot.ynab_id)
493 action = precommit_action(current, end_state, prior_state)
494 if action is PrecommitAction.NO_CHANGE:
495 return NoChange()
496 if action is PrecommitAction.ALREADY_TARGET:
497 return Reapplied(decision=decision)
498 if action is PrecommitAction.DIVERGED:
499 return Diverged(
500 ynab_summary=_target_summary(current, names),
501 requested_summary=_target_summary(end_state, names),
502 )
503 
504 # PrecommitAction.WRITE: converge to the target, then verify (SPEC §3 r3-4).
505 await asyncio.to_thread(client.commit, snapshot.ynab_id, decision)
506 read = await asyncio.to_thread(client.read_back, snapshot.ynab_id)
507 verdict = classify_verify(read, end_state)
508 if verdict is VerifyOutcome.MATCH:
509 return Reapplied(decision=decision)
510 if verdict is VerifyOutcome.COULD_NOT_CONFIRM:
511 return CouldNotConfirm()
512 return Diverged(
513 ynab_summary=_target_summary(read, names),
514 requested_summary=_target_summary(end_state, names),
⋯ 46 lines hidden (lines 515–560)
515 )
516 
517 
518@activity.defn
519async def feed_rule_learning(feed: FeedRuleLearning) -> None:
520 """Persist a confirm/correct event into the durable rule registry (W5).
521 
522 Signal-with-start on the singleton :class:`RuleRegistryWorkflow`: the first
523 learning event creates it, every later one just delivers the signal, and the
524 workflow folds the event into the rule table the autonomy gate reads (SPEC
525 §9, §14). The conflict policy reuses the running singleton rather than
526 starting a second registry.
527 """
528 from temporalio.common import WorkflowIDConflictPolicy
529 
530 from ynab_agent.workflow.registry_types import (
531 REGISTRY_WORKFLOW_ID,
532 RegistryParams,
533 )
534 from ynab_agent.workflow.temporal_client import client, task_queue
535 
536 temporal = await client()
537 await temporal.start_workflow(
538 "RuleRegistryWorkflow",
539 RegistryParams(),
540 id=REGISTRY_WORKFLOW_ID,
541 task_queue=task_queue(),
542 id_conflict_policy=WorkflowIDConflictPolicy.USE_EXISTING,
543 start_signal="record",
544 start_signal_args=[feed],
545 )
546 
547 
548@activity.defn
549async def close_thread(thread_id: str) -> None:
550 """Label and close the AgentMail thread on archive."""
551 import asyncio
552 
553 from ynab_agent.mail.client import MailClient
554 from ynab_agent.settings import Settings
555 
556 settings = Settings()
557 mail = MailClient.from_env()
558 await asyncio.to_thread(
559 mail.close, inbox_id=settings.inbox, thread_id=thread_id
560 )

Threading the baseline

The activity needs the last-applied decision as its divergence baseline. The W2 state machine already carries it: a Revising state holds prior (the decision it's revising, or None when entered from LAPSED). The workflow just passes it through — a one-line change, no new state.

st.prior is already on the Revising state; this hands it to the activity.

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

The discriminating tests

Two layers of tests. The pure precommit_action cases pin every branch, including the two that are easy to get wrong: a landed retry must be ALREADY_TARGET (not NO_CHANGE), and a memo-only drift must not read as divergence.

The no-op / already-target / diverged trio; the write and edge cases follow (folded).

tests/policy/test_converge.py · 152 lines
tests/policy/test_converge.py152 lines · Python
⋯ 85 lines hidden (lines 1–85)
1"""Tests for the converge-to-target reconciliation (SPEC §3 rules 2-4)."""
2 
3from __future__ import annotations
4 
5import datetime
6 
7from ynab_agent.domain.allocations import ResolvedCategory
8from ynab_agent.domain.enums import ClearedState, DecidedBy
9from ynab_agent.domain.events import VerifyOutcome
10from ynab_agent.domain.ids import AccountId, CategoryId, YnabTransactionId
11from ynab_agent.domain.money import Money
12from ynab_agent.domain.proposal import Decision
13from ynab_agent.domain.transaction import YnabSnapshot
14from ynab_agent.policy.converge import (
15 PrecommitAction,
16 TargetState,
17 classify_verify,
18 content_hash,
19 needs_write,
20 precommit_action,
21 reconciliation_blocks,
22 target_of,
24 
25_EPOCH = datetime.datetime(2026, 5, 28, tzinfo=datetime.UTC)
26 
27 
28def _target(category: str = "dining", memo: str | None = None) -> TargetState:
29 return TargetState(
30 allocation=ResolvedCategory(category=CategoryId(category)), memo=memo
31 )
32 
33 
34def test_content_hash_is_stable_and_distinguishing() -> None:
35 assert content_hash(_target()) == content_hash(_target())
36 assert content_hash(_target("dining")) != content_hash(_target("gifts"))
37 assert content_hash(_target(memo="a")) != content_hash(_target(memo="b"))
38 
39 
40def test_needs_write_skips_an_equal_state() -> None:
41 assert not needs_write(_target(), _target())
42 assert needs_write(_target("dining"), _target("gifts"))
43 assert needs_write(None, _target())
44 
45 
46def test_classify_verify_outcomes() -> None:
47 target = _target("dining")
48 assert classify_verify(target, target) is VerifyOutcome.MATCH
49 assert classify_verify(None, target) is VerifyOutcome.COULD_NOT_CONFIRM
50 assert classify_verify(_target("gifts"), target) is VerifyOutcome.DIVERGED
51 
52 
53def test_target_of_projects_a_decision() -> None:
54 decision = Decision(
55 allocation=ResolvedCategory(category=CategoryId("dining")),
56 memo="coffee",
57 approved=True,
58 decided_by=DecidedBy.AGENT,
59 decided_at=_EPOCH,
60 )
61 target = target_of(decision)
62 assert target.memo == "coffee"
63 assert content_hash(target) == content_hash(_target(memo="coffee"))
64 
65 
66def test_reconciliation_guard() -> None:
67 base: dict[str, object] = {
68 "ynab_id": YnabTransactionId("t1"),
69 "account": AccountId("a1"),
70 "payee": "Blue Bottle",
71 "amount": Money.from_currency("-4.50"),
72 "txn_date": datetime.date(2026, 5, 28),
73 }
74 reconciled = YnabSnapshot.model_validate(
75 {**base, "cleared": ClearedState.RECONCILED}
76 )
77 closed_month = YnabSnapshot.model_validate({**base, "month_closed": True})
78 cleared = YnabSnapshot.model_validate(
79 {**base, "cleared": ClearedState.CLEARED}
80 )
81 assert reconciliation_blocks(reconciled)
82 assert reconciliation_blocks(closed_month)
83 assert not reconciliation_blocks(cleared)
84 
85 
86# ── precommit_action: the pre-write converge decision (SPEC §3 r3-4) ─────────
87def test_precommit_no_change_when_target_equals_prior() -> None:
88 # YNAB already holds the prior state and the target asks nothing new.
89 action = precommit_action(
90 _target("dining"), _target("dining"), _target("dining")
91 )
92 assert action is PrecommitAction.NO_CHANGE
93 
94 
95def test_precommit_already_target_when_write_already_landed() -> None:
96 # current == target but differs from the prior: a retried converge whose
97 # write landed (or an out-of-band edit that happens to match the target).
98 # Adopt it as re-applied rather than rewriting — and never as NO_CHANGE,
99 # which would revert the workflow's decision to the stale prior.
100 assert (
101 precommit_action(_target("gifts"), _target("gifts"), _target("dining"))
102 is PrecommitAction.ALREADY_TARGET
103 )
104 # No prior (a revision entered from LAPSED) with the target already present.
105 assert (
106 precommit_action(_target("gifts"), _target("gifts"), None)
107 is PrecommitAction.ALREADY_TARGET
108 )
109 
110 
111def test_precommit_diverged_on_out_of_band_recategorisation() -> None:
112 # A write is needed, but YNAB drifted to a different non-empty category than
113 # the agent last applied (a spouse edited it directly) — surface it BEFORE
114 # writing, never clobber.
115 action = precommit_action(
116 _target("groceries"), _target("gifts"), _target("dining")
117 )
118 assert action is PrecommitAction.DIVERGED
⋯ 34 lines hidden (lines 119–152)
119 
120 
121def test_precommit_writes_when_current_matches_prior() -> None:
122 # The normal revision: YNAB still shows what the agent last applied, and the
123 # target differs — converge.
124 action = precommit_action(
125 _target("dining"), _target("gifts"), _target("dining")
126 )
127 assert action is PrecommitAction.WRITE
128 
129 
130def test_precommit_writes_from_lapsed_without_a_prior() -> None:
131 # No prior baseline (entered from LAPSED): there is nothing of ours to
132 # clobber, so a differing target simply writes.
133 action = precommit_action(_target("dining"), _target("gifts"), None)
134 assert action is PrecommitAction.WRITE
135 
136 
137def test_precommit_writes_when_current_is_unreadable() -> None:
138 # current None (a split or uncategorized) is not a divergence — there is no
139 # non-empty state to protect — so it writes (and the read-back classifies).
140 action = precommit_action(None, _target("gifts"), _target("dining"))
141 assert action is PrecommitAction.WRITE
142 
143 
144def test_precommit_memo_only_drift_is_not_divergence() -> None:
145 # A spouse changing only the memo (same category) is not a divergence; the
146 # allocation still matches the prior, so the converge proceeds.
147 action = precommit_action(
148 _target("dining", memo="their note"),
149 _target("gifts"),
150 _target("dining", memo="orig"),
151 )
152 assert action is PrecommitAction.WRITE

Then an integration test drives the real converge activity with a stub client and a stubbed agent, asserting the write is actually skipped. This is the test the old code would fail: it committed in every case, so fake.commits would never be empty for a no-op or a divergence.

The stub records commits; the no-op and divergence cases assert it stays empty.

tests/workflow/test_inbound_activities.py · 254 lines
tests/workflow/test_inbound_activities.py254 lines · Python
⋯ 107 lines hidden (lines 1–107)
1"""Tests for the W2 inbound activities (SPEC §3, §5).
2 
3``interpret_inbound`` and ``converge`` are thin glue over the interpret/converge
4agents (``tests/agentic``) and the verify policy (``tests/policy``). Their pure
5helpers (the single proposed category, an end-state summary) are covered here.
6The ``converge`` *orchestration* is also covered directly: it reads the current
7YNAB state and decides before writing (SPEC §3 r3-4), so a no-op and a
8divergence never issue a clobbering write.
9"""
10 
11from __future__ import annotations
12 
13import datetime
14from typing import TYPE_CHECKING
15 
16from ynab_agent.budget.overspend import CategorySpend
17from ynab_agent.domain.allocations import (
18 PercentShare,
19 ProposedCategory,
20 ProposedSplit,
21 ResolvedCategory,
22 SplitLine,
24from ynab_agent.domain.enums import Confidence, DecidedBy
25from ynab_agent.domain.events import Diverged, NoChange, Reapplied
26from ynab_agent.domain.ids import (
27 AccountId,
28 CategoryId,
29 MessageId,
30 ThreadId,
31 YnabTransactionId,
33from ynab_agent.domain.money import Money
34from ynab_agent.domain.proposal import Decision, Proposal
35from ynab_agent.domain.signals import ReplySignal
36from ynab_agent.domain.transaction import YnabSnapshot
37from ynab_agent.policy.converge import TargetState, target_of
38from ynab_agent.workflow.activities import (
39 _proposed_category_id,
40 _target_summary,
41 converge,
43 
44if TYPE_CHECKING:
45 import pytest
46 
47_NAMES = {"dining": "Dining Out", "coffee": "Coffee"}
48 
49 
50def _proposal(allocation: object) -> Proposal:
51 return Proposal(
52 allocation=allocation, # type: ignore[arg-type]
53 confidence=Confidence.MEDIUM,
54 rationale="because",
55 )
56 
57 
58def test_proposed_category_id_for_single_category() -> None:
59 proposal = _proposal(ProposedCategory(category=CategoryId("dining")))
60 assert _proposed_category_id(proposal) == "dining"
61 
62 
63def test_proposed_category_id_none_for_split() -> None:
64 split = ProposedSplit(
65 lines=(
66 SplitLine(
67 share=PercentShare(percent=50), category=CategoryId("dining")
68 ),
69 SplitLine(
70 share=PercentShare(percent=50), category=CategoryId("coffee")
71 ),
72 )
73 )
74 assert _proposed_category_id(_proposal(split)) is None
75 
76 
77def test_proposed_category_id_none_for_no_proposal() -> None:
78 assert _proposed_category_id(None) is None
79 
80 
81def test_target_summary_names_category_and_memo() -> None:
82 target = TargetState(
83 allocation=ResolvedCategory(category=CategoryId("dining")),
84 memo="team lunch",
85 )
86 assert _target_summary(target, _NAMES) == "Dining Out — team lunch"
87 
88 
89def test_target_summary_without_memo() -> None:
90 target = TargetState(
91 allocation=ResolvedCategory(category=CategoryId("coffee"))
92 )
93 assert _target_summary(target, _NAMES) == "Coffee"
94 
95 
96def test_target_summary_handles_unreadable_state() -> None:
97 assert _target_summary(None, _NAMES) == "(could not read)"
98 
99 
100def test_target_summary_falls_back_to_id() -> None:
101 target = TargetState(
102 allocation=ResolvedCategory(category=CategoryId("mystery"))
103 )
104 assert _target_summary(target, _NAMES) == "mystery"
105 
106 
107# ── converge orchestration: read current state before writing (SPEC §3 r3-4) ──
108class _FakeYnab:
109 """A YNAB client stub that records commits and serves a current state.
110 
111 ``read_back`` returns the current end-state; a ``commit`` lands the write so
112 a subsequent ``read_back`` (the post-write verify) returns the target.
113 """
114 
115 def __init__(self, current: TargetState | None) -> None:
116 self._state = current
117 self.commits: list[Decision] = []
118 
119 def category_spends(self) -> tuple[CategorySpend, ...]:
120 zero = Money.from_milliunits(0)
121 return tuple(
122 CategorySpend(
123 category=CategoryId(name),
124 name=name.title(),
125 budgeted=zero,
126 activity=zero,
127 balance=zero,
128 )
129 for name in ("dining", "gifts", "groceries")
130 )
131 
132 def read_back(self, ynab_id: str) -> TargetState | None:
133 return self._state
134 
135 def commit(self, ynab_id: str, decision: Decision) -> None:
⋯ 60 lines hidden (lines 136–195)
136 self.commits.append(decision)
137 self._state = target_of(decision)
138 
139 
140def _converge_snapshot() -> YnabSnapshot:
141 return YnabSnapshot(
142 ynab_id=YnabTransactionId("t-1"),
143 account=AccountId("a1"),
144 payee="Blue Bottle",
145 amount=Money.from_currency("-4.50"),
146 txn_date=datetime.date(2026, 5, 30),
147 category_id=CategoryId("dining"),
148 )
149 
150 
151def _reply(text: str) -> ReplySignal:
152 return ReplySignal(
153 thread_id=ThreadId("thread-1"),
154 message_id=MessageId("m-1"),
155 from_address="matthew@example.com",
156 text=text,
157 )
158 
159 
160def _decision(category: str) -> Decision:
161 return Decision(
162 allocation=ResolvedCategory(category=CategoryId(category)),
163 approved=True,
164 decided_by=DecidedBy.HUMAN,
165 decided_at=datetime.datetime(2026, 5, 28, tzinfo=datetime.UTC),
166 )
167 
168 
169async def _run_converge(
170 monkeypatch: pytest.MonkeyPatch,
171 *,
172 current: TargetState | None,
173 prior: Decision | None,
174 retarget_to: str = "gifts",
175) -> tuple[object, _FakeYnab]:
176 """Drive the converge activity with a stub client and a stubbed agent."""
177 from ynab_agent.agentic import converge as converge_mod
178 from ynab_agent.ynab.client import YnabClient
179 
180 fake = _FakeYnab(current)
181 
182 async def _fake_interpret(
183 request: object, *, model: object = None
184 ) -> converge_mod.RevisionTarget:
185 return converge_mod.RevisionTarget(
186 decision=converge_mod.RevisionDecision.RETARGET,
187 category_id=retarget_to,
188 )
189 
190 monkeypatch.setattr(converge_mod, "interpret_revision", _fake_interpret)
191 monkeypatch.setattr(YnabClient, "from_env", classmethod(lambda cls: fake))
192 outcome = await converge(
193 _converge_snapshot(), _reply("make it gifts"), prior
194 )
195 return outcome, fake
196 
197 
198async def test_converge_no_op_skips_the_write(
199 monkeypatch: pytest.MonkeyPatch,
200) -> None:
201 # The target already equals what the agent last applied: no write at all.
202 outcome, fake = await _run_converge(
203 monkeypatch,
204 current=target_of(_decision("gifts")),
205 prior=_decision("gifts"),
206 )
207 assert isinstance(outcome, NoChange)
208 assert fake.commits == []
209 
210 
211async def test_converge_adopts_a_landed_write_without_rewriting(
212 monkeypatch: pytest.MonkeyPatch,
213) -> None:
214 # The target is already in YNAB but differs from the prior (a retried
215 # converge whose write landed): re-applied, not NoChange, and not rewritten.
216 outcome, fake = await _run_converge(
217 monkeypatch,
218 current=target_of(_decision("gifts")),
219 prior=_decision("dining"),
220 )
221 assert isinstance(outcome, Reapplied)
222 assert fake.commits == []
223 
224 
225async def test_converge_surfaces_divergence_without_clobbering(
226 monkeypatch: pytest.MonkeyPatch,
227) -> None:
228 # A spouse recategorised the txn out of band: surface it, never overwrite.
229 outcome, fake = await _run_converge(
230 monkeypatch,
231 current=target_of(_decision("groceries")),
232 prior=_decision("dining"),
233 )
234 assert isinstance(outcome, Diverged)
235 assert outcome.ynab_summary == "Groceries"
236 assert outcome.requested_summary == "Gifts"
237 assert fake.commits == []
⋯ 17 lines hidden (lines 238–254)
238 
239 
240async def test_converge_writes_then_verifies_a_real_change(
241 monkeypatch: pytest.MonkeyPatch,
242) -> None:
243 # YNAB still shows what the agent last applied and the target differs:
244 # converge (one write), then verify the read-back.
245 outcome, fake = await _run_converge(
246 monkeypatch,
247 current=target_of(_decision("dining")),
248 prior=_decision("dining"),
249 )
250 assert isinstance(outcome, Reapplied)
251 assert outcome.decision.allocation == ResolvedCategory(
252 category=CategoryId("gifts")
253 )
254 assert len(fake.commits) == 1