What changed, and why
In YNAB, approving an imported transaction accepts that import. So a YNAB-matched/duplicate import — one YNAB flagged as matching an existing txn — must never be auto-approved; it goes to a human (SPEC §13). The guard was unwired.
W1 did compute the condition: plan_ingest set route_to_human for a matched import. But address_transaction starts the W2 with the transaction id alone — the flag was dropped, "carried for observability only." And the autonomy gate never looked at the import state. So a matched import that happened to hit a blessed rule would be auto-applied, accepting the duplicate — the one thing §13 says not to do.
The guard, where autonomy is decided
The gate is the single path to auto-apply, so the guard belongs there. Right after the hard-floor check, a matched import forces ASK regardless of trust — before any blessed rule is even considered.
A second deterministic guard beside the floor: a matched import can never reach the blessed-rule AUTO path.
src/ynab_agent/policy/gate.py · 153 lines
⋯ 99 lines hidden (lines 1–99)
⋯ 37 lines hidden (lines 117–153)
One predicate, two readers
is_matched_import is a YnabSnapshot property, alongside reconciled / categorized / has_memo. It is the single source W1's observability flag and the gate's guard both read, so the two can't drift on what counts as a matched import.
The predicate, on the snapshot.
src/ynab_agent/domain/transaction.py · 265 lines
⋯ 84 lines hidden (lines 1–84)
⋯ 172 lines hidden (lines 94–265)
W1's is_duplicate_import now delegates to the property, rather than re-deriving matched_transaction_id is not None.
src/ynab_agent/ingest/plan.py · 78 lines
⋯ 38 lines hidden (lines 1–38)
⋯ 32 lines hidden (lines 47–78)
Tests
The discriminating test mirrors the floor-override test: a matched import ASKs even with a single blessed rule present — proving the guard sits ahead of the AUTO path. The property and the delegation are pinned too.
A blessed rule does not auto-apply a matched import.
tests/policy/test_gate.py · 177 lines
⋯ 157 lines hidden (lines 1–157)
⋯ 9 lines hidden (lines 169–177)
The snapshot predicate flags a YNAB match.