What changed, and why
Splits were broken on the verify side. The write half worked: a split decision set category_id = null and a subtransaction per line. The verify half could not see it. to_target — which turns a read-back snapshot into the end-state to compare — returned None for any snapshot whose category_id was None, and a split parent's category is always null.
So classify_verify saw read = None, returned COULD_NOT_CONFIRM, and a perfectly-applied split escalated to a human every time: "I tried to change this and couldn't confirm it landed." A split could be written but never confirmed.
The fix reads the subtransactions back and reconstructs the split end-state, so a split write verifies field-by-field like any other.
Reading the subtransactions back
YNAB returns a split's subtransactions on the parent transaction; the wire model just wasn't reading them (it ignores unknown fields). Adding the field, the snapshot now carries the split lines, mapped from the wire — deleted and uncategorized lines dropped, since the agent only writes fully-categorized splits.
The wire gains WireSubtransaction and the parent's subtransactions.
src/ynab_agent/ynab/wire.py · 75 lines
⋯ 19 lines hidden (lines 1–19)
⋯ 22 lines hidden (lines 54–75)
_to_split_lines maps the wire subtransactions onto domain split lines.
src/ynab_agent/ynab/client.py · 432 lines
⋯ 63 lines hidden (lines 1–63)
⋯ 349 lines hidden (lines 84–432)
Reconstructing the split end-state
With the lines in hand, to_target rebuilds a ResolvedSplit for a split parent — so its end-state can be compared field-by-field — and falls back to the whole-category case. It returns None now only for a genuinely uncategorized txn (nothing to confirm).
A split parent reconstructs to a ResolvedSplit; the old code returned None here, forcing could-not-confirm.
src/ynab_agent/ynab/client.py · 432 lines
⋯ 116 lines hidden (lines 1–116)
⋯ 294 lines hidden (lines 139–432)
Order-insensitive comparison
One subtlety: YNAB doesn't promise to return a split's subtransactions in the order they were written. A naive hash of the line tuple would then read a correct split as diverged. So content_hash canonically orders the lines before hashing — two equal splits hash the same regardless of order, while a genuinely different split still differs.
Canonicalize split-line order, then hash — the verify is order-insensitive.
src/ynab_agent/policy/converge.py · 123 lines
⋯ 49 lines hidden (lines 1–49)
⋯ 39 lines hidden (lines 85–123)
Tests
The discriminating test is end-to-end: a written split MATCHes on read-back even when YNAB returns the lines in the opposite order — the exact bug. The mapping and the order-insensitive hash are pinned directly too.
A split parent reconstructs to a ResolvedSplit.
tests/ynab/test_ynab_client.py · 400 lines
⋯ 262 lines hidden (lines 1–262)
⋯ 116 lines hidden (lines 285–400)
content_hash is order-insensitive for splits.