What changed, and why
W2's ASK path — propose a category, email the owner, wait — always ran the model, even for a payee whose category the owner had already confirmed enough times for its learned rule to reach trusted. The rule's knowledge was demoted to a one-line prompt hint, and the model re-derived the owner's own repeated answer on every new charge from that payee.
This PR lets a trusted matching rule be the proposal: same category, the same honest rationale the hint used, sources=rule, no model call. The human still decides — the deterministic gate alone grants autonomy, so nothing about write authority moves. As rules accumulate, this removes the proposal call for every repeat payee, the most common model call in the system.
The rule-answered proposal
_proposal_from_rule walks the matching rules and returns a proposal from the first one that qualifies. The bar is deliberately specific: trust must be TRUSTED (consistency-proven, the same threshold bless-eligibility uses), the action must be a single category, and that category must still be a live candidate. Anything weaker returns None and the model path runs unchanged.
Trusted + single-category + still-a-candidate, or None.
src/ynab_agent/agentic/enrich.py · 387 lines
⋯ 214 lines hidden (lines 1–214)
⋯ 137 lines hidden (lines 251–387)
Wiring: one early return on the ASK path
decide_enrichment is untouched until after the AUTO branch. The clean-context review that judges an auto-apply (SPEC §0.6 Layer 2) still runs the model on purpose — that call is a safety layer, not overhead. Only then, where the ASK proposal used to be built, the rule gets first claim; the model call below it is byte-for-byte the old behavior, hint and all.
Rule first; the model ASK (with the rule hint) is the unchanged fallback.
src/ynab_agent/agentic/enrich.py · 387 lines
⋯ 363 lines hidden (lines 1–363)
⋯ 1 line hidden (lines 387–387)
The proof: an explosive model
The discriminating test replaces propose with a stub that raises if reached, so the no-model contract is enforced, not assumed. Two fallback tests pin the other side: a stale category id and a merely-confirmed rule both still consult the model.
A trusted-but-unblessed rule, and the stub that fails the test if the model is consulted.
tests/agentic/test_enrich.py · 469 lines
⋯ 367 lines hidden (lines 1–367)
⋯ 57 lines hidden (lines 413–469)
Fallbacks: stale category id → model; confirmed-only rule → model with hint.
tests/agentic/test_enrich.py · 469 lines
⋯ 413 lines hidden (lines 1–413)
⋯ 13 lines hidden (lines 457–469)
Gate: 745 passed, 7 skipped; ruff, mypy, and the workflow-determinism check clean.