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
src/ynab_agent/webhook/app.py249 lines · Python
⋯ 82 lines hidden (lines 1–82)
1"""W3 · the inbound webhook receiver (SPEC §5).
2 
3A tiny HTTP front door, deployed alongside the worker. AgentMail POSTs a
4Svix-signed event here when an email arrives; this app verifies the signature
5(proving the request really came from AgentMail — the ``signature_verified``
6provenance check, SPEC §0.6), maps the message onto the domain
7``InboundMessage``, and starts a ``DispatchWorkflow`` on Temporal. The
8*dispatcher* then does the routing (reply / receipt / command / quarantine);
9this only authenticates the transport and hands off.
10 
11AgentMail already authenticates the *sender* (SPF/DKIM/DMARC) and surfaces it as
12the event type, so only ``message.received`` is acted on — ``*.unauthenticated``
13/ ``*.spam`` / ``*.blocked`` are ignored here. The Temporal client lives on
14``app.state`` so tests inject one without a real connection.
15"""
16 
17from __future__ import annotations
18 
19import os
20from contextlib import asynccontextmanager
21from typing import TYPE_CHECKING
22 
23from fastapi import FastAPI, HTTPException, Request
24from pydantic import BaseModel, ConfigDict, Field
25 
26from ynab_agent.dispatch.classify import InboundMessage
27from ynab_agent.domain.ids import MessageId, ThreadId
28from ynab_agent.workflow.dispatch_types import DispatchParams
29from ynab_agent.workflow.dispatch_workflow import DispatchWorkflow
30 
31if TYPE_CHECKING:
32 from collections.abc import AsyncIterator, Mapping
33 
34 from temporalio.client import Client
35 
36WEBHOOK_SECRET_ENV = "AGENTMAIL_WEBHOOK_SECRET"
37_RECEIVED = "message.received"
38 
39 
40class _WireMessage(BaseModel):
41 """The message fields we read from an AgentMail webhook (loose)."""
42 
43 model_config = ConfigDict(extra="ignore", populate_by_name=True)
44 
45 message_id: str
46 from_address: str = Field(alias="from")
47 subject: str = ""
48 text: str | None = None
49 # The new reply text with the quoted history stripped — what the human
50 # actually wrote. Preferred over ``text`` so the interpreter isn't fed the
51 # whole quoted proposal back.
52 extracted_text: str | None = None
53 thread_id: str | None = None
54 # The raw RFC 822 headers AgentMail surfaces (a flat name→value dict). Read
55 # only to detect machine-generated mail (see ``_is_auto_reply``); ``None``
56 # when AgentMail omits them.
57 headers: dict[str, str] | None = None
58 
59 
60class _WebhookPayload(BaseModel):
61 """An AgentMail webhook event — only the parts we use.
62 
63 The Svix envelope's top-level ``type`` is always the literal ``"event"``;
64 the actual event name (``message.received`` and its ``.spam`` / ``.blocked``
65 / ``.unauthenticated`` variants) is in ``event_type``. We act only on an
66 exact ``message.received``.
67 """
68 
69 model_config = ConfigDict(extra="ignore")
70 
71 event_type: str = ""
72 message: _WireMessage | None = None
73 
74 
75# RFC 3834 + de-facto vendor markers that a message was generated by a machine
76# (a vacation/auto-reply, a bounce, or a bulk mailer) rather than typed by a
77# human. Such a message must never be read as a confirmation — "silence is not
78# consent" (SPEC §0.6 inbound boundary, §5d).
79_AUTO_PRECEDENCE = frozenset({"bulk", "auto_reply", "junk"})
80_AUTO_MARKER_HEADERS = ("x-autoreply", "x-autorespond", "list-id")
81 
82 
83def _is_auto_reply(headers: Mapping[str, str] | None) -> bool:
84 """True if the inbound headers mark the message as machine-generated.
85 
86 Per RFC 3834, ``Auto-Submitted`` present and set to anything but ``no``
87 means the message was produced automatically (the canonical vacation /
88 auto-reply signal). We also honor the de-facto ``Precedence:
89 bulk/auto_reply/junk`` convention and the vendor ``X-Autoreply`` /
90 ``X-Autorespond`` markers, plus ``List-Id`` (bulk/list traffic). Header
91 names are case-insensitive, so the lookup is normalized to lower case.
92 """
93 if not headers:
94 return False
95 lowered = {
96 name.lower(): value.strip().lower() for name, value in headers.items()
97 }
98 auto_submitted = lowered.get("auto-submitted")
99 if auto_submitted is not None and auto_submitted != "no":
100 return True
101 if lowered.get("precedence") in _AUTO_PRECEDENCE:
102 return True
103 return any(header in lowered for header in _AUTO_MARKER_HEADERS)
104 
105 
106def to_inbound(message: _WireMessage, *, verified: bool) -> InboundMessage:
107 """Map a verified webhook message onto the domain InboundMessage."""
108 return InboundMessage(
109 message_id=MessageId(message.message_id),
110 from_address=message.from_address,
111 subject=message.subject,
112 body=message.extracted_text or message.text or "",
113 thread_id=ThreadId(message.thread_id) if message.thread_id else None,
114 signature_verified=verified,
115 is_auto_reply=_is_auto_reply(message.headers),
116 )
⋯ 133 lines hidden (lines 117–249)
117 
118 
119def verify(body: bytes, headers: Mapping[str, str], secret: str) -> object:
120 """Verify an AgentMail (Svix) webhook and return its parsed payload.
121 
122 Raises ``svix.webhooks.WebhookVerificationError`` if the signature is bad.
123 """
124 from svix.webhooks import Webhook
125 
126 return Webhook(secret).verify(body, dict(headers))
127 
128 
129async def start_dispatch(
130 client: Client,
131 inbound: InboundMessage,
132 *,
133 allowlist: frozenset[str],
134 task_queue: str,
135) -> bool:
136 """Start a DispatchWorkflow for one message; idempotent (SPEC §5).
137 
138 The workflow id is derived from the message id, and the id-reuse policy
139 rejects duplicates (running *or* completed), so an AgentMail webhook retry
140 is a no-op rather than a re-dispatch. Returns ``True`` if newly started,
141 ``False`` if it had already been dispatched.
142 """
143 from temporalio.common import WorkflowIDReusePolicy
144 from temporalio.exceptions import WorkflowAlreadyStartedError
145 
146 try:
147 await client.start_workflow(
148 DispatchWorkflow.run,
149 DispatchParams(message=inbound, allowlist=allowlist),
150 id=f"dispatch-{inbound.message_id}",
151 task_queue=task_queue,
152 id_reuse_policy=WorkflowIDReusePolicy.REJECT_DUPLICATE,
153 )
154 except WorkflowAlreadyStartedError:
155 return False
156 return True
157 
158 
159def _allowlist_from_env() -> frozenset[str]:
160 from ynab_agent.settings import Settings
161 
162 return frozenset(address.lower() for address in Settings().owners)
163 
164 
165async def _connect() -> Client:
166 from temporalio.client import Client
167 from temporalio.contrib.pydantic import pydantic_data_converter
168 
169 from ynab_agent.telemetry import metrics_runtime, tracing_interceptors
170 
171 return await Client.connect(
172 os.environ.get("TEMPORAL_HOST", "localhost:7233"),
173 namespace=os.environ.get("TEMPORAL_NAMESPACE", "default"),
174 data_converter=pydantic_data_converter,
175 interceptors=tracing_interceptors(),
176 runtime=metrics_runtime(),
177 )
178 
179 
180def create_app(
181 *,
182 webhook_secret: str | None = None,
183 allowlist: frozenset[str] | None = None,
184 task_queue: str | None = None,
185) -> FastAPI:
186 """Build the webhook app.
187 
188 Production reads its config from the environment; tests pass it in and set
189 ``app.state.temporal`` before startup.
190 """
191 secret = (
192 webhook_secret
193 if webhook_secret is not None
194 else os.environ.get(WEBHOOK_SECRET_ENV)
195 )
196 queue = task_queue or os.environ.get("TEMPORAL_TASK_QUEUE", "ynab-agent")
197 allow = allowlist if allowlist is not None else _allowlist_from_env()
198 
199 @asynccontextmanager
200 async def lifespan(app: FastAPI) -> AsyncIterator[None]:
201 if not hasattr(app.state, "temporal"):
202 app.state.temporal = await _connect()
203 yield
204 # uvicorn fires this on SIGTERM; flush buffered spans before exit.
205 from ynab_agent.telemetry import shutdown_tracing
206 
207 shutdown_tracing()
208 
209 app = FastAPI(lifespan=lifespan, title="ynab-agent webhook")
210 
211 from ynab_agent.telemetry import instrument_fastapi, setup_tracing
212 
213 setup_tracing("ynab-agent-webhook")
214 instrument_fastapi(app)
215 
216 @app.get("/healthz")
217 async def healthz() -> dict[str, str]:
218 return {"status": "ok"}
219 
220 @app.post("/webhooks/agentmail")
221 async def agentmail_webhook(request: Request) -> dict[str, str]:
222 if not secret:
223 raise HTTPException(
224 status_code=500, detail="webhook secret not configured"
225 )
226 body = await request.body()
227 from svix.webhooks import WebhookVerificationError
228 
229 try:
230 payload = verify(body, request.headers, secret)
231 except WebhookVerificationError as err:
232 raise HTTPException(
233 status_code=401, detail="invalid signature"
234 ) from err
235 
236 event = _WebhookPayload.model_validate(payload)
237 if event.event_type != _RECEIVED or event.message is None:
238 return {"status": "ignored"}
239 
240 inbound = to_inbound(event.message, verified=True)
241 started = await start_dispatch(
242 request.app.state.temporal,
243 inbound,
244 allowlist=allow,
245 task_queue=queue,
246 )
247 return {"status": "dispatched" if started else "duplicate"}
248 
249 return app

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
src/ynab_agent/learn/registry.py221 lines · Python
⋯ 137 lines hidden (lines 1–137)
1"""The durable rule registry's pure state and folds (SPEC §14, W5).
2 
3The registry is the agent's *only* persistent learning memory: the rule table
4that earns autonomy. Per the derived-state rule (SPEC §0.5) it lives as Temporal
5workflow state — never an external store — and everything else (what a payee
6*usually* maps to, how consistent it has been) is derived from YNAB's canonical
7history on demand, not duplicated here.
8 
9This module is the pure core the durable :class:`RuleRegistryWorkflow` wraps
10(mirroring how ``state_machine`` sits under ``txn_workflow``): given the current
11:class:`RegistryState` and one learning input, return the next state. It folds
12in the already-pure transition logic (``plan_rule_update`` / ``apply_learning``)
13and keeps a bounded audit tail so "why did it learn that?" is answerable without
14an external log.
15 
16Autonomy is opt-in (SPEC §14): a *learned* rule that reaches ``trusted`` by
17consistency is only **eligible** for auto-apply — :func:`eligible_for_bless`
18surfaces it for the one-time owner prompt — and the gate auto-applies it only
19once the owner blesses it (``source=human_explicit``). Nothing here grants
20autonomy on silence.
21"""
22 
23from __future__ import annotations
24 
25import datetime
26from typing import TYPE_CHECKING
27 
28from pydantic import Field
29 
30from ynab_agent.domain.base import Frozen
31from ynab_agent.domain.enums import RuleSource, TrustState
32from ynab_agent.domain.rule import Rule
33from ynab_agent.learn.handler import plan_rule_update
34from ynab_agent.learn.transitions import (
35 RuleChange,
36 RuleChangeKind,
37 apply_learning,
39 
40if TYPE_CHECKING:
41 from ynab_agent.domain.effects import FeedRuleLearning
42 from ynab_agent.domain.ids import RuleId
43 from ynab_agent.learn.events import ExplicitCommand
44 
45# How many recent rule-table changes the registry keeps for the in-band audit
46# (SPEC §9's interim log until the externalized audit subsystem lands). The tail
47# is bounded so continued learning never grows the carried state without limit.
48AUDIT_CAP = 200
49 
50 
51class RegistryAuditEntry(Frozen):
52 """One rule-table change, time-stamped and keyed to its payee (SPEC §9)."""
53 
54 at: datetime.datetime
55 payee: str
56 change: RuleChange
57 
58 
59class RegistryState(Frozen):
60 """The learned rule table plus a bounded change history.
61 
62 The whole of the agent's persistent learning memory. Held as Temporal
63 workflow state and carried across continue-as-new, so it is a frozen value
64 like the rest of the domain.
65 """
66 
67 rules: tuple[Rule, ...] = ()
68 audit: tuple[RegistryAuditEntry, ...] = Field(default=())
69 
70 
71def _appended_audit(
72 state: RegistryState, entry: RegistryAuditEntry
73) -> tuple[RegistryAuditEntry, ...]:
74 """The audit tail with ``entry`` appended, capped to :data:`AUDIT_CAP`."""
75 return (*state.audit, entry)[-AUDIT_CAP:]
76 
77 
78def record_learning(
79 state: RegistryState,
80 feed: FeedRuleLearning,
81 *,
82 now: datetime.datetime,
83 next_id: RuleId,
84) -> RegistryState:
85 """Fold a W2 confirm/correct effect into the registry (SPEC §9). Pure.
86 
87 Returns the state unchanged when there is nothing learnable (no decision,
88 or a split whose share template is the agent's call — ``plan_rule_update``).
89 """
90 outcome = plan_rule_update(state.rules, feed, now=now, next_id=next_id)
91 if outcome is None:
92 return state
93 entry = RegistryAuditEntry(at=now, payee=feed.payee, change=outcome.change)
94 return state.model_copy(
95 update={"rules": outcome.rules, "audit": _appended_audit(state, entry)}
96 )
97 
98 
99def bless_rule(
100 state: RegistryState,
101 command: ExplicitCommand,
102 *,
103 now: datetime.datetime,
104 next_id: RuleId,
105) -> RegistryState:
106 """Bless a ``(match, action)`` straight to ``trusted`` (SPEC §14). Pure.
107 
108 The owner's opt-in: an eligible learned rule becomes auto-applicable, or an
109 explicit standing command ("always categorize X as Y") seeds one already
110 trusted. Either way ``source`` becomes ``human_explicit`` — granted, not
111 earned — which is what the gate requires before it acts alone.
112 """
113 outcome = apply_learning(state.rules, command, now=now, next_id=next_id)
114 entry = RegistryAuditEntry(
115 at=now, payee=command.match.payee_pattern, change=outcome.change
116 )
117 return state.model_copy(
118 update={"rules": outcome.rules, "audit": _appended_audit(state, entry)}
119 )
120 
121 
122def eligible_for_bless(state: RegistryState) -> tuple[Rule, ...]:
123 """Learned rules that reached ``trusted`` by consistency but aren't blessed.
124 
125 These are the on-ramp surface (SPEC §14): a payee the owner has confirmed
126 enough times that the agent *could* take it over, awaiting the one-time
127 "want me to auto-handle this from now on?" opt-in. A rule already blessed
128 (``human_explicit``) is excluded — it is past eligibility, already trusted.
129 """
130 return tuple(
131 rule
132 for rule in state.rules
133 if rule.trust is TrustState.TRUSTED
134 and rule.source is RuleSource.LEARNED
135 )
136 
137 
138def pending_offers(state: RegistryState) -> tuple[Rule, ...]:
139 """Eligible rules whose one-time autonomy offer has not been sent yet (3b).
140 
141 The registry workflow drives off this: a rule that has just become eligible
142 and was never offered is volunteered to the owner exactly once. Once the
143 offer goes out, :func:`mark_offered` stamps ``offered_at`` and it drops off
144 this list, so a later confirmation of the same payee never re-asks.
145 """
146 return tuple(
147 rule for rule in eligible_for_bless(state) if rule.offered_at is None
148 )
149 
150 
151def mark_offered(
152 state: RegistryState, rule_id: RuleId, *, now: datetime.datetime
153) -> RegistryState:
154 """Record that the autonomy offer was sent for ``rule_id`` (SPEC §14.7 3b).
155 
156 Idempotent: stamping a rule already marked (or one no longer present) leaves
157 the table unchanged. This is the one-time guard — combined with the offer
158 workflow's id-reuse rejection — that the proactive prompt is sent only once.
159 """
160 updated = tuple(
161 rule.model_copy(update={"offered_at": now})
162 if rule.id == rule_id and rule.offered_at is None
163 else rule
164 for rule in state.rules
165 )
166 if updated == state.rules:
167 return state
168 return state.model_copy(update={"rules": updated})
169 
170 
171def bless_by_id(
172 state: RegistryState, rule_id: RuleId, *, now: datetime.datetime
173) -> RegistryState:
174 """Bless an existing eligible rule by id — owner accepted the offer (3b).
175 
176 Flips that specific learned rule to ``trusted``/``human_explicit`` (the
177 grant the gate requires). Unlike :func:`bless_rule`, it never creates a
178 rule: if the rule is gone or was corrected away from eligibility in the
179 meantime, this is a no-op, so accepting a stale offer can never resurrect a
180 superseded action or duplicate the payee.
181 """
182 target = next((r for r in state.rules if r.id == rule_id), None)
183 if (
184 target is None
185 or target.trust is not TrustState.TRUSTED
186 or target.source is not RuleSource.LEARNED
187 ):
188 return state
189 updated = target.model_copy(
190 update={
191 "source": RuleSource.HUMAN_EXPLICIT,
192 "last_confirmed_at": now,
193 }
194 )
195 new_rules = tuple(
196 updated if rule.id == rule_id else rule for rule in state.rules
197 )
198 entry = RegistryAuditEntry(
199 at=now,
200 payee=target.match.payee_pattern,
201 change=RuleChange(
202 kind=RuleChangeKind.BLESSED, rule_id=rule_id, trust=updated.trust
203 ),
204 )
205 return state.model_copy(
206 update={"rules": new_rules, "audit": _appended_audit(state, entry)}
207 )
⋯ 14 lines hidden (lines 208–221)
208 
209 
210def rules_for_payee(state: RegistryState, payee: str) -> tuple[Rule, ...]:
211 """The rules whose payee pattern matches ``payee`` (case-insensitive).
212 
213 The gate's load path: it asks the registry for the candidate rules on a
214 transaction's payee, then decides auto-vs-ask over just those.
215 """
216 lowered = payee.lower()
217 return tuple(
218 rule
219 for rule in state.rules
220 if rule.match.payee_pattern.lower() in lowered
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
src/ynab_agent/workflow/registry_workflow.py164 lines · Python
⋯ 59 lines hidden (lines 1–59)
1"""W5 · the durable rule registry — the agent's persistent learning memory.
2 
3A singleton, long-lived workflow (id :data:`REGISTRY_WORKFLOW_ID`) holding the
4learned rule table as Temporal state (SPEC §14, §0.5 derived-state). It is born
5on the first learning signal — the ``feed_rule_learning`` activity does a
6signal-with-start — and lives forever, continuing-as-new to keep its history
7bounded while carrying the rule table forward.
8 
9The workflow is a thin durable shell over the pure
10:mod:`ynab_agent.learn.registry` folds (the same pattern ``txn_workflow`` has
11over ``state_machine``): signals fold confirm/correct/bless inputs into the
12state; queries read it back for the autonomy gate and the on-ramp prompt. All
13clock and id reads go through ``workflow.*`` so replay stays deterministic.
14"""
15 
16from __future__ import annotations
17 
18import contextlib
19 
20from temporalio import workflow
21from temporalio.exceptions import ActivityError
22 
23from ynab_agent.workflow.registry_types import RegistryParams, RegistryView
24 
25with workflow.unsafe.imports_passed_through():
26 from ynab_agent.domain.effects import FeedRuleLearning
27 from ynab_agent.domain.ids import RuleId
28 from ynab_agent.domain.rule import Rule
29 from ynab_agent.learn.events import ExplicitCommand
30 from ynab_agent.learn.registry import (
31 RegistryState,
32 bless_by_id,
33 bless_rule,
34 eligible_for_bless,
35 mark_offered,
36 pending_offers,
37 record_learning,
38 rules_for_payee,
39 )
40 from ynab_agent.workflow import alert_activities, offer_activities
41 from ynab_agent.workflow.alerting import build_failure_alert
42 from ynab_agent.workflow.constants import (
43 ACTIVITY_RETRY,
44 ACTIVITY_TIMEOUT,
45 ALERT_BUDGET,
46 ALERT_RETRY,
47 ALERT_TIMEOUT,
48 )
49 
50 
51@workflow.defn
52class RuleRegistryWorkflow:
53 """The household's one durable rule table (one fold per learning signal)."""
54 
55 def __init__(self) -> None:
56 """Start empty; the run method adopts any carried-forward state."""
57 self._state = RegistryState()
58 
59 @workflow.run
60 async def run(self, params: RegistryParams) -> None:
61 """Hold the rule table; volunteer offers; roll when history grows.
62 
63 A thin effect-dispatcher (mirroring the W2 driver): each wake, if a rule
64 has newly become eligible and was never offered, start its one-time
65 autonomy offer (SPEC §14.7 3b) and mark it offered; otherwise, once the
66 offer queue is drained and history has grown, continue-as-new carrying
67 the table forward. Draining before the roll mirrors W2 draining its
68 inbound before continue-as-new.
69 """
70 self._state = params.state
71 while True:
72 await workflow.wait_condition(
73 lambda: (
74 bool(pending_offers(self._state))
75 or workflow.info().is_continue_as_new_suggested()
76 )
77 )
78 pending = pending_offers(self._state)
79 if pending:
80 await self._offer(pending[0])
81 continue
82 workflow.continue_as_new(RegistryParams(state=self._state))
83 
84 async def _offer(self, rule: Rule) -> None:
85 """Start a rule's one-time offer, then mark it offered (SPEC §14.7 3b).
86 
87 The singleton must never die or busy-loop on a failed offer (the gate
88 reads its rule table), so a terminal failure is alerted best-effort and
89 the rule is marked offered regardless — we move on rather than retry the
90 same prompt forever. The id-reuse rejection in ``start_autonomy_offer``
91 keeps it one-time even across replay.
92 """
93 try:
94 await workflow.execute_activity(
95 offer_activities.start_autonomy_offer,
96 rule,
97 start_to_close_timeout=ACTIVITY_TIMEOUT,
98 retry_policy=ACTIVITY_RETRY,
99 )
100 except ActivityError as exc:
101 with contextlib.suppress(ActivityError):
102 await workflow.execute_activity(
103 alert_activities.alert_failure,
104 build_failure_alert(
105 key=f"offer-start-{rule.id}",
106 context=(
107 f"starting autonomy offer for "
108 f"{rule.match.payee_pattern}"
109 ),
110 exc=exc,
111 ),
112 start_to_close_timeout=ALERT_TIMEOUT,
113 schedule_to_close_timeout=ALERT_BUDGET,
114 retry_policy=ALERT_RETRY,
115 )
116 self._state = mark_offered(self._state, rule.id, now=workflow.now())
⋯ 19 lines hidden (lines 117–135)
117 
118 def _next_id(self) -> RuleId:
119 """A fresh rule id, minted deterministically for replay safety."""
120 return RuleId(str(workflow.uuid4()))
121 
122 @workflow.signal
123 def record(self, feed: FeedRuleLearning) -> None:
124 """Fold a W2 confirm/correct effect into the rule table (SPEC §9)."""
125 self._state = record_learning(
126 self._state, feed, now=workflow.now(), next_id=self._next_id()
127 )
128 
129 @workflow.signal
130 def bless(self, command: ExplicitCommand) -> None:
131 """Grant a rule autonomy — the owner's opt-in or standing command."""
132 self._state = bless_rule(
133 self._state, command, now=workflow.now(), next_id=self._next_id()
134 )
135 
136 @workflow.signal
137 def bless_existing(self, rule_id: str) -> None:
138 """Bless an eligible rule by id — the owner accepted its offer (3b).
139 
140 Distinct from :meth:`bless`: it grants autonomy to *exactly* the offered
141 rule (no match/action reconstruction), and no-ops if that rule is gone
142 or no longer eligible, so a stale acceptance can never resurrect it.
143 """
144 self._state = bless_by_id(
145 self._state, RuleId(rule_id), now=workflow.now()
146 )
⋯ 18 lines hidden (lines 147–164)
147 
148 @workflow.query
149 def rules(self) -> tuple[Rule, ...]:
150 """The whole rule table (for tests and operators)."""
151 return self._state.rules
152 
153 @workflow.query
154 def view(self) -> RegistryView:
155 """The rule table plus the rules eligible for an opt-in bless."""
156 return RegistryView(
157 rules=self._state.rules,
158 eligible=eligible_for_bless(self._state),
159 )
160 
161 @workflow.query
162 def payee_rules(self, payee: str) -> tuple[Rule, ...]:
163 """The candidate rules for one payee — the gate's load path."""
164 return rules_for_payee(self._state, payee)

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
src/ynab_agent/workflow/offer_workflow.py125 lines · Python
⋯ 50 lines hidden (lines 1–50)
1"""The autonomy-offer workflow — the proactive eligibility prompt (§14.7 3b).
2 
3One short-lived workflow per eligible rule (id ``autonomy-offer-{rule_id}``,
4started ``REJECT_DUPLICATE`` so the prompt is one-time). It opens its own email
5thread asking "want me to auto-handle *Payee*?", stamps an ``OfferThreadId``
6search attribute so W3 routes the reply back here (a bless-acceptance, never a
7category reply), and waits for an answer. A yes blesses the rule and confirms; a
8no sends a brief note; an unclear reply keeps waiting; silence past the patience
9window simply ends the offer (the owner can still bless later via a command).
10 
11The seam mirrors the W2 driver: ``workflow.*`` for all clock reads, side effects
12behind activities, pure :class:`~ynab_agent.domain.enums.OfferVerdict` for the
13branch — so the model/mail stacks never enter this sandbox.
14"""
15 
16from __future__ import annotations
17 
18from collections import deque
19from datetime import timedelta
20 
21from temporalio import workflow
22from temporalio.common import SearchAttributeKey
23from temporalio.exceptions import ActivityError
24 
25from ynab_agent.workflow.offer_types import OFFER_PATIENCE, OFFER_THREAD_ID
26 
27_OFFER_THREAD_ID = SearchAttributeKey.for_keyword(OFFER_THREAD_ID)
28 
29with workflow.unsafe.imports_passed_through():
30 from ynab_agent.dispatch.classify import InboundMessage
31 from ynab_agent.domain.enums import OfferVerdict
32 from ynab_agent.workflow import alert_activities, offer_activities
33 from ynab_agent.workflow.alerting import build_failure_alert
34 from ynab_agent.workflow.constants import (
35 ACTIVITY_RETRY,
36 ACTIVITY_TIMEOUT,
37 ALERT_BUDGET,
38 ALERT_RETRY,
39 ALERT_TIMEOUT,
40 )
41 from ynab_agent.workflow.offer_types import OfferParams
42 
43 
44@workflow.defn
45class AutonomyOfferWorkflow:
46 """One eligible rule's one-time "may I auto-handle this?" offer."""
47 
48 def __init__(self) -> None:
49 """Start with no reply buffered; ``run`` opens the thread and waits."""
50 self._responses: deque[InboundMessage] = deque()
51 
52 @workflow.signal
53 def submit_response(self, message: InboundMessage) -> None:
54 """The owner replied to the offer (delivered by W3)."""
55 self._responses.append(message)
56 
57 @workflow.run
58 async def run(self, params: OfferParams) -> None:
59 """Make the offer and act on the reply, paging on terminal failure."""
60 try:
61 await self._run(params)
62 except ActivityError as exc:
63 payee = params.rule.match.payee_pattern
64 await workflow.execute_activity(
65 alert_activities.alert_failure,
66 build_failure_alert(
67 key=f"offer-{params.rule.id}",
68 context=f"autonomy offer for {payee}",
69 exc=exc,
70 ),
71 start_to_close_timeout=ALERT_TIMEOUT,
72 schedule_to_close_timeout=ALERT_BUDGET,
73 retry_policy=ALERT_RETRY,
74 )
75 raise
76 
77 async def _run(self, params: OfferParams) -> None:
78 rule = params.rule
79 thread_id = await workflow.execute_activity(
80 offer_activities.open_offer_thread,
81 rule,
82 start_to_close_timeout=ACTIVITY_TIMEOUT,
83 retry_policy=ACTIVITY_RETRY,
84 )
85 # Index this workflow by its thread so a reply routes here (§5a-style).
86 workflow.upsert_search_attributes(
87 [_OFFER_THREAD_ID.value_set(thread_id)]
88 )
89 
90 deadline = workflow.now() + OFFER_PATIENCE
91 while True:
92 timeout = deadline - workflow.now()
93 if timeout < timedelta(0):
94 timeout = timedelta(0)
95 try:
96 await workflow.wait_condition(
97 lambda: len(self._responses) > 0, timeout=timeout
98 )
99 except TimeoutError:
100 # No clear answer in the window; leave the rule eligible, end.
101 return
102 message = self._responses.popleft()
103 verdict = await workflow.execute_activity(
104 offer_activities.interpret_offer_reply,
105 args=[message.body, rule.match.payee_pattern],
106 start_to_close_timeout=ACTIVITY_TIMEOUT,
107 retry_policy=ACTIVITY_RETRY,
108 )
109 if verdict is OfferVerdict.ACCEPT:
110 await workflow.execute_activity(
111 offer_activities.accept_offer,
112 args=[rule, thread_id],
113 start_to_close_timeout=ACTIVITY_TIMEOUT,
114 retry_policy=ACTIVITY_RETRY,
115 )
116 return
117 if verdict is OfferVerdict.DECLINE:
118 await workflow.execute_activity(
119 offer_activities.decline_offer,
120 args=[rule, thread_id],
121 start_to_close_timeout=ACTIVITY_TIMEOUT,
122 retry_policy=ACTIVITY_RETRY,
123 )
124 return
⋯ 1 line hidden (lines 125–125)
125 # UNCLEAR: keep waiting for a clearer reply until the deadline.

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
src/ynab_agent/dispatch/classify.py138 lines · Python
⋯ 71 lines hidden (lines 1–71)
1"""W3 inbound classification: the deterministic routing decision (SPEC §5).
2 
3Every inbound AgentMail message is classified and routed. The parts that are
4*deterministic* live here and run first — the inbound boundary (§0.6): trust the
5signed webhook for provenance, act only on allow-listed senders, and never treat
6an autoresponder or bounce as a confirmation. A verified, allow-listed message
7on a known transaction thread routes straight to that W2; anything else without
8a thread is handed to the agentic classifier (receipt vs. command).
9"""
10 
11from __future__ import annotations
12 
13from email.utils import parseaddr
14from enum import StrEnum
15from typing import Annotated, Literal
16 
17from pydantic import Field
18 
19from ynab_agent.domain.base import Frozen
20from ynab_agent.domain.ids import MessageId, ThreadId, YnabTransactionId
21 
22# Local-part prefixes that mark a bounce/system message — never a real reply.
23_SYSTEM_SENDERS = ("mailer-daemon@", "postmaster@")
24 
25 
26class InboundMessage(Frozen):
27 """A normalized inbound email from the AgentMail webhook.
28 
29 ``signature_verified`` is the Svix webhook provenance check, and
30 ``is_auto_reply`` is derived from the headers (auto-submitted / vacation);
31 both are computed by the webhook handler before classification.
32 """
33 
34 message_id: MessageId
35 from_address: str
36 subject: str
37 body: str
38 thread_id: ThreadId | None = None
39 signature_verified: bool = False
40 is_auto_reply: bool = False
41 
42 
43class InboundKind(StrEnum):
44 """The agentic classifier's verdict for a non-thread message (SPEC §5)."""
45 
46 RECEIPT = "receipt"
47 COMMAND = "command"
48 NOISE = "noise"
49 
50 
51class Quarantine(Frozen):
52 """The message failed the inbound boundary; hold it, do not act."""
53 
54 kind: Literal["quarantine"] = "quarantine"
55 reason: str
56 
57 
58class Ignore(Frozen):
59 """A non-actionable autoresponder/bounce; drop it silently."""
60 
61 kind: Literal["ignore"] = "ignore"
62 reason: str
63 
64 
65class RouteToTransaction(Frozen):
66 """A reply on a known transaction thread; signal that W2 (SPEC §5a)."""
67 
68 kind: Literal["transaction"] = "transaction"
69 txn_id: YnabTransactionId
70 
71 
72class RouteToOffer(Frozen):
73 """A reply on an autonomy-offer thread; signal that offer workflow (3b)."""
74 
75 kind: Literal["offer"] = "offer"
76 offer_id: str
⋯ 53 lines hidden (lines 77–129)
77 
78 
79class RouteToInterpret(Frozen):
80 """No thread: hand to the agentic classifier (receipt vs. command)."""
81 
82 kind: Literal["interpret"] = "interpret"
83 
84 
85DispatchDecision = Annotated[
86 Quarantine | Ignore | RouteToTransaction | RouteToOffer | RouteToInterpret,
87 Field(discriminator="kind"),
89 
90 
91def _sender_address(raw: str) -> str:
92 """The bare, lower-cased email from a possibly display-named ``From``.
93 
94 AgentMail surfaces the raw ``From`` header, which a mail client may send as
95 ``"Real Name <addr@host>"`` rather than a bare address. The owner allow-list
96 holds bare addresses, so the display-name form must be reduced to its
97 address before the membership (and system-sender) checks — otherwise a
98 legitimate owner reply is quarantined as "sender not allow-listed".
99 ``parseaddr`` returns the address unchanged when there is no display name;
100 on a parse miss we fall back to the raw value so the allow-list never
101 silently widens.
102 """
103 return (parseaddr(raw)[1] or raw).lower()
104 
105 
106def classify(
107 message: InboundMessage,
108 allowlist: frozenset[str],
109 *,
110 txn_id: YnabTransactionId | None,
111 offer_id: str | None = None,
112) -> DispatchDecision:
113 """Decide how to route an inbound message. Pure (SPEC §5, §0.6).
114 
115 Args:
116 message: The normalized inbound message.
117 allowlist: Lower-cased sender addresses permitted to act.
118 txn_id: The transaction this thread maps to, or ``None`` if the message
119 is not on a known transaction thread.
120 offer_id: The autonomy-offer workflow this thread maps to, or ``None``.
121 A transaction thread takes precedence (a message is on at most one).
122 
123 Returns:
124 The routing decision; ``RouteToInterpret`` defers receipt-vs-command to
125 the agentic classifier.
126 """
127 if not message.signature_verified:
128 return Quarantine(reason="unsigned webhook")
129 sender = _sender_address(message.from_address)
130 if message.is_auto_reply or sender.startswith(_SYSTEM_SENDERS):
131 return Ignore(reason="autoresponder or bounce")
132 if sender not in allowlist:
133 return Quarantine(reason="sender not allow-listed")
134 if txn_id is not None:
135 return RouteToTransaction(txn_id=txn_id)
136 if offer_id is not None:
137 return RouteToOffer(offer_id=offer_id)
138 return RouteToInterpret()

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
src/ynab_agent/workflow/dispatch_activities.py192 lines · Python
⋯ 43 lines hidden (lines 1–43)
1"""The I/O ports of the W3 inbound dispatcher, as Temporal activities.
2 
3Kept separate from the other workflows' activity modules so each workflow's
4sandbox import graph stays minimal (see ``poll_activities``). Heavy clients
5(Temporal, the model stack) are imported lazily inside the bodies so they never
6enter the workflow sandbox.
7"""
8 
9from __future__ import annotations
10 
11import contextlib
12 
13from temporalio import activity
14 
15from ynab_agent.dispatch.classify import InboundKind, InboundMessage
16from ynab_agent.workflow.temporal_client import client, task_queue
17 
18 
19@activity.defn
20async def resolve_thread(thread_id: str | None) -> str | None:
21 """Resolve an AgentMail thread id to its txn id, or None (SPEC §5).
22 
23 The per-transaction workflow stamps its AgentMail thread id into the
24 ``TxnThreadId`` search attribute, so a reply's thread maps back to that
25 workflow through a Temporal visibility query. The workflow id *is* the YNAB
26 transaction id (started ``REJECT_DUPLICATE`` on it), so the matching
27 execution's id is the answer — there is no stored thread↔txn table (SPEC
28 §0.5, store-free). ``None`` when the thread belongs to no live transaction.
29 """
30 if thread_id is None:
31 return None
32 temporal = await client()
33 # The thread id is an AgentMail token, but quote-escape defensively so the
34 # visibility query stays well-formed.
35 safe = thread_id.replace('"', '\\"')
36 async for execution in temporal.list_workflows(
37 query=f'TxnThreadId = "{safe}"'
38 ):
39 return execution.id
40 return None
41 
42 
43@activity.defn
44async def resolve_offer_thread(thread_id: str | None) -> str | None:
45 """Resolve an AgentMail thread to a *running* offer workflow id (3b).
46 
47 The autonomy-offer workflow stamps its thread into the ``OfferThreadId``
48 search attribute, so a reply on that thread maps back to it the same way
49 ``resolve_thread`` maps a transaction. Filtered to ``Running`` executions so
50 a reply landing after the offer has closed is *not* routed (it falls through
51 to the command path, the documented late-accept fallback) rather than
52 resurrecting a finished offer. ``None`` when no live offer owns the thread.
53 """
54 if thread_id is None:
55 return None
56 from ynab_agent.workflow.offer_types import OFFER_THREAD_ID
57 
58 temporal = await client()
59 safe = thread_id.replace('"', '\\"')
60 query = f'{OFFER_THREAD_ID} = "{safe}" AND ExecutionStatus = "Running"'
61 async for execution in temporal.list_workflows(query=query):
62 return execution.id
63 return None
64 
65 
66@activity.defn
67async def signal_offer(offer_id: str, message: InboundMessage) -> None:
68 """Deliver a reply to its autonomy-offer workflow (SPEC §14.7 3b).
69 
70 A plain signal (not signal-with-start): the offer must be live to receive it
71 — ``resolve_offer_thread`` already filtered to running executions — so a
72 closed offer is never resurrected. A missing handle is a benign no-op.
73 """
74 from temporalio.service import RPCError
75 
76 temporal = await client()
77 handle = temporal.get_workflow_handle(offer_id)
78 with contextlib.suppress(RPCError):
79 await handle.signal("submit_response", message)
80 
⋯ 112 lines hidden (lines 81–192)
81 
82@activity.defn
83async def classify_inbound(message: InboundMessage) -> InboundKind:
84 """Agentically classify a non-thread message: receipt, command, or noise.
85 
86 The model stack is imported lazily here so it never enters the workflow
87 sandbox. The model only labels the message; the deterministic dispatcher
88 routes it (SPEC §5).
89 """
90 from ynab_agent.agentic.classify import classify_inbound as run_classifier
91 from ynab_agent.agentic.classify import to_kind
92 
93 return to_kind(await run_classifier(message))
94 
95 
96@activity.defn
97async def signal_transaction(txn_id: str, message: InboundMessage) -> None:
98 """Deliver a reply to the transaction's W2 (SPEC §5a).
99 
100 Signal-with-start on ``submit_inbound``: a running W2 (the common case — it
101 asked the question) receives the reply and wakes; if the transaction's run
102 has closed, a fresh W2 starts with the reply buffered and re-triages. The
103 workflow id is the YNAB transaction id, so no thread↔txn table is needed.
104 """
105 from ynab_agent.domain.ids import YnabTransactionId
106 from ynab_agent.domain.signals import ReplySignal
107 from ynab_agent.workflow.types import TransactionParams
108 
109 if message.thread_id is None:
110 # RouteToTransaction only fires for a thread-matched message, so a
111 # missing thread id here is a routing bug, not an expected input.
112 msg = f"signal for {txn_id} has no thread id"
113 raise RuntimeError(msg)
114 reply = ReplySignal(
115 thread_id=message.thread_id,
116 message_id=message.message_id,
117 from_address=message.from_address,
118 text=message.body,
119 )
120 temporal = await client()
121 await temporal.start_workflow(
122 "TransactionWorkflow",
123 TransactionParams(ynab_id=YnabTransactionId(txn_id)),
124 id=txn_id,
125 task_queue=task_queue(),
126 start_signal="submit_inbound",
127 start_signal_args=[reply],
128 )
129 
130 
131@activity.defn
132async def route_receipt(_message: InboundMessage) -> None:
133 """Hand a forwarded receipt to the W4 join (SPEC §5b).
134 
135 No-op in v1: the receipt join (W4) is out of the core-triage scope, and the
136 agent does not advertise receipt forwarding, so receipts do not arrive yet.
137 Wired when W4 lands (the message arg is kept for that contract).
138 """
139 return None
140 
141 
142@activity.defn
143async def handle_command(message: InboundMessage) -> None:
144 """Parse a standing command and bless the rule it grants (SPEC §5c, §14).
145 
146 The owner's direct opt-in: "always categorize X as Y" becomes an
147 ``ExplicitCommand`` signalled to the durable registry's ``bless``, which
148 trusts the rule for auto-apply (``source=human_explicit``). Anything not a
149 clear bless — a question or comment — is a deliberate no-op (the parser
150 declines it), so a stray message never grants autonomy.
151 """
152 import asyncio
153 
154 from temporalio.common import WorkflowIDConflictPolicy
155 
156 from ynab_agent.agentic.command import (
157 CommandRequest,
158 parse_command,
159 to_explicit_command,
160 )
161 from ynab_agent.agentic.enrich import CandidateCategory
162 from ynab_agent.workflow.registry_types import (
163 REGISTRY_WORKFLOW_ID,
164 RegistryParams,
165 )
166 from ynab_agent.workflow.temporal_client import client, task_queue
167 from ynab_agent.ynab.client import YnabClient
168 
169 ynab = YnabClient.from_env()
170 spends = await asyncio.to_thread(ynab.category_spends)
171 candidates = tuple(
172 CandidateCategory(id=str(spend.category), name=spend.name)
173 for spend in spends
174 )
175 if not candidates:
176 return
177 reading = await parse_command(
178 CommandRequest(command_text=message.body, candidates=candidates)
179 )
180 command = to_explicit_command(reading)
181 if command is None:
182 return
183 temporal = await client()
184 await temporal.start_workflow(
185 "RuleRegistryWorkflow",
186 RegistryParams(),
187 id=REGISTRY_WORKFLOW_ID,
188 task_queue=task_queue(),
189 id_conflict_policy=WorkflowIDConflictPolicy.USE_EXISTING,
190 start_signal="bless",
191 start_signal_args=[command],
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
src/ynab_agent/agentic/enrich.py277 lines · Python
⋯ 160 lines hidden (lines 1–160)
1"""The enrichment agent: propose a category for a transaction (SPEC §4.1).
2 
3The agentic half of the ``enrich`` step. Given a transaction's facts and the
4candidate budget categories, a Pydantic AI agent picks the best category, rates
5its confidence, and explains why. The deterministic gate (``policy.gate``) then
6decides auto-apply vs. ask — confidence here is *framing only*, never a gate
7(principle 6). :func:`to_proposal` maps the agent's structured output onto the
8domain :class:`~ynab_agent.domain.proposal.Proposal`.
9 
10The model is injected per run so tests drive a ``TestModel``/``FunctionModel``
11offline; production uses :func:`~ynab_agent.agentic.model.build_model` (Ollama).
12"""
13 
14from __future__ import annotations
15 
16from typing import TYPE_CHECKING
17 
18from pydantic import Field
19from pydantic_ai import Agent
20 
21from ynab_agent.agentic.model import run_structured
22from ynab_agent.domain.allocations import ProposedCategory
23from ynab_agent.domain.base import Frozen
24from ynab_agent.domain.enums import Confidence, ReviewVerdict, SourceKind
25from ynab_agent.domain.events import AskHuman, AutoApply
26from ynab_agent.domain.ids import CategoryId
27from ynab_agent.domain.proposal import Proposal, ProposalSource
28from ynab_agent.policy.gate import (
29 GateVerdict,
30 build_auto_decision,
31 evaluate_gate,
33 
34if TYPE_CHECKING:
35 import datetime
36 from collections.abc import Iterable
37 
38 from pydantic_ai.models import Model
39 
40 from ynab_agent.domain.events import EnrichmentOutcome
41 from ynab_agent.domain.rule import Rule
42 from ynab_agent.domain.transaction import YnabSnapshot
43 from ynab_agent.policy.floor import AutoActionCounters
44 
45 
46class CandidateCategory(Frozen):
47 """One budget category the agent may choose from."""
48 
49 id: str
50 name: str
51 
52 
53class EnrichmentRequest(Frozen):
54 """The facts the agent reasons over to propose a category."""
55 
56 payee: str
57 amount_display: str
58 candidates: tuple[CandidateCategory, ...] = Field(min_length=1)
59 memo: str | None = None
60 rule_hint: str | None = None
61 
62 
63class EnrichmentSuggestion(Frozen):
64 """The agent's structured proposal (mapped to a domain Proposal)."""
65 
66 category_id: str
67 confidence: Confidence
68 rationale: str
69 alternatives: tuple[str, ...] = ()
70 
71 
72_SYSTEM_PROMPT = """\
73You categorize a single bank transaction for a personal budget. You are given
74the payee, amount, an optional memo, an optional hint from a learned rule, and a
75list of candidate budget categories (each with an id and a name).
76 
77Choose the SINGLE best category for the transaction. Your `category_id` MUST be
78one of the provided candidate ids — never invent one. Also list up to 2
79`alternatives`: the next-most-likely candidate ids (also from the list, never
80the chosen one) the owner might prefer — or leave empty if none fit. Rate your
81confidence (high / medium / low) and give a one-sentence rationale. Confidence
82is framing only; a human or a trusted rule decides whether to auto-apply."""
83 
84_AGENT: Agent[None, EnrichmentSuggestion] = Agent(
85 output_type=EnrichmentSuggestion,
86 system_prompt=_SYSTEM_PROMPT,
88 
89 
90def _format_request(request: EnrichmentRequest) -> str:
91 """Render the request as the agent's user prompt."""
92 lines = [
93 f"Payee: {request.payee}",
94 f"Amount: {request.amount_display}",
95 ]
96 if request.memo:
97 lines.append(f"Memo: {request.memo}")
98 if request.rule_hint:
99 lines.append(f"Rule hint: {request.rule_hint}")
100 lines.append("Candidate categories:")
101 lines.extend(f" - {c.name} (id: {c.id})" for c in request.candidates)
102 return "\n".join(lines)
103 
104 
105async def propose(
106 request: EnrichmentRequest, *, model: Model | None = None
107) -> EnrichmentSuggestion:
108 """Run the enrichment agent for one transaction (SPEC §4.1).
109 
110 Args:
111 request: The transaction facts and candidate categories.
112 model: A model to use; defaults to the configured Ollama/Gemma.
113 
114 Returns:
115 The agent's structured category suggestion.
116 """
117 return await run_structured(
118 _AGENT,
119 _format_request(request),
120 output_type=EnrichmentSuggestion,
121 model=model,
122 )
123 
124 
125def to_proposal(suggestion: EnrichmentSuggestion) -> Proposal:
126 """Map the agent's suggestion onto a domain Proposal (SPEC §4.1).
127 
128 The model's runner-up ids become the proposal's alternatives (the chosen
129 one filtered out defensively), surfaced in the email so the owner can pick
130 one at a glance.
131 """
132 alternatives = tuple(
133 CategoryId(alt)
134 for alt in suggestion.alternatives
135 if alt and alt != suggestion.category_id
136 )
137 return Proposal(
138 allocation=ProposedCategory(
139 category=CategoryId(suggestion.category_id)
140 ),
141 confidence=suggestion.confidence,
142 rationale=suggestion.rationale,
143 sources=(ProposalSource(kind=SourceKind.MODEL),),
144 alternatives=alternatives,
145 )
146 
147 
148def _blessed_category_id(rule: Rule) -> str | None:
149 """The single category a rule auto-applies, or ``None`` for a split.
150 
151 The safety review compares a single independent category against the rule's;
152 a split action has no single category to judge, so the review is skipped for
153 it (learned-eligible rules are always single-category, so this is rare).
154 """
155 allocation = rule.action.allocation
156 if isinstance(allocation, ProposedCategory):
157 return str(allocation.category)
158 return None
159 
160 
161def review_auto_apply(
162 blessed_category_id: str, suggestion: EnrichmentSuggestion
163) -> ReviewVerdict:
164 """The agent-powered safety review's one-way ratchet (SPEC §0.6 Layer 2).
165 
166 An independent, *clean-context* model categorization (run with no knowledge
167 of the rule's choice, to stay unbiased) judges the impending auto-apply: it
168 ``PROCEED``s only if it considers the blessed category plausible — the
169 category it chose or listed as an alternative — and otherwise
170 ``ESCALATE_TO_HUMAN``. It can only hold an auto-apply back, never grant one,
171 so the deterministic gate still authorizes (principle 6).
172 """
173 plausible = {suggestion.category_id, *suggestion.alternatives}
174 if blessed_category_id in plausible:
175 return ReviewVerdict.PROCEED
176 return ReviewVerdict.ESCALATE_TO_HUMAN
⋯ 65 lines hidden (lines 177–241)
177 
178 
179def _escalation_proposal(
180 suggestion: EnrichmentSuggestion, blessed_category_id: str
181) -> Proposal:
182 """The proposal emailed when the review holds an auto-apply back.
183 
184 Leads with the model's independent pick and offers the usual auto category
185 as an alternative, so the owner sees both views and the disagreement that
186 triggered the question.
187 """
188 base = to_proposal(suggestion)
189 blessed = CategoryId(blessed_category_id)
190 # to_proposal always builds a single-category allocation; narrow for mypy.
191 allocation = base.allocation
192 top = (
193 allocation.category
194 if isinstance(allocation, ProposedCategory)
195 else None
196 )
197 alternatives = base.alternatives
198 if blessed != top and blessed not in alternatives:
199 alternatives = (blessed, *alternatives)
200 return base.model_copy(
201 update={
202 "alternatives": alternatives,
203 "rationale": (
204 "I'd usually auto-file this payee, but this charge looked "
205 "different from the usual — confirming before I apply it."
206 ),
207 }
208 )
209 
210 
211async def decide_enrichment(
212 snapshot: YnabSnapshot,
213 candidates: tuple[CandidateCategory, ...],
214 rules: Iterable[Rule],
215 counters: AutoActionCounters,
216 *,
217 now: datetime.datetime,
218 model: Model | None = None,
219) -> EnrichmentOutcome:
220 """Compose the enrich step: gate, then a safety review (SPEC §4.1, §0.6).
221 
222 The deterministic gate decides autonomy from the rules alone — a single
223 blessed rule may auto-apply *its* action (the model never authorizes a
224 write, principle 6). Before an auto-apply lands, an independent
225 *clean-context* model review judges it (:func:`review_auto_apply`); a
226 disagreement holds it back to ASK (a one-way ratchet — it can only veto). A
227 gated ASK runs the same model to produce the best-guess proposal the email
228 shows.
229 
230 Args:
231 snapshot: The transaction being enriched.
232 candidates: The budget categories the agent may choose from.
233 rules: The rules in scope for the gate.
234 counters: The auto-action counters the hard floor reads.
235 now: The decision timestamp (the activity supplies it).
236 model: A model override for tests; defaults to Ollama/Gemma.
237 
238 Returns:
239 ``AutoApply`` when a blessed rule gates it *and* the review proceeds,
240 else ``AskHuman``.
241 """
242 rules = tuple(rules)
243 gate = evaluate_gate(snapshot, rules, counters)
244 if gate.verdict is GateVerdict.AUTO and gate.rule_id is not None:
245 rule = next((r for r in rules if r.id == gate.rule_id), None)
246 if rule is not None:
247 decision = build_auto_decision(rule, snapshot, now)
248 blessed_category_id = _blessed_category_id(rule)
249 if blessed_category_id is None:
250 return AutoApply(decision=decision)
251 # Clean context: the independent judge never sees the rule's choice.
252 suggestion = await propose(
253 EnrichmentRequest(
254 payee=snapshot.payee,
255 amount_display=str(snapshot.amount),
256 candidates=candidates,
257 ),
258 model=model,
259 )
260 if (
261 review_auto_apply(blessed_category_id, suggestion)
262 is ReviewVerdict.PROCEED
263 ):
264 return AutoApply(decision=decision)
265 return AskHuman(
266 proposal=_escalation_proposal(suggestion, blessed_category_id)
⋯ 11 lines hidden (lines 267–277)
267 )
268 
269 suggestion = await propose(
270 EnrichmentRequest(
271 payee=snapshot.payee,
272 amount_display=str(snapshot.amount),
273 candidates=candidates,
274 ),
275 model=model,
276 )
277 return AskHuman(proposal=to_proposal(suggestion))

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
src/ynab_agent/workflow/txn_workflow.py548 lines · Python
⋯ 461 lines hidden (lines 1–461)
1"""W2 · the Transaction Lifecycle workflow (SPEC §3, §0.5).
2 
3One durable workflow per ``ynab_id``. It is a thin *driver* around the pure
4:func:`~ynab_agent.domain.state_machine.advance` core: each step produces one
5event — from an activity (fetch / enrich / commit+verify / converge) or from a
6signal or an absolute-deadline timer — feeds it to ``advance``, then dispatches
7the emitted effects back out through activities. All nondeterminism lives in
8activities; the workflow uses only pure state and Temporal APIs
9(``workflow.now`` for time), so it replays deterministically. Long-lived
10transactions survive via ``continue-as-new`` from a resting state, carrying
11their state forward.
12 
13Deferred (a cohesive subsystem for its own step, SPEC §3, §9): the externalized,
14append-only **audit log**. ``TxnCore.audit_log_ref`` and the
15``_action_seq`` outbound-dedup key are plumbed here, but no audit entries are
16written yet — Temporal's own event history provides replay-safety in the
17meantime, and ``_action_seq`` is the idempotency key the real send activity will
18use so a retry never double-emails.
19"""
20 
21from __future__ import annotations
22 
23import datetime
24from collections import deque
25from datetime import timedelta
26from typing import TYPE_CHECKING
27 
28from temporalio import workflow
29from temporalio.common import SearchAttributeKey
30from temporalio.exceptions import ActivityError
31 
32if TYPE_CHECKING:
33 from collections.abc import Callable
34 
35 from ynab_agent.domain.proposal import Proposal
36 
37# The reply-routing reverse index: each workflow stamps its AgentMail thread_id
38# here on open_thread, so the dispatcher resolves an inbound reply's thread back
39# to this workflow with a Temporal visibility query (no separate store, §5a).
40# Registered on the namespace by manage/search-attributes.yaml.
41_TXN_THREAD_ID = SearchAttributeKey.for_keyword("TxnThreadId")
42 
43with workflow.unsafe.imports_passed_through():
44 from ynab_agent.domain.config import DEFAULT_POLICY
45 from ynab_agent.domain.effects import (
46 CancelTimer,
47 CloseThread,
48 CommitToYnab,
49 Effect,
50 FeedRuleLearning,
51 OpenThread,
52 ReplayBuffered,
53 SendThreadMessage,
54 SetTimer,
55 TimerKind,
56 )
57 from ynab_agent.domain.enums import DecidedBy
58 from ynab_agent.domain.events import (
59 AnswerReceived,
60 ArchiveWindowReached,
61 ClarifyRequested,
62 Converged,
63 Enriched,
64 HoldDeadlineReached,
65 HoldResolved,
66 InboundReceived,
67 LifecycleEvent,
68 OverrideDetected,
69 PatienceExpired,
70 SnapshotMaterialized,
71 SnapshotUnavailable,
72 WriteVerified,
73 )
74 from ynab_agent.domain.ids import ThreadId, YnabTransactionId
75 from ynab_agent.domain.proposal import Decision
76 from ynab_agent.domain.signals import InboundSignal
77 from ynab_agent.domain.state_machine import advance
78 from ynab_agent.domain.transaction import (
79 Applied,
80 Archived,
81 AutoApplied,
82 AwaitingHuman,
83 Discovered,
84 Enriching,
85 HoldAmazon,
86 Lapsed,
87 Open,
88 Revising,
89 Transaction,
90 YnabSnapshot,
91 born,
92 )
93 from ynab_agent.policy.converge import classify_verify, target_of
94 from ynab_agent.workflow import activities, alert_activities
95 from ynab_agent.workflow.alerting import build_failure_alert
96 from ynab_agent.workflow.constants import (
97 ACTIVITY_RETRY,
98 ACTIVITY_TIMEOUT,
99 ALERT_BUDGET,
100 ALERT_RETRY,
101 ALERT_TIMEOUT,
102 )
103 from ynab_agent.workflow.types import AnswerOutcome, TransactionParams
104 
105# History-length ceiling before a resting workflow continues-as-new. High enough
106# that ordinary flows never trip it; long-lived (30-45 day) transactions do.
107_CONTINUE_AS_NEW_AFTER = 4_000
108 
109# Resting states the workflow may sit in for days while waiting on signals or
110# timers; continue-as-new fires only from one of these. DISCOVERED counts: a
111# transaction born from a signal can wait here for a slow YNAB import (SPEC §3).
112_RESTING = (Discovered, AwaitingHuman, Open, Lapsed, HoldAmazon)
113 
114 
115def _is_amazon(payee: str) -> bool:
116 return "amazon" in payee.lower()
117 
118 
119def _hold_for_amazon(snapshot: YnabSnapshot) -> bool:
120 """Whether to hold for Amazon item detail: Amazon-ish and no memo yet."""
121 return _is_amazon(snapshot.payee) and not snapshot.has_memo
122 
123 
124@workflow.defn
125class TransactionWorkflow:
126 """The durable per-transaction lifecycle."""
127 
128 def __init__(self) -> None:
129 """Initialize empty state; ``run`` populates it from the params."""
130 self._txn: Transaction
131 self._ynab_id: str = ""
132 self._thread_id: str | None = None
133 self._deadlines: dict[TimerKind, datetime.datetime] = {}
134 self._inbound: deque[InboundSignal] = deque()
135 self._snapshot_ready: YnabSnapshot | None = None
136 # Monotonic per-transaction counter: the outbound-send idempotency key
137 # so a replay/retry never double-emails (SPEC §3 outbound dedup).
138 self._action_seq: int = 0
139 
140 # ── Signals (the external world pushes in) ──────────────────────────────
141 @workflow.signal
142 def submit_inbound(self, signal: InboundSignal) -> None:
143 """A reply or matched receipt arrived (W3/W4)."""
144 self._inbound.append(signal)
145 
146 @workflow.signal
147 def notify_snapshot(self, snapshot: YnabSnapshot) -> None:
148 """W1 materialized (or backfilled the memo of) the YNAB snapshot."""
149 self._snapshot_ready = snapshot
150 
151 @workflow.query
152 def state(self) -> str:
153 """The current lifecycle state (for observability)."""
154 return self._txn.state.value
155 
156 # ── The durable loop ────────────────────────────────────────────────────
157 @workflow.run
158 async def run(self, params: TransactionParams) -> None:
159 """Drive the lifecycle until the transaction is archived (SPEC §3)."""
160 self._ynab_id = params.ynab_id
161 self._thread_id = params.thread_id
162 self._deadlines = dict(params.resume_deadlines)
163 self._inbound.extend(params.resume_inbound)
164 self._action_seq = params.resume_action_seq
165 self._txn = params.resume_txn or born(
166 YnabTransactionId(params.ynab_id), params.thread_id
167 )
168 self._sync_thread_id()
169 
170 try:
171 while not isinstance(self._txn, Archived):
172 await self._step()
173 if (
174 isinstance(self._txn, _RESTING)
175 and not self._inbound # drain pending signals (SPEC §0.5)
176 and workflow.info().get_current_history_length()
177 > _CONTINUE_AS_NEW_AFTER
178 ):
179 # continue_as_new raises to restart; nothing runs after it.
180 # (ContinueAsNewError is not an ActivityError, so it escapes
181 # the failure hook below untouched.)
182 workflow.continue_as_new(self._resume_params())
183 except ActivityError as exc:
184 # A terminal activity failure: a non-retryable bug (the constants
185 # denylist) or the schedule_to_close budget elapsing. Page the owner
186 # once — deduped, best-effort — then re-raise so the transaction
187 # still fails and stays visible in Temporal (SPEC §13).
188 await workflow.execute_activity(
189 alert_activities.alert_failure,
190 build_failure_alert(
191 key=self._ynab_id,
192 context=self._alert_context(),
193 exc=exc,
194 ),
195 start_to_close_timeout=ALERT_TIMEOUT,
196 schedule_to_close_timeout=ALERT_BUDGET,
197 retry_policy=ALERT_RETRY,
198 )
199 raise
200 
201 def _alert_context(self) -> str:
202 """A human locator for a failure alert: payee + txn id when known."""
203 st = self._txn
204 if isinstance(st, Discovered):
205 return f"txn {self._ynab_id}"
206 return f"{st.core.snapshot.payee} (txn {self._ynab_id})"
207 
208 def _resume_params(self) -> TransactionParams:
209 return TransactionParams(
210 ynab_id=YnabTransactionId(self._ynab_id),
211 thread_id=ThreadId(self._thread_id)
212 if self._thread_id is not None
213 else None,
214 resume_txn=self._txn,
215 resume_deadlines=dict(self._deadlines),
216 resume_inbound=tuple(self._inbound),
217 resume_action_seq=self._action_seq,
218 )
219 
220 async def _step(self) -> None:
221 st = self._txn
222 if isinstance(st, Discovered):
223 await self._on_discovered()
224 elif isinstance(st, Enriching):
225 await self._on_enriching(st)
226 elif isinstance(st, HoldAmazon):
227 await self._on_hold(st)
228 elif isinstance(st, AwaitingHuman):
229 await self._on_awaiting(st)
230 elif isinstance(st, Open):
231 await self._on_open(st)
232 elif isinstance(st, Lapsed):
233 await self._on_lapsed()
234 elif isinstance(st, Revising):
235 await self._on_revising(st)
236 elif isinstance(st, (AutoApplied, Applied, Archived)):
237 # Transient (handled via the commit→verify follow-up) or terminal.
238 return
239 
240 # ── apply + effect dispatch ─────────────────────────────────────────────
241 async def _dispatch(self, event: LifecycleEvent) -> None:
242 transition = advance(
243 self._txn, event, now=workflow.now(), policy=DEFAULT_POLICY
244 )
245 self._txn = transition.next
246 # Keep the thread-id mirror in step with the state machine, which can
247 # adopt a reply's thread on its own (e.g. a reply in DISCOVERED).
248 self._sync_thread_id()
249 for effect in transition.effects:
250 followup = await self._execute(effect)
251 if followup is not None:
252 await self._dispatch(followup)
253 
254 async def _execute(self, effect: Effect) -> LifecycleEvent | None:
255 if isinstance(effect, CommitToYnab):
256 await workflow.execute_activity(
257 activities.commit_to_ynab,
258 args=[self._ynab_id, effect.decision],
259 start_to_close_timeout=ACTIVITY_TIMEOUT,
260 retry_policy=ACTIVITY_RETRY,
261 )
262 read = await workflow.execute_activity(
263 activities.read_back,
264 self._ynab_id,
265 start_to_close_timeout=ACTIVITY_TIMEOUT,
266 retry_policy=ACTIVITY_RETRY,
267 )
268 return WriteVerified(
269 outcome=classify_verify(read, target_of(effect.decision))
270 )
271 if isinstance(effect, OpenThread):
272 tid = await workflow.execute_activity(
273 activities.open_thread,
274 args=[self._ynab_id, self._proposal()],
275 start_to_close_timeout=ACTIVITY_TIMEOUT,
276 retry_policy=ACTIVITY_RETRY,
277 )
278 self._set_thread_id(tid)
279 # Index this workflow by its thread for reply routing (§5a).
280 workflow.upsert_search_attributes([_TXN_THREAD_ID.value_set(tid)])
281 elif isinstance(effect, SendThreadMessage):
282 self._action_seq += 1
283 await workflow.execute_activity(
284 activities.send_thread_message,
285 args=[
286 self._ynab_id,
287 self._thread_id,
288 effect.purpose,
289 self._action_seq,
290 self._proposal(),
291 ],
292 start_to_close_timeout=ACTIVITY_TIMEOUT,
293 retry_policy=ACTIVITY_RETRY,
294 )
295 elif isinstance(effect, FeedRuleLearning):
296 await workflow.execute_activity(
297 activities.feed_rule_learning,
298 effect,
299 start_to_close_timeout=ACTIVITY_TIMEOUT,
300 retry_policy=ACTIVITY_RETRY,
301 )
302 elif isinstance(effect, CloseThread):
303 if self._thread_id is not None:
304 await workflow.execute_activity(
305 activities.close_thread,
306 self._thread_id,
307 start_to_close_timeout=ACTIVITY_TIMEOUT,
308 retry_policy=ACTIVITY_RETRY,
309 )
310 elif isinstance(effect, SetTimer):
311 self._deadlines[effect.timer] = effect.deadline
312 elif isinstance(effect, CancelTimer):
313 self._deadlines.pop(effect.timer, None)
314 elif isinstance(effect, ReplayBuffered):
315 self._inbound.extendleft(reversed(effect.signals))
316 return None
317 
318 def _set_thread_id(self, tid: str) -> None:
319 self._thread_id = tid
320 st = self._txn
321 if not isinstance(st, Discovered):
322 new_core = st.core.model_copy(update={"thread_id": ThreadId(tid)})
323 self._txn = st.model_copy(update={"core": new_core})
324 
325 def _sync_thread_id(self) -> None:
326 """Mirror the current transaction's thread id (SM may adopt it)."""
327 st = self._txn
328 tid = st.thread_id if isinstance(st, Discovered) else st.core.thread_id
329 if tid is not None:
330 self._thread_id = str(tid)
331 
332 def _proposal(self) -> Proposal | None:
333 """The current best-guess proposal, for states that carry one.
334 
335 Passed to the mail activities so the proposal email can name the guess +
336 alternatives; ``None`` for purposes whose content derives from YNAB.
337 """
338 st = self._txn
339 if isinstance(st, (Enriching, AwaitingHuman, Lapsed)):
340 return st.proposal
341 return None
342 
343 # ── per-state steps ─────────────────────────────────────────────────────
344 async def _on_discovered(self) -> None:
345 snapshot = await workflow.execute_activity(
346 activities.fetch_snapshot,
347 self._ynab_id,
348 start_to_close_timeout=ACTIVITY_TIMEOUT,
349 retry_policy=ACTIVITY_RETRY,
350 )
351 if snapshot is not None:
352 await self._dispatch(
353 SnapshotMaterialized(
354 snapshot=snapshot,
355 hold_for_amazon=_hold_for_amazon(snapshot),
356 )
357 )
358 return
359 # Signal beat the poll: wait for materialization or buffer an inbound.
360 await self._dispatch(SnapshotUnavailable())
361 await workflow.wait_condition(
362 lambda: self._snapshot_ready is not None or len(self._inbound) > 0
363 )
364 if self._snapshot_ready is not None:
365 snap = self._snapshot_ready
366 self._snapshot_ready = None
367 await self._dispatch(
368 SnapshotMaterialized(
369 snapshot=snap, hold_for_amazon=_hold_for_amazon(snap)
370 )
371 )
372 else:
373 await self._dispatch(
374 InboundReceived(signal=self._inbound.popleft())
375 )
376 
377 async def _on_enriching(self, st: Enriching) -> None:
378 outcome = await workflow.execute_activity(
379 activities.enrich,
380 st.core.snapshot,
381 start_to_close_timeout=ACTIVITY_TIMEOUT,
382 retry_policy=ACTIVITY_RETRY,
383 )
384 await self._dispatch(Enriched(outcome=outcome))
385 
386 async def _on_hold(self, st: HoldAmazon) -> None:
387 ready = await self._wait_until(
388 st.amazon_deadline,
389 lambda: self._snapshot_ready is not None or len(self._inbound) > 0,
390 )
391 if not ready:
392 await self._dispatch(HoldDeadlineReached())
393 elif self._snapshot_ready is not None:
394 snap = self._snapshot_ready
395 self._snapshot_ready = None
396 await self._dispatch(HoldResolved(snapshot=snap))
397 else:
398 await self._dispatch(
399 InboundReceived(signal=self._inbound.popleft())
400 )
401 
402 async def _on_awaiting(self, st: AwaitingHuman) -> None:
403 got_inbound = await self._wait_until(
404 st.patience_deadline, self._has_inbound
405 )
406 if not got_inbound:
407 before = type(self._txn)
408 await self._dispatch(PatienceExpired())
409 if type(self._txn) is not before:
410 return # lapsed (or otherwise transitioned)
411 # Flagged (verify-failure) entry: PatienceExpired is ignored and
412 # does not lapse (SPEC §3). Drop the passed timer and wait for an
413 # inbound instead of re-spinning the expired deadline.
414 self._deadlines.pop(TimerKind.PATIENCE, None)
415 await workflow.wait_condition(self._has_inbound)
416 await self._interpret_inbound(st.core.snapshot)
417 
418 async def _interpret_inbound(self, snapshot: YnabSnapshot) -> None:
419 signal = self._inbound.popleft()
420 interpretation = await workflow.execute_activity(
421 activities.interpret_inbound,
422 args=[signal, snapshot, self._proposal()],
423 start_to_close_timeout=ACTIVITY_TIMEOUT,
424 retry_policy=ACTIVITY_RETRY,
425 )
426 if isinstance(interpretation, AnswerOutcome):
427 await self._dispatch(
428 AnswerReceived(decision=interpretation.decision)
429 )
430 else:
431 await self._dispatch(
432 ClarifyRequested(question=interpretation.question)
433 )
434 
435 async def _on_open(self, st: Open) -> None:
436 deadline = self._deadlines.get(TimerKind.ARCHIVE)
437 got_inbound = (
438 await self._wait_until(deadline, self._has_inbound)
439 if deadline is not None
440 else await self._wait_forever(self._has_inbound)
441 )
442 if got_inbound:
443 await self._dispatch(
444 InboundReceived(signal=self._inbound.popleft())
445 )
446 return
447 # The archive window elapsed. Before closing the book, re-read YNAB to
448 # catch a silent manual recategorization — an out-of-band correction
449 # that must demote the driving rule (SPEC §14.2).
450 event = await self._archive_or_override(st)
451 before = type(self._txn)
452 await self._dispatch(event)
453 if type(self._txn) is before:
454 # Archive blocked (not reconciled) and no override: drop the stale
455 # timer and wait for an inbound rather than busy-looping (SPEC §3).
456 self._deadlines.pop(TimerKind.ARCHIVE, None)
457 await workflow.wait_condition(self._has_inbound)
458 await self._dispatch(
459 InboundReceived(signal=self._inbound.popleft())
460 )
461 
462 async def _archive_or_override(self, st: Open) -> LifecycleEvent:
463 """At archive time, detect a manual YNAB edit (SPEC §14.2).
464 
465 Re-reads the current end-state and, if its category no longer matches
466 the agent's applied decision, returns an ``OverrideDetected`` carrying
467 the human's choice (the spine then demotes the rule); otherwise the
468 ordinary ``ArchiveWindowReached``. A memo-only change is not an override
469 — only the allocation is compared.
470 """
471 read = await workflow.execute_activity(
472 activities.read_back,
473 self._ynab_id,
474 start_to_close_timeout=ACTIVITY_TIMEOUT,
475 retry_policy=ACTIVITY_RETRY,
476 )
477 if read is not None and read.allocation != st.decision.allocation:
478 human = Decision(
479 allocation=read.allocation,
480 memo=read.memo,
481 approved=read.approved,
482 decided_by=DecidedBy.HUMAN,
483 decided_at=workflow.now(),
484 )
485 return OverrideDetected(decision=human)
486 return ArchiveWindowReached()
⋯ 62 lines hidden (lines 487–548)
487 
488 async def _on_lapsed(self) -> None:
489 await self._wait_then_revise_or_archive()
490 
491 def _has_inbound(self) -> bool:
492 return len(self._inbound) > 0
493 
494 async def _wait_then_revise_or_archive(self) -> None:
495 deadline = self._deadlines.get(TimerKind.ARCHIVE)
496 got_inbound = (
497 await self._wait_until(deadline, self._has_inbound)
498 if deadline is not None
499 else await self._wait_forever(self._has_inbound)
500 )
501 if got_inbound:
502 await self._dispatch(
503 InboundReceived(signal=self._inbound.popleft())
504 )
505 return
506 # The archive window elapsed. Attempt to archive; if it is blocked (not
507 # reconciled / not categorized) the state is unchanged, so drop the
508 # now-stale timer and wait for an inbound rather than busy-looping on
509 # the already-passed deadline.
510 before = type(self._txn)
511 await self._dispatch(ArchiveWindowReached())
512 if type(self._txn) is before:
513 self._deadlines.pop(TimerKind.ARCHIVE, None)
514 await workflow.wait_condition(self._has_inbound)
515 await self._dispatch(
516 InboundReceived(signal=self._inbound.popleft())
517 )
518 
519 async def _wait_forever(self, predicate: Callable[[], bool]) -> bool:
520 await workflow.wait_condition(predicate)
521 return True
522 
523 async def _on_revising(self, st: Revising) -> None:
524 outcome = await workflow.execute_activity(
525 activities.converge,
526 args=[st.core.snapshot, st.instruction],
527 start_to_close_timeout=ACTIVITY_TIMEOUT,
528 retry_policy=ACTIVITY_RETRY,
529 )
530 await self._dispatch(Converged(outcome=outcome))
531 
532 async def _wait_until(
533 self,
534 deadline: datetime.datetime,
535 predicate: Callable[[], bool],
536 ) -> bool:
537 """Wait for ``predicate`` until an absolute deadline.
538 
539 Returns ``True`` if the predicate fired first, ``False`` on timeout.
540 """
541 timeout = deadline - workflow.now()
542 if timeout < timedelta(0):
543 timeout = timedelta(0)
544 try:
545 await workflow.wait_condition(predicate, timeout=timeout)
546 except TimeoutError:
547 return False
548 return True

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
src/ynab_agent/domain/state_machine.py626 lines · Python
⋯ 444 lines hidden (lines 1–444)
1"""The transaction lifecycle state machine (SPEC §3).
2 
3``advance(txn, event, now, policy)`` is a pure function: given the current
4transaction, a decided event, the current time, and the timer policy, it returns
5a :class:`Transition` — the next transaction plus the effects the spine should
6execute. It performs no I/O and reads no clock; ``now`` is passed in, so replay
7is deterministic.
8 
9Dispatch is per-state (one handler each), and ``advance`` ends in
10``assert_never`` so mypy proves all ten states are handled. An event a state
11does not expect yields a ``REJECTED`` transition (no state change), never an
12exception, so the spine decides how to handle the surprise.
13 
14Interpretation notes (where the SPEC's diagram is collapsed for a pure model):
15 
16* The REVISING converge step already bundles commit *and* verify (SPEC §3 rules
17 3-4), so its ``Reapplied`` outcome lands directly in ``OPEN`` (re-approved and
18 verified), collapsing the degenerate ``APPLIED → OPEN`` hop the diagram draws.
19* ``AWAITING_HUMAN`` reached from a verify failure carries a ``flag`` and no
20 proposal; per the SPEC such flagged entries do *not* lapse into the generic
21 hand-off note, so ``PatienceExpired`` there is ignored (the §13 sweep tracks
22 it).
23"""
24 
25from __future__ import annotations
26 
27from enum import StrEnum
28from typing import TYPE_CHECKING, assert_never
29 
30from ynab_agent.domain.base import Frozen
31from ynab_agent.domain.effects import (
32 CancelTimer,
33 CloseThread,
34 CommitToYnab,
35 Effect,
36 FeedRuleLearning,
37 MessagePurpose,
38 OpenThread,
39 ReplayBuffered,
40 RuleLearningKind,
41 SendThreadMessage,
42 SetTimer,
43 TimerKind,
45from ynab_agent.domain.enums import AwaitingFlag, RevisingOrigin
46from ynab_agent.domain.events import (
47 AnswerReceived,
48 ArchiveWindowReached,
49 AskHuman,
50 AutoApply,
51 ClarifyRequested,
52 Converged,
53 CouldNotConfirm,
54 Diverged,
55 Enriched,
56 HoldDeadlineReached,
57 HoldResolved,
58 InboundReceived,
59 LifecycleEvent,
60 NeedsHuman,
61 NoChange,
62 OverrideDetected,
63 PatienceExpired,
64 Reapplied,
65 SnapshotMaterialized,
66 SnapshotUnavailable,
67 VerifyOutcome,
68 WriteVerified,
70from ynab_agent.domain.signals import ReplySignal
71from ynab_agent.domain.transaction import (
72 Applied,
73 Archived,
74 AutoApplied,
75 AwaitingHuman,
76 Discovered,
77 Enriching,
78 HoldAmazon,
79 Lapsed,
80 Open,
81 Revising,
82 Transaction,
83 TxnCore,
85 
86if TYPE_CHECKING:
87 import datetime
88 
89 from ynab_agent.domain.config import LifecyclePolicy
90 from ynab_agent.domain.proposal import Decision
91 
92 
93class TransitionKind(StrEnum):
94 """The disposition of an ``advance`` call."""
95 
96 ADVANCED = "advanced"
97 IGNORED = "ignored"
98 REJECTED = "rejected"
99 
100 
101class Transition(Frozen):
102 """The result of advancing a transaction.
103 
104 Attributes:
105 kind: ``ADVANCED`` (state and/or effects), ``IGNORED`` (legal no-op), or
106 ``REJECTED`` (the event is not valid in this state).
107 next: The resulting transaction (unchanged for IGNORED/REJECTED).
108 effects: The effects the spine should execute, in order.
109 reason: A short explanation for IGNORED/REJECTED.
110 """
111 
112 kind: TransitionKind
113 next: Transaction
114 effects: tuple[Effect, ...] = ()
115 reason: str | None = None
116 
117 
118def _advanced(nxt: Transaction, *effects: Effect) -> Transition:
119 return Transition(kind=TransitionKind.ADVANCED, next=nxt, effects=effects)
120 
121 
122def _ignored(txn: Transaction, reason: str) -> Transition:
123 return Transition(kind=TransitionKind.IGNORED, next=txn, reason=reason)
124 
125 
126def _rejected(txn: Transaction, event: LifecycleEvent) -> Transition:
127 reason = f"{type(event).__name__} is not valid in {txn.state}"
128 return Transition(kind=TransitionKind.REJECTED, next=txn, reason=reason)
129 
130 
131def advance(
132 txn: Transaction,
133 event: LifecycleEvent,
134 *,
135 now: datetime.datetime,
136 policy: LifecyclePolicy,
137) -> Transition:
138 """Advance a transaction by one event. Pure; see the module docstring."""
139 match txn:
140 case Discovered():
141 return _from_discovered(txn, event, now=now, policy=policy)
142 case HoldAmazon():
143 return _from_hold_amazon(txn, event)
144 case Enriching():
145 return _from_enriching(txn, event, now=now, policy=policy)
146 case AutoApplied():
147 return _from_auto_applied(txn, event, now=now, policy=policy)
148 case AwaitingHuman():
149 return _from_awaiting_human(txn, event, now=now, policy=policy)
150 case Applied():
151 return _from_applied(txn, event, now=now, policy=policy)
152 case Open():
153 return _from_open(txn, event)
154 case Lapsed():
155 return _from_lapsed(txn, event)
156 case Revising():
157 return _from_revising(txn, event, now=now, policy=policy)
158 case Archived():
159 return _from_archived(txn, event)
160 assert_never(txn)
161 
162 
163def _from_discovered(
164 txn: Discovered,
165 event: LifecycleEvent,
166 *,
167 now: datetime.datetime,
168 policy: LifecyclePolicy,
169) -> Transition:
170 match event:
171 case SnapshotMaterialized(snapshot=snap, hold_for_amazon=hold):
172 core = TxnCore(snapshot=snap, thread_id=txn.thread_id)
173 replay: tuple[Effect, ...] = (
174 (ReplayBuffered(signals=txn.pending),) if txn.pending else ()
175 )
176 if hold:
177 deadline = now + policy.amazon_hold
178 return _advanced(
179 HoldAmazon(core=core, amazon_deadline=deadline),
180 SetTimer(timer=TimerKind.AMAZON_HOLD, deadline=deadline),
181 *replay,
182 )
183 return _advanced(Enriching(core=core), *replay)
184 case SnapshotUnavailable():
185 return _ignored(txn, "snapshot not yet in YNAB; staying DISCOVERED")
186 case InboundReceived(signal=sig):
187 thread_id = txn.thread_id
188 if thread_id is None and isinstance(sig, ReplySignal):
189 thread_id = sig.thread_id
190 buffered = Discovered(
191 ynab_id=txn.ynab_id,
192 thread_id=thread_id,
193 pending=(*txn.pending, sig),
194 )
195 return _advanced(buffered)
196 case _:
197 return _rejected(txn, event)
198 
199 
200def _from_hold_amazon(txn: HoldAmazon, event: LifecycleEvent) -> Transition:
201 match event:
202 case HoldResolved(snapshot=snap):
203 core = txn.core.model_copy(update={"snapshot": snap})
204 return _advanced(
205 Enriching(core=core),
206 CancelTimer(timer=TimerKind.AMAZON_HOLD),
207 )
208 case HoldDeadlineReached():
209 # Deadline hit: enrich and fall back to asking (SPEC §3).
210 return _advanced(Enriching(core=txn.core))
211 case InboundReceived(signal=sig):
212 # A matched receipt (or reply) short-circuits the hold (SPEC §6).
213 return _advanced(
214 Enriching(core=txn.core),
215 CancelTimer(timer=TimerKind.AMAZON_HOLD),
216 ReplayBuffered(signals=(sig,)),
217 )
218 case _:
219 return _rejected(txn, event)
220 
221 
222def _from_enriching(
223 txn: Enriching,
224 event: LifecycleEvent,
225 *,
226 now: datetime.datetime,
227 policy: LifecyclePolicy,
228) -> Transition:
229 match event:
230 case Enriched(outcome=outcome):
231 match outcome:
232 case AutoApply(decision=decision):
233 return _advanced(
234 AutoApplied(core=txn.core, decision=decision),
235 CommitToYnab(decision=decision),
236 )
237 case AskHuman(proposal=proposal):
238 deadline = now + policy.patience_window
239 # First contact: open_thread sends the proposal as the
240 # thread's opening email (AgentMail starts a thread on its
241 # first send), so no separate send is emitted. A re-proposal
242 # (the thread is already open) sends it as a reply.
243 send_effect: tuple[Effect, ...] = (
244 (OpenThread(),)
245 if txn.core.thread_id is None
246 else (
247 SendThreadMessage(purpose=MessagePurpose.PROPOSAL),
248 )
249 )
250 return _advanced(
251 AwaitingHuman(
252 core=txn.core,
253 proposal=proposal,
254 patience_deadline=deadline,
255 ),
256 *send_effect,
257 SetTimer(timer=TimerKind.PATIENCE, deadline=deadline),
258 )
259 assert_never(outcome)
260 case InboundReceived(signal=sig):
261 # Re-feed so enrichment incorporates the new signal; stay ENRICHING.
262 return Transition(
263 kind=TransitionKind.ADVANCED,
264 next=txn,
265 effects=(ReplayBuffered(signals=(sig,)),),
266 )
267 case _:
268 return _rejected(txn, event)
269 
270 
271def _from_auto_applied(
272 txn: AutoApplied,
273 event: LifecycleEvent,
274 *,
275 now: datetime.datetime,
276 policy: LifecyclePolicy,
277) -> Transition:
278 match event:
279 case WriteVerified(outcome=outcome):
280 return _resolve_write_verify(
281 core=txn.core,
282 decision=txn.decision,
283 outcome=outcome,
284 applied_message=MessagePurpose.FYI,
285 learning=None,
286 now=now,
287 policy=policy,
288 )
289 case _:
290 return _rejected(txn, event)
291 
292 
293def _from_applied(
294 txn: Applied,
295 event: LifecycleEvent,
296 *,
297 now: datetime.datetime,
298 policy: LifecyclePolicy,
299) -> Transition:
300 match event:
301 case WriteVerified(outcome=outcome):
302 return _resolve_write_verify(
303 core=txn.core,
304 decision=txn.decision,
305 outcome=outcome,
306 applied_message=MessagePurpose.CONFIRM,
307 learning=RuleLearningKind.CONFIRM,
308 now=now,
309 policy=policy,
310 )
311 case _:
312 return _rejected(txn, event)
313 
314 
315def _resolve_write_verify(
316 *,
317 core: TxnCore,
318 decision: Decision,
319 outcome: VerifyOutcome,
320 applied_message: MessagePurpose,
321 learning: RuleLearningKind | None,
322 now: datetime.datetime,
323 policy: LifecyclePolicy,
324) -> Transition:
325 """Shared AUTO_APPLIED/APPLIED → OPEN-or-AWAITING handling (SPEC §3)."""
326 match outcome:
327 case VerifyOutcome.MATCH:
328 archive = now + policy.archive_window
329 effects: tuple[Effect, ...] = (
330 SendThreadMessage(purpose=applied_message),
331 SetTimer(timer=TimerKind.ARCHIVE, deadline=archive),
332 )
333 if learning is not None:
334 effects = (
335 FeedRuleLearning(
336 event=learning,
337 payee=core.snapshot.payee,
338 decision=decision,
339 ),
340 *effects,
341 )
342 return _advanced(Open(core=core, decision=decision), *effects)
343 case VerifyOutcome.COULD_NOT_CONFIRM:
344 return _enter_flagged_awaiting(
345 core,
346 AwaitingFlag.POSSIBLY_INCONSISTENT,
347 MessagePurpose.POSSIBLY_INCONSISTENT,
348 now=now,
349 policy=policy,
350 )
351 case VerifyOutcome.DIVERGED:
352 return _enter_flagged_awaiting(
353 core,
354 AwaitingFlag.DIVERGED,
355 MessagePurpose.DIVERGED_READBACK,
356 now=now,
357 policy=policy,
358 )
359 assert_never(outcome)
360 
361 
362def _enter_flagged_awaiting(
363 core: TxnCore,
364 flag: AwaitingFlag,
365 message: MessagePurpose,
366 *,
367 now: datetime.datetime,
368 policy: LifecyclePolicy,
369) -> Transition:
370 """Route a verify failure to a flagged AWAITING_HUMAN with a read-back."""
371 deadline = now + policy.patience_window
372 return _advanced(
373 AwaitingHuman(core=core, patience_deadline=deadline, flag=flag),
374 SendThreadMessage(purpose=message),
375 SetTimer(timer=TimerKind.PATIENCE, deadline=deadline),
376 )
377 
378 
379def _from_awaiting_human(
380 txn: AwaitingHuman,
381 event: LifecycleEvent,
382 *,
383 now: datetime.datetime,
384 policy: LifecyclePolicy,
385) -> Transition:
386 match event:
387 case AnswerReceived(decision=decision):
388 return _advanced(
389 Applied(core=txn.core, decision=decision),
390 CommitToYnab(decision=decision),
391 CancelTimer(timer=TimerKind.PATIENCE),
392 )
393 case ClarifyRequested():
394 deadline = now + policy.patience_window
395 return _advanced(
396 AwaitingHuman(
397 core=txn.core,
398 proposal=txn.proposal,
399 patience_deadline=deadline,
400 flag=txn.flag,
401 ),
402 SendThreadMessage(purpose=MessagePurpose.CLARIFY),
403 SetTimer(timer=TimerKind.PATIENCE, deadline=deadline),
404 )
405 case PatienceExpired():
406 if txn.flag is not AwaitingFlag.NONE:
407 # Flagged (inconsistent/diverged) entries do not generic-lapse.
408 return _ignored(txn, "flagged entry does not lapse (SPEC §3)")
409 archive = now + policy.archive_window
410 return _advanced(
411 Lapsed(core=txn.core, proposal=txn.proposal),
412 SendThreadMessage(purpose=MessagePurpose.HANDOFF),
413 SetTimer(timer=TimerKind.ARCHIVE, deadline=archive),
414 )
415 case InboundReceived(signal=sig):
416 # Re-feed for interpretation into an answer/clarify; keep waiting.
417 return Transition(
418 kind=TransitionKind.ADVANCED,
419 next=txn,
420 effects=(ReplayBuffered(signals=(sig,)),),
421 )
422 case _:
423 return _rejected(txn, event)
424 
425 
426def _from_open(txn: Open, event: LifecycleEvent) -> Transition:
427 match event:
428 case InboundReceived(signal=sig):
429 return _advanced(
430 Revising(
431 core=txn.core,
432 instruction=sig,
433 origin=RevisingOrigin.APPLIED,
434 prior=txn.decision,
435 ),
436 CancelTimer(timer=TimerKind.ARCHIVE),
437 )
438 case ArchiveWindowReached():
439 if not txn.core.snapshot.reconciled:
440 return _ignored(txn, "not reconciled yet; staying OPEN")
441 return _advanced(
442 Archived(core=txn.core, final=txn.decision),
443 CloseThread(),
444 )
445 case OverrideDetected(decision=human_decision):
446 # The owner recategorized in YNAB directly: a silent correction.
447 # Demote the driving rule back to Observe (CORRECT, carrying the
448 # prior agent decision so the *right* rule is demoted, §14.2) and
449 # tell them we noticed and backed off. Close the book if the txn is
450 # reconciled; otherwise adopt their value and wait (ARCHIVED needs a
451 # reconciled snapshot), so we never archive prematurely.
452 demote = FeedRuleLearning(
453 event=RuleLearningKind.CORRECT,
454 payee=txn.core.snapshot.payee,
455 decision=human_decision,
456 prior=txn.decision,
457 )
458 notice = SendThreadMessage(purpose=MessagePurpose.OVERRIDE_NOTICE)
459 if not txn.core.snapshot.reconciled:
460 return _advanced(
461 Open(core=txn.core, decision=human_decision),
462 demote,
463 notice,
464 )
465 return _advanced(
466 Archived(core=txn.core, final=human_decision),
467 demote,
468 notice,
469 CloseThread(),
470 )
⋯ 156 lines hidden (lines 471–626)
471 case _:
472 return _rejected(txn, event)
473 
474 
475def _from_lapsed(txn: Lapsed, event: LifecycleEvent) -> Transition:
476 match event:
477 case InboundReceived(signal=sig):
478 return _advanced(
479 Revising(
480 core=txn.core,
481 instruction=sig,
482 origin=RevisingOrigin.LAPSED,
483 ),
484 CancelTimer(timer=TimerKind.ARCHIVE),
485 )
486 case ArchiveWindowReached():
487 snap = txn.core.snapshot
488 if not snap.reconciled:
489 return _ignored(txn, "not reconciled yet; staying LAPSED")
490 if not snap.categorized:
491 # Don't go silent: warn, then let the §13 sweep keep tracking.
492 return Transition(
493 kind=TransitionKind.ADVANCED,
494 next=txn,
495 effects=(
496 SendThreadMessage(
497 purpose=MessagePurpose.ARCHIVE_NOTICE
498 ),
499 ),
500 reason="uncategorized; warned before archiving (SPEC §3)",
501 )
502 return _advanced(
503 Archived(core=txn.core),
504 CloseThread(),
505 )
506 case _:
507 return _rejected(txn, event)
508 
509 
510def _from_revising(
511 txn: Revising,
512 event: LifecycleEvent,
513 *,
514 now: datetime.datetime,
515 policy: LifecyclePolicy,
516) -> Transition:
517 match event:
518 case Converged(outcome=outcome):
519 match outcome:
520 case Reapplied(decision=decision):
521 return _reapply(txn, decision, now=now, policy=policy)
522 case NoChange():
523 return _resolve_no_change(txn, now=now, policy=policy)
524 case CouldNotConfirm():
525 return _enter_flagged_awaiting(
526 txn.core,
527 AwaitingFlag.POSSIBLY_INCONSISTENT,
528 MessagePurpose.POSSIBLY_INCONSISTENT,
529 now=now,
530 policy=policy,
531 )
532 case Diverged():
533 return _enter_flagged_awaiting(
534 txn.core,
535 AwaitingFlag.DIVERGED,
536 MessagePurpose.DIVERGED_READBACK,
537 now=now,
538 policy=policy,
539 )
540 case NeedsHuman():
541 deadline = now + policy.patience_window
542 return _advanced(
543 AwaitingHuman(
544 core=txn.core, patience_deadline=deadline
545 ),
546 SendThreadMessage(purpose=MessagePurpose.PROPOSAL),
547 SetTimer(timer=TimerKind.PATIENCE, deadline=deadline),
548 )
549 assert_never(outcome)
550 case InboundReceived(signal=sig):
551 # Newest instruction wins (SPEC §3 rule 1): retain the correction
552 # and re-target the in-flight converge, rather than dropping it.
553 return Transition(
554 kind=TransitionKind.ADVANCED,
555 next=txn.model_copy(update={"instruction": sig}),
556 effects=(ReplayBuffered(signals=(sig,)),),
557 )
558 case _:
559 return _rejected(txn, event)
560 
561 
562def _reapply(
563 txn: Revising,
564 decision: Decision,
565 *,
566 now: datetime.datetime,
567 policy: LifecyclePolicy,
568) -> Transition:
569 """Re-applied revision: open, summarize, and feed learning (SPEC §3, §9).
570 
571 A real category change demotes the prior rule (CORRECT, carrying the prior
572 decision so W5 demotes the *right* rule); a same-category revision — a
573 receipt adding a memo, or a late first answer from LAPSED — only confirms.
574 """
575 archive = now + policy.archive_window
576 is_correction = (
577 txn.prior is not None and txn.prior.allocation != decision.allocation
578 )
579 payee = txn.core.snapshot.payee
580 if is_correction:
581 learning = FeedRuleLearning(
582 event=RuleLearningKind.CORRECT,
583 payee=payee,
584 decision=decision,
585 prior=txn.prior,
586 )
587 else:
588 learning = FeedRuleLearning(
589 event=RuleLearningKind.CONFIRM, payee=payee, decision=decision
590 )
591 return _advanced(
592 Open(core=txn.core, decision=decision),
593 learning,
594 SendThreadMessage(purpose=MessagePurpose.REVISE_SUMMARY),
595 SetTimer(timer=TimerKind.ARCHIVE, deadline=archive),
596 )
597 
598 
599def _resolve_no_change(
600 txn: Revising,
601 *,
602 now: datetime.datetime,
603 policy: LifecyclePolicy,
604) -> Transition:
605 """No-change exit depends on history (SPEC §3 rule 5).
606 
607 Already applied → OPEN (resting). Entered from LAPSED (never applied) →
608 AWAITING_HUMAN, re-arming patience, so an unhandled txn is not mislabeled.
609 """
610 if txn.origin is RevisingOrigin.APPLIED and txn.prior is not None:
611 archive = now + policy.archive_window
612 return _advanced(
613 Open(core=txn.core, decision=txn.prior),
614 SetTimer(timer=TimerKind.ARCHIVE, deadline=archive),
615 )
616 deadline = now + policy.patience_window
617 return _advanced(
618 AwaitingHuman(core=txn.core, patience_deadline=deadline),
619 SetTimer(timer=TimerKind.PATIENCE, deadline=deadline),
620 )
621 
622 
623def _from_archived(txn: Archived, event: LifecycleEvent) -> Transition:
624 # Terminal. A late edit is handled by signal-with-start re-instantiating a
625 # fresh REVISING run under the same logical id, not by a transition here.
626 return _rejected(txn, event)

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).