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
⋯ 95 lines hidden (lines 1–95)
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
⋯ 402 lines hidden (lines 1–402)
⋯ 77 lines hidden (lines 408–484)
⋯ 46 lines hidden (lines 515–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
⋯ 522 lines hidden (lines 1–522)
⋯ 18 lines hidden (lines 531–548)
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
⋯ 85 lines hidden (lines 1–85)
⋯ 34 lines hidden (lines 119–152)
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.