What changed, and why
This PR closes the loop on earned autonomy: the agent can now offer to take a payee over, guard every auto-apply, and revoke the grant when it gets something wrong — and a smaller fix stops it ever reading an autoresponder as a "yes".
Before, a learned rule could climb to eligible but nothing ever surfaced it (eligible_for_bless had zero callers); the only path to autonomy was the owner proactively emailing a standing command. Now the lifecycle is complete:
- Earn — a rule reaches eligibility only after K (raised 3 → 5) consistent same-way confirmations. - Offer (SPEC §14.7 3b) — the registry volunteers a one-time "want me to auto-handle Spotify?" email; a free-form "yes" blesses the rule. - Guard (§0.6 Layer 2) — before any auto-apply lands, an independent model review can veto it. - Revoke (§14.8) — a silent in-YNAB edit, or an explicit correction, demotes the rule back to Observe.
The design choice that keeps it simple: the offer is its own email thread, so a reply there is unambiguously a bless-acceptance, never a category reply — the pure per-transaction state machine is untouched. Read the pieces below; each is small and independently safe.
The fix: an autoresponder is not a confirmation
The deterministic classifier already refused to act on an autoresponder — but it keyed on an is_auto_reply flag the webhook never set, so the guard was dead and an owner's out-of-office reply on a transaction thread was read as a categorization. The fix derives the flag from the headers AgentMail already surfaces, in the one place that builds the domain message.
RFC 3834 Auto-Submitted, the bulk/auto Precedence values, and the vendor markers — normalised case-insensitively — then wired into to_inbound.
src/ynab_agent/webhook/app.py · 249 lines
⋯ 82 lines hidden (lines 1–82)
⋯ 133 lines hidden (lines 117–249)
The pure core: a one-time marker and three folds
Everything stateful about the offer lives in the registry's pure folds, so it is trivially testable and carries across continue-as-new like the rest of the domain. A rule gains an offered_at marker; three new folds read and advance it.
pending_offers (eligible AND never offered), mark_offered (idempotent), bless_by_id (grant by id — never creates, so a stale offer can't resurrect).
src/ynab_agent/learn/registry.py · 221 lines
⋯ 137 lines hidden (lines 1–137)
⋯ 14 lines hidden (lines 208–221)
The registry becomes a thin effect-dispatcher
The registry was a park-on-signals singleton. It now mirrors the W2 driver: each wake, if a rule is newly eligible-and-unoffered it starts that rule's one-time offer and marks it offered; only once the offer queue is drained (and history has grown) does it continue-as-new. Crucially it must never die or busy-loop on a failed offer — the gate reads its rule table — so a terminal failure is alerted best-effort and the rule is marked offered anyway.
The run loop derives pending offers from state each wake (no separate queue → replay-safe); _offer is failure-isolated; bless_existing blesses by id.
src/ynab_agent/workflow/registry_workflow.py · 164 lines
⋯ 59 lines hidden (lines 1–59)
⋯ 19 lines hidden (lines 117–135)
⋯ 18 lines hidden (lines 147–164)
The offer workflow: ask, then read the reply
One short-lived workflow per eligible rule. It opens its own offer thread, stamps an OfferThreadId search attribute (so a reply routes back to it), and waits for an answer or a 14-day timeout. The reply is read by a model into accept/decline/unclear; accept blesses, decline sends a brief note, unclear keeps waiting, and silence simply ends the offer (a later "yes" still works via the command path).
All clock reads via workflow.* and side effects behind activities, so the model/mail stacks never enter this sandbox; the verdict branch is a pure enum.
src/ynab_agent/workflow/offer_workflow.py · 125 lines
⋯ 50 lines hidden (lines 1–50)
⋯ 1 line hidden (lines 125–125)
Routing a reply back by thread identity
This is what lets a bare "yes" work without confusing the spine. When an inbound thread isn't a transaction's, W3 asks whether it's a live offer thread; if so, classify routes it to that offer — after the same inbound-boundary guards (unsigned / autoresponder / non-allow-listed all still win first).
RouteToOffer joins the discriminated union; the guard order is unchanged.
src/ynab_agent/dispatch/classify.py · 138 lines
⋯ 71 lines hidden (lines 1–71)
⋯ 53 lines hidden (lines 77–129)
resolve_offer_thread filters to ExecutionStatus=Running (a closed offer is never resurrected); signal_offer is a plain best-effort signal.
src/ynab_agent/workflow/dispatch_activities.py · 192 lines
⋯ 43 lines hidden (lines 1–43)
⋯ 112 lines hidden (lines 81–192)
Guard: an independent, clean-context safety review
Before an auto-apply lands, the model judges it — but blind to the rule's choice, so it isn't anchored. If the independent categorization doesn't even consider the blessed category plausible, the auto-apply is held back to ASK. This is the doubt-detection for "this transaction looks different from the usual."
review_auto_apply is the one-way ratchet (wiring the dormant ReviewVerdict enum); decide_enrichment runs it only on the AUTO branch.
src/ynab_agent/agentic/enrich.py · 277 lines
⋯ 160 lines hidden (lines 1–160)
⋯ 65 lines hidden (lines 177–241)
Revoke: catching a silent manual edit
The owner might just recategorize an auto-applied transaction in YNAB directly — a silent correction the reply path never sees. At the OPEN→archive boundary the driver re-reads YNAB and compares the live category to what the agent applied; a mismatch becomes an OverrideDetected carrying the human's choice.
A category-only comparison (a memo-only edit is not an override); the human Decision is built from the read-back.
src/ynab_agent/workflow/txn_workflow.py · 548 lines
⋯ 461 lines hidden (lines 1–461)
⋯ 62 lines hidden (lines 487–548)
The pure transition: demote the driving rule (CORRECT, carrying the prior agent decision), notify, and close on the owner's value — archiving only if reconciled, else adopt-and-wait (ARCHIVED requires a reconciled snapshot).
src/ynab_agent/domain/state_machine.py · 626 lines
⋯ 444 lines hidden (lines 1–444)
⋯ 156 lines hidden (lines 471–626)
Verification
The full gate is green — ruff, mypy over 182 files, and 449 tests — including the "Many Hands" determinism, sandbox-imports, and variant-exhaustiveness sweeps, which scan the new workflow files. New coverage spans every layer: the pure folds and the K=5 ladder; the registry workflow (eligibility → exactly one offer → no re-offer; bless_existing); the offer workflow (accept / decline / unclear / timeout); classify's RouteToOffer and guard precedence; resolve_offer_thread's Running-only query; the safety review's proceed/escalate; and the manual-edit demotion end to end.
One deploy step rides alongside the code: the new OfferThreadId search attribute must be registered on the Temporal namespace (the same idempotent job that registers TxnThreadId).