What changed, and why

The proposal email was good — and almost everything after it was broken. Six of ten message purposes rendered the placeholder "A quick note on this transaction." at exactly the moments the SPEC promises specific copy: the 7-day handoff, the auto-apply FYI, the revision summary, the couldn't-confirm warning, the diverged which-wins question, the archive notice. The payloads those emails needed were computed upstream — the model's clarify question, the diverged summaries, the decided category — then dropped before compose. W2 follow-ups omitted recipients, so when the agent spoke last, AgentMail addressed the handoff back to the agent's own inbox. The auto path never opened a thread, so the first blessed auto-apply crashed its workflow after the YNAB write. And a flagged AwaitingHuman answered every reply with the same canned question, forever.

One fix through one seam: the effect carries the payload, the state machine populates it, the workflow passes it through (and opens a thread when none exists), the activity resolves names and recipients, and compose renders real copy per purpose.

The payload rides the effect

SendThreadMessage gains two optional fields: detail — free text specific to this message (a clarify question, a which-wins comparison) — and decision — what was actually written, so confirms and FYIs can name it.

The effect: payloads travel with the purpose instead of being dropped.

src/ynab_agent/domain/effects.py · 149 lines
src/ynab_agent/domain/effects.py149 lines · Python
⋯ 58 lines hidden (lines 1–58)
1"""Effects: data describing what the spine should do after a transition.
2 
3The state machine is pure and performs no I/O. It returns *effects* — commit a
4write, send a thread message, set or cancel a timer, feed rule learning. The
5Temporal spine interprets and executes them (commit→verify, idempotent sends,
6durable timers). Effects are values, so a transition is fully testable without
7touching YNAB or email.
8"""
9 
10from __future__ import annotations
11 
12import datetime
13from enum import StrEnum
14from typing import Annotated, Literal
15 
16from pydantic import Field
17 
18from ynab_agent.domain.base import Frozen
19from ynab_agent.domain.proposal import Decision
20from ynab_agent.domain.signals import InboundSignal
21 
22 
23class MessagePurpose(StrEnum):
24 """Why a thread message is being sent (shapes the prose; SPEC §3, §5)."""
25 
26 PROPOSAL = "proposal"
27 FYI = "fyi"
28 CONFIRM = "confirm"
29 CLARIFY = "clarify"
30 HANDOFF = "handoff"
31 REVISE_SUMMARY = "revise_summary"
32 POSSIBLY_INCONSISTENT = "possibly_inconsistent"
33 DIVERGED_READBACK = "diverged_readback"
34 ARCHIVE_NOTICE = "archive_notice"
35 OVERRIDE_NOTICE = "override_notice"
36 
37 
38class TimerKind(StrEnum):
39 """The lifecycle timers (SPEC §3)."""
40 
41 AMAZON_HOLD = "amazon_hold"
42 PATIENCE = "patience"
43 ARCHIVE = "archive"
44 
45 
46class RuleLearningKind(StrEnum):
47 """The human-decision events rule learning consumes (SPEC §9)."""
48 
49 CONFIRM = "confirm"
50 CORRECT = "correct"
51 
52 
53class OpenThread(Frozen):
54 """Create the AgentMail thread for this transaction (SPEC §5)."""
55 
56 kind: Literal["open_thread"] = "open_thread"
57 
58 
59class SendThreadMessage(Frozen):
60 """Send a message on the transaction's thread.
61 
62 ``detail`` is the message-specific content the template wraps (the model's
63 clarifying question, a diverged which-wins comparison); ``decision`` lets
64 the send name what was actually written (the confirm/FYI/revise-summary
65 category). Without these the spine once rendered every post-proposal email
66 as a contentless placeholder — the payloads were computed, then dropped.
67 """
68 
69 kind: Literal["send_message"] = "send_message"
70 purpose: MessagePurpose
71 detail: str | None = None
72 decision: Decision | None = None
73 
⋯ 76 lines hidden (lines 74–149)
74 
75class CommitToYnab(Frozen):
76 """Commit a decision to YNAB (the spine does commit→verify; SPEC §0.5)."""
77 
78 kind: Literal["commit_to_ynab"] = "commit_to_ynab"
79 decision: Decision
80 
81 
82class SetTimer(Frozen):
83 """Arm a durable timer with an absolute deadline."""
84 
85 kind: Literal["set_timer"] = "set_timer"
86 timer: TimerKind
87 deadline: datetime.datetime
88 
89 
90class CancelTimer(Frozen):
91 """Cancel a previously armed timer."""
92 
93 kind: Literal["cancel_timer"] = "cancel_timer"
94 timer: TimerKind
95 
96 
97class FeedRuleLearning(Frozen):
98 """Feed a confirm/correct event to rule learning (W5; SPEC §9).
99 
100 ``payee`` is carried so W5 can key the rule's match on it without re-reading
101 the snapshot. For a correction, ``prior`` carries the decision being
102 overturned, so W5 can demote *the rule that produced the prior decision*
103 (§3 rule 6), not the new one.
104 """
105 
106 kind: Literal["feed_rule_learning"] = "feed_rule_learning"
107 event: RuleLearningKind
108 payee: str
109 decision: Decision | None = None
110 prior: Decision | None = None
111 
112 
113class RecordAutoAction(Frozen):
114 """Record a landed auto-action in the circuit-breaker ledger (SPEC §0.6).
115 
116 Emitted alongside the commit when a blessed rule auto-applies, so the hard
117 floor's per-run / per-day counts are real and the breaker can trip. Keyed by
118 ``ynab_id`` so a retry or re-enrichment counts the transaction once.
119 """
120 
121 kind: Literal["record_auto_action"] = "record_auto_action"
122 ynab_id: str
123 
124 
125class ReplayBuffered(Frozen):
126 """Re-deliver the signals buffered while in DISCOVERED (SPEC §3)."""
127 
128 kind: Literal["replay_buffered"] = "replay_buffered"
129 signals: tuple[InboundSignal, ...]
130 
131 
132class CloseThread(Frozen):
133 """Label and close the AgentMail thread on archive (SPEC §13)."""
134 
135 kind: Literal["close_thread"] = "close_thread"
136 
137 
138Effect = Annotated[
139 OpenThread
140 | SendThreadMessage
141 | CommitToYnab
142 | SetTimer
143 | CancelTimer
144 | FeedRuleLearning
145 | RecordAutoAction
146 | ReplayBuffered
147 | CloseThread,
148 Field(discriminator="kind"),

The state machine populates them at every emit site. Two examples: the clarify branch carries the model's actual question, and the diverged branch builds the which-wins comparison from the summaries the converge activity already resolved — the signature safety moment finally says what it knows.

Clarify carries the question; diverged carries both sides; NeedsHuman asks a question carrying its reason instead of an empty proposal shell.

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

No thread? Open one — the auto-apply crash

A blessed auto-apply commits, verifies, then sends its FYI — but the auto path never proposed anything, so no thread exists, and the send raised ('no thread open yet'), retried ~15 minutes, paged the operator, and killed the workflow after the money write. The fix is one spine-level guard: when a message must be sent and no thread exists, open the thread with that message as its first email. The FYI becomes the thread — which is what makes the SPEC §14.5 'one-reply undo' possible at all.

The fallback covers every future no-thread send, not just the FYI.

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

Names, comparisons, and recipients at the send boundary

The send activity resolves the decided allocation to category names (Done — set to Groceries and approved, not 'the category you picked'), derives a which-wins comparison for the plain commit→verify path from a fresh snapshot read, and — the quiet killer — addresses every send to the owners explicitly. Without to=, AgentMail replies to the thread's last speaker; when that was the agent, the handoff/archive/override notices went to the agent's own inbox.

_decided_display names what was written; _diverged_detail builds the comparison for the non-converge path.

src/ynab_agent/workflow/activities.py · 706 lines
src/ynab_agent/workflow/activities.py706 lines · Python
⋯ 236 lines hidden (lines 1–236)
1"""The I/O ports of the transaction lifecycle, as Temporal activities.
2 
3Every side effect the workflow performs — reading YNAB, committing a write,
4sending email, the agentic enrichment/interpretation/converge steps — is an
5activity, so neither the model's nor the spine's I/O runs in workflow code
6(SPEC §0.5). These are *stubs*: typed signatures with no body. The real
7implementations (YNAB/AgentMail MCP, Pydantic AI) are wired in a later step; the
8workflow tests register mock implementations.
9"""
10 
11from __future__ import annotations
12 
13from typing import TYPE_CHECKING
14 
15from temporalio import activity
16 
17from ynab_agent.domain.allocations import (
18 ProposedCategory,
19 ProposedSplit,
20 ResolvedCategory,
22from ynab_agent.domain.effects import FeedRuleLearning, MessagePurpose
23from ynab_agent.domain.events import ConvergeOutcome, EnrichmentOutcome
24from ynab_agent.domain.ids import CategoryId
25from ynab_agent.domain.proposal import Decision, Proposal
26from ynab_agent.domain.signals import InboundSignal
27from ynab_agent.domain.transaction import YnabSnapshot
28from ynab_agent.policy.converge import TargetState
29from ynab_agent.workflow.types import ReplyOutcome
30 
31if TYPE_CHECKING:
32 import datetime
33 
34 # Annotation-only: never imported at runtime, so the agentic/model stack
35 # (pydantic-ai) never enters the workflow sandbox. The enrich body imports
36 # it lazily, inside the activity, where it runs outside the sandbox.
37 from ynab_agent.agentic.enrich import CandidateCategory
38 from ynab_agent.budget.overspend import CategorySpend
39 from ynab_agent.domain.rule import Rule
40 from ynab_agent.policy.floor import AutoActionCounters
41 
42 
43@activity.defn
44async def fetch_snapshot(ynab_id: str) -> YnabSnapshot | None:
45 """Read the current YNAB snapshot, or ``None`` if not yet imported.
46 
47 The YNAB client is imported lazily (keeping httpx out of the sandbox) and
48 its blocking call runs off the event loop.
49 """
50 import asyncio
51 
52 from ynab_agent.ynab.client import YnabClient
53 
54 client = YnabClient.from_env()
55 return await asyncio.to_thread(client.snapshot, ynab_id)
56 
57 
58def _candidates_from_spends(
59 spends: tuple[CategorySpend, ...],
60) -> tuple[CandidateCategory, ...]:
61 """Map the budget's live categories to the agent's candidate choices."""
62 from ynab_agent.agentic.enrich import CandidateCategory
63 
64 return tuple(
65 CandidateCategory(id=str(spend.category), name=spend.name)
66 for spend in spends
67 )
68 
69 
70async def _load_payee_rules(payee: str) -> list[Rule]:
71 """Query the durable rule registry for this payee's rules (W5, SPEC §14).
72 
73 Returns ``[]`` when the registry has not been started yet (no learning has
74 happened) or is unreachable — the conservative fallback, which routes the
75 transaction to ASK rather than risking an auto-apply on stale knowledge.
76 """
77 from temporalio.service import RPCError
78 
79 from ynab_agent.domain.rule import Rule
80 from ynab_agent.workflow.registry_types import REGISTRY_WORKFLOW_ID
81 from ynab_agent.workflow.temporal_client import client
82 
83 temporal = await client()
84 handle = temporal.get_workflow_handle(REGISTRY_WORKFLOW_ID)
85 try:
86 # ``result_type`` is load-bearing: the pydantic data converter decodes a
87 # query payload to plain ``dict``s without it, and the gate reads real
88 # ``Rule`` objects (``rule.match``) — a dict there raises AttributeError
89 # and the enrich activity retries forever (SPEC §14 gate-load path).
90 rules = await handle.query(
91 "payee_rules", payee, result_type=tuple[Rule, ...]
92 )
93 except RPCError:
94 return []
95 return list(rules)
96 
97 
98async def _load_auto_action_counters(
99 now: datetime.datetime,
100) -> AutoActionCounters:
101 """Read the live circuit-breaker counts from the durable ledger (SPEC §0.6).
102 
103 Returns zeros when the ledger has not been started yet (no auto-action has
104 ever happened) or is unreachable — the correct conservative default *here*,
105 since a never-started ledger genuinely means zero auto-actions and the
106 breaker must not trip on its own absence (contrast ``_load_payee_rules``,
107 which fails *closed* to ASK because an unknown rule must never auto-apply).
108 """
109 from temporalio.service import RPCError
110 
111 from ynab_agent.policy.floor import AutoActionCounters
112 from ynab_agent.workflow.auto_action_types import (
113 AUTO_ACTION_LEDGER_WORKFLOW_ID,
114 CountersRequest,
115 )
116 from ynab_agent.workflow.temporal_client import client
117 
118 temporal = await client()
119 handle = temporal.get_workflow_handle(AUTO_ACTION_LEDGER_WORKFLOW_ID)
120 try:
121 result: AutoActionCounters = await handle.query(
122 "counters",
123 CountersRequest(now=now),
124 result_type=AutoActionCounters,
125 )
126 except RPCError:
127 return AutoActionCounters()
128 return result
129 
130 
131async def _load_enrichment_inputs(
132 snapshot: YnabSnapshot,
133 now: datetime.datetime,
134) -> tuple[tuple[CandidateCategory, ...], list[Rule], AutoActionCounters]:
135 """Fetch the candidate categories, in-scope rules, and live auto counters.
136 
137 The candidates are the budget's live categories, read from YNAB (the source
138 of truth); the rules come from the durable registry, keyed on the payee; the
139 auto-action counters come from the durable circuit-breaker ledger (SPEC
140 §0.6), so the per-run / per-day cap reads real counts and can trip. The gate
141 (SPEC §4.2, §14) decides auto-vs-ask over the loaded rules — auto-applying
142 only a blessed one, still bounded by the floor.
143 """
144 import asyncio
145 
146 from ynab_agent.ynab.client import YnabClient
147 
148 client = YnabClient.from_env()
149 spends = await asyncio.to_thread(client.category_spends)
150 rules = await _load_payee_rules(snapshot.payee)
151 counters = await _load_auto_action_counters(now)
152 return _candidates_from_spends(spends), rules, counters
153 
154 
155@activity.defn
156async def enrich(snapshot: YnabSnapshot) -> EnrichmentOutcome:
157 """Assemble the proposal and route via the gate (the agentic middle).
158 
159 The model stack is imported lazily, here in the activity body, so it never
160 enters the workflow sandbox. The gate decides autonomy from the rules; the
161 agent runs only to produce the proposal a human is asked to confirm.
162 """
163 from datetime import UTC, datetime
164 
165 from ynab_agent.agentic.enrich import decide_enrichment
166 
167 now = datetime.now(UTC)
168 candidates, rules, counters = await _load_enrichment_inputs(snapshot, now)
169 return await decide_enrichment(
170 snapshot, candidates, rules, counters, now=now
171 )
172 
173 
174@activity.defn
175async def commit_to_ynab(ynab_id: str, decision: Decision) -> None:
176 """Commit a decision to YNAB (the deterministic write).
177 
178 Lazy YNAB client (httpx stays out of the sandbox); the blocking write runs
179 off the event loop.
180 """
181 import asyncio
182 
183 from ynab_agent.ynab.client import YnabClient
184 
185 client = YnabClient.from_env()
186 await asyncio.to_thread(client.commit, ynab_id, decision)
187 
188 
189@activity.defn
190async def read_back(ynab_id: str) -> TargetState | None:
191 """Read the post-write end-state for verification, or ``None`` if unread."""
192 import asyncio
193 
194 from ynab_agent.ynab.client import YnabClient
195 
196 client = YnabClient.from_env()
197 return await asyncio.to_thread(client.read_back, ynab_id)
198 
199 
200def _txn_label(ynab_id: str) -> str:
201 """The per-transaction idempotency label (open-thread dedup; SPEC §5)."""
202 return f"yatxn-{ynab_id}"
203 
204 
205def _seq_label(ynab_id: str, action_seq: int) -> str:
206 """The per-action idempotency label (send dedup; SPEC §3)."""
207 return f"yaseq-{ynab_id}-{action_seq}"
208 
209 
210def _subject(snapshot: YnabSnapshot, category: str | None) -> str:
211 """The thread's subject: payee + amount, and the suggested category.
212 
213 No ``[YNAB]`` prefix — the sender address is a known contact. Naming the
214 proposed category lets the owner act from the subject line alone.
215 """
216 base = f"{snapshot.payee}{snapshot.amount}"
217 return f"{base} · {category}?" if category else base
218 
219 
220def _date_display(day: datetime.date) -> str:
221 """A short, friendly transaction date, e.g. ``May 29`` (no leading zero)."""
222 return f"{day.strftime('%b')} {day.day}"
223 
224 
225def _allocation_display(
226 allocation: ProposedCategory | ProposedSplit, names: dict[str, str]
227) -> str:
228 """A human display of the proposed allocation, by category name."""
229 if isinstance(allocation, ProposedCategory):
230 return names.get(str(allocation.category), str(allocation.category))
231 return " + ".join(
232 names.get(str(line.category), str(line.category))
233 for line in allocation.lines
234 )
235 
236 
237def _decided_display(
238 decision: Decision | None, names: dict[str, str]
239) -> str | None:
240 """The category name(s) a decision wrote, for naming it in an email."""
241 if decision is None:
242 return None
243 allocation = decision.allocation
244 if isinstance(allocation, ResolvedCategory):
245 return names.get(str(allocation.category), str(allocation.category))
246 return " + ".join(
247 names.get(str(line.category), str(line.category))
248 for line in allocation.lines
249 )
250 
251 
252async def _read_for_compose(
253 ynab_id: str,
254) -> tuple[YnabSnapshot, dict[str, str]]:
255 """Re-read the YNAB snapshot and a category-id→name map for composing.
256 
257 Both come from YNAB (the source of truth) at send time — there is no stored
258 copy of the facts or the category names (SPEC §0.5, store-free).
259 """
260 import asyncio
261 
262 from ynab_agent.ynab.client import YnabClient
263 
264 client = YnabClient.from_env()
265 snapshot = await asyncio.to_thread(client.snapshot, ynab_id)
266 if snapshot is None:
267 msg = f"transaction {ynab_id} not found in YNAB at compose time"
268 raise RuntimeError(msg)
269 spends = await asyncio.to_thread(client.category_spends)
270 names = {str(spend.category): spend.name for spend in spends}
271 return snapshot, names
272 
273 
274def _diverged_detail(
275 snapshot: YnabSnapshot, decision: Decision, names: dict[str, str]
276) -> str:
277 """A which-wins comparison from the live snapshot vs. the intended write.
278 
279 The converge path carries its own comparison on the effect; this covers the
280 plain commit→verify path, where only the intended decision is known — the
281 current side comes from the fresh snapshot read at send time.
282 """
283 current = (
284 names.get(str(snapshot.category_id), str(snapshot.category_id))
285 if snapshot.category_id is not None
286 else ("a split" if snapshot.subtransactions else "uncategorized")
287 )
288 intended = _decided_display(decision, names)
289 return (
290 f"YNAB now shows {current}, but I set {intended} — which should "
291 "win? Reply with your choice and I'll sort it out."
292 )
⋯ 414 lines hidden (lines 293–706)
293 
294 
295def _render_message(
296 snapshot: YnabSnapshot,
297 proposal: Proposal | None,
298 purpose: MessagePurpose,
299 names: dict[str, str],
300 *,
301 detail: str | None = None,
302 decision: Decision | None = None,
303) -> str:
304 """Lay out one message body for the thread (deterministic; SPEC §5).
305 
306 The proposal's category + alternatives (model-chosen upstream) are resolved
307 to names here and handed to the template — no model call at send time.
308 ``detail`` and ``decision`` carry the message-specific payload from the
309 state machine (the clarify question, the diverged comparison, what a
310 confirm/FYI/revision actually wrote).
311 """
312 from ynab_agent.agentic.compose import ComposeRequest, render_body
313 
314 proposed = (
315 _allocation_display(proposal.allocation, names)
316 if proposal is not None
317 else None
318 )
319 alternatives = (
320 tuple(names.get(str(alt), str(alt)) for alt in proposal.alternatives)
321 if proposal is not None
322 else ()
323 )
324 if (
325 detail is None
326 and decision is not None
327 and purpose is MessagePurpose.DIVERGED_READBACK
328 ):
329 detail = _diverged_detail(snapshot, decision, names)
330 request = ComposeRequest(
331 purpose=purpose.value,
332 payee=snapshot.payee,
333 amount_display=str(snapshot.amount),
334 txn_date=_date_display(snapshot.txn_date),
335 memo=snapshot.memo,
336 proposed_category=proposed,
337 alternatives=alternatives,
338 rationale=proposal.rationale if proposal is not None else None,
339 detail=detail,
340 decided_category=_decided_display(decision, names),
341 )
342 return render_body(request)
343 
344 
345@activity.defn
346async def open_thread(
347 ynab_id: str,
348 proposal: Proposal | None,
349 purpose: MessagePurpose = MessagePurpose.PROPOSAL,
350 detail: str | None = None,
351 decision: Decision | None = None,
352) -> str:
353 """Open the AgentMail thread with its first message; returns its id.
354 
355 A thread starts on its first send (AgentMail has no empty-thread create).
356 Usually that first message is the proposal, but an auto-applied transaction
357 has no proposal email — its thread opens with the FYI (``purpose``/
358 ``decision``), which is what makes the SPEC §14.5 per-action FYI + one-reply
359 undo possible at all. The txn facts + category names are re-read from YNAB
360 at compose time, and the open is idempotent on the per-transaction label, so
361 a retry re-finds the thread rather than sending a duplicate.
362 """
363 import asyncio
364 
365 from ynab_agent.mail.client import MailClient
366 from ynab_agent.settings import Settings
367 
368 settings = Settings()
369 snapshot, names = await _read_for_compose(ynab_id)
370 body = _render_message(
371 snapshot, proposal, purpose, names, detail=detail, decision=decision
372 )
373 headline = _decided_display(decision, names) or (
374 _allocation_display(proposal.allocation, names)
375 if proposal is not None
376 else None
377 )
378 mail = MailClient.from_env()
379 return await asyncio.to_thread(
380 mail.open_thread,
381 inbox_id=settings.inbox,
382 to=list(settings.owners),
383 subject=_subject(snapshot, headline),
384 body=body,
385 txn_label=_txn_label(ynab_id),
386 )
387 
388 
389@activity.defn
390async def send_thread_message(
391 ynab_id: str,
392 thread_id: str | None,
393 purpose: MessagePurpose,
394 action_seq: int,
395 proposal: Proposal | None,
396 detail: str | None = None,
397 decision: Decision | None = None,
398) -> None:
399 """Send a follow-up message on the transaction's thread.
400 
401 ``action_seq`` is the per-transaction idempotency key: the send dedups on it
402 so a retry never double-sends (SPEC §3). ``proposal`` is the current best
403 guess where the purpose needs it (re-proposal); ``detail``/``decision``
404 carry the message's specific payload (clarify question, diverged
405 comparison, what was written). Recipients are set to the owners explicitly:
406 when the agent was the last speaker on the thread, AgentMail would
407 otherwise address the reply back to the agent's own inbox and the owners
408 would never see it (the same fix the W7 balancer needed, #17).
409 """
410 import asyncio
411 
412 from ynab_agent.mail.client import MailClient
413 from ynab_agent.settings import Settings
414 
415 if thread_id is None:
416 msg = f"cannot send {purpose.value} for {ynab_id}: no thread open yet"
417 raise RuntimeError(msg)
418 settings = Settings()
419 snapshot, names = await _read_for_compose(ynab_id)
420 body = _render_message(
421 snapshot, proposal, purpose, names, detail=detail, decision=decision
422 )
423 mail = MailClient.from_env()
424 await asyncio.to_thread(
425 mail.send_on_thread,
426 inbox_id=settings.inbox,
427 thread_id=thread_id,
428 body=body,
429 seq_label=_seq_label(ynab_id, action_seq),
430 to=list(settings.owners),
431 )
432 
433 
434def _proposed_category_id(proposal: Proposal | None) -> CategoryId | None:
435 """The single proposed category id, or None (a split is not approvable)."""
436 if proposal is None:
437 return None
438 allocation = proposal.allocation
439 if isinstance(allocation, ProposedCategory):
440 return allocation.category
441 return None
442 
443 
444@activity.defn
445async def interpret_inbound(
446 signal: InboundSignal,
447 snapshot: YnabSnapshot,
448 proposal: Proposal | None,
449) -> ReplyOutcome:
450 """Interpret a human reply into an answer or a question (SPEC §3, §5).
451 
452 Free-form: the model reads whether the reply approves the proposal, names a
453 different category, or asks a question. The spine — not the model — stamps
454 the human + time into the resulting Decision. A non-reply inbound can't be
455 answered directly, so ask rather than guess a write. A *missing* proposal
456 (a flagged verify-failure entry, a NeedsHuman wait) is fine: the owner can
457 still name a category outright, and looping the same canned question at
458 them made those states unanswerable by email. Names + candidates are
459 re-read from YNAB at interpret time.
460 """
461 import asyncio
462 from datetime import UTC, datetime
463 
464 from ynab_agent.agentic.interpret import (
465 InterpretRequest,
466 interpret,
467 to_reply_outcome,
468 )
469 from ynab_agent.domain.signals import ReplySignal
470 from ynab_agent.workflow.types import ClarifyOutcome
471 from ynab_agent.ynab.client import YnabClient
472 
473 proposed_id = _proposed_category_id(proposal)
474 if not isinstance(signal, ReplySignal):
475 return ClarifyOutcome(
476 question="Could you say which category this should be?"
477 )
478 
479 client = YnabClient.from_env()
480 spends = await asyncio.to_thread(client.category_spends)
481 names = {str(spend.category): spend.name for spend in spends}
482 request = InterpretRequest(
483 reply_text=signal.text,
484 payee=snapshot.payee,
485 amount_display=str(snapshot.amount),
486 proposed_category_name=names.get(str(proposed_id), str(proposed_id))
487 if proposed_id is not None
488 else None,
489 candidates=_candidates_from_spends(spends),
490 )
491 interpretation = await interpret(request)
492 return to_reply_outcome(
493 interpretation,
494 proposed_category=proposed_id,
495 decided_at=datetime.now(UTC),
496 )
497 
498 
499def _target_summary(target: TargetState | None, names: dict[str, str]) -> str:
500 """A short human description of an end-state, for a divergence note."""
501 if target is None:
502 return "(could not read)"
503 allocation = target.allocation
504 if isinstance(allocation, ResolvedCategory):
505 label = names.get(str(allocation.category), str(allocation.category))
506 else:
507 label = "a split"
508 return f"{label}{target.memo}" if target.memo else label
509 
510 
511@activity.defn
512async def converge(
513 snapshot: YnabSnapshot,
514 instruction: InboundSignal,
515 prior: Decision | None,
516) -> ConvergeOutcome:
517 """Converge a REVISING run to its target and verify it (SPEC §3 rules 3-4).
518 
519 The agent reads the revision instruction into a target (retarget / memo /
520 no-change). The spine then converges to it: it reads the *current* YNAB
521 end-state first and, comparing against the agent's last-applied decision
522 (``prior``), skips a needless write (the no-op exit), adopts a write that
523 already landed, or surfaces a divergence (a spouse edited it directly) —
524 *before* overwriting it — rather than committing blind and only noticing on
525 the read-back. Only a genuine change writes, then verifies field-by-field. A
526 reconciled or closed-month transaction, a non-reply instruction, or anything
527 the model under-specifies routes to a human rather than a silent edit.
528 """
529 import asyncio
530 from datetime import UTC, datetime
531 
532 from ynab_agent.agentic.converge import (
533 RevisionRequest,
534 interpret_revision,
535 to_revision_plan,
536 )
537 from ynab_agent.domain.enums import DecidedBy
538 from ynab_agent.domain.events import (
539 CouldNotConfirm,
540 Diverged,
541 NeedsHuman,
542 NoChange,
543 Reapplied,
544 VerifyOutcome,
545 )
546 from ynab_agent.domain.signals import ReplySignal
547 from ynab_agent.policy.converge import (
548 PrecommitAction,
549 classify_verify,
550 precommit_action,
551 reconciliation_blocks,
552 target_of,
553 )
554 from ynab_agent.ynab.client import YnabClient
555 
556 if reconciliation_blocks(snapshot):
557 return NeedsHuman(
558 reason="reconciled or closed-month — propose, don't silently edit"
559 )
560 if not isinstance(instruction, ReplySignal):
561 return NeedsHuman(reason="non-reply revision instruction unsupported")
562 
563 client = YnabClient.from_env()
564 spends = await asyncio.to_thread(client.category_spends)
565 names = {str(spend.category): spend.name for spend in spends}
566 current_name = (
567 names.get(str(snapshot.category_id), str(snapshot.category_id))
568 if snapshot.category_id is not None
569 else "(uncategorized)"
570 )
571 target = await interpret_revision(
572 RevisionRequest(
573 instruction=instruction.text,
574 current_category_name=current_name,
575 candidates=_candidates_from_spends(spends),
576 current_memo=snapshot.memo,
577 )
578 )
579 plan = to_revision_plan(target)
580 if not plan.changes:
581 return NoChange()
582 
583 category = (
584 CategoryId(plan.category_id)
585 if plan.category_id is not None
586 else snapshot.category_id
587 )
588 if category is None:
589 return NeedsHuman(reason="revision did not resolve a category")
590 decision = Decision(
591 allocation=ResolvedCategory(category=category),
592 memo=plan.memo if plan.memo is not None else snapshot.memo,
593 approved=True,
594 decided_by=DecidedBy.HUMAN,
595 decided_at=datetime.now(UTC),
596 )
597 end_state = target_of(decision)
598 prior_state = target_of(prior) if prior is not None else None
599 
600 # SPEC §3 r3-4: read the current end-state *before* writing, then decide.
601 current = await asyncio.to_thread(client.read_back, snapshot.ynab_id)
602 action = precommit_action(current, end_state, prior_state)
603 if action is PrecommitAction.NO_CHANGE:
604 return NoChange()
605 if action is PrecommitAction.ALREADY_TARGET:
606 return Reapplied(decision=decision)
607 if action is PrecommitAction.DIVERGED:
608 return Diverged(
609 ynab_summary=_target_summary(current, names),
610 requested_summary=_target_summary(end_state, names),
611 )
612 
613 # PrecommitAction.WRITE: converge to the target, then verify (SPEC §3 r3-4).
614 await asyncio.to_thread(client.commit, snapshot.ynab_id, decision)
615 read = await asyncio.to_thread(client.read_back, snapshot.ynab_id)
616 verdict = classify_verify(read, end_state)
617 if verdict is VerifyOutcome.MATCH:
618 return Reapplied(decision=decision)
619 if verdict is VerifyOutcome.COULD_NOT_CONFIRM:
620 return CouldNotConfirm()
621 return Diverged(
622 ynab_summary=_target_summary(read, names),
623 requested_summary=_target_summary(end_state, names),
624 )
625 
626 
627@activity.defn
628async def feed_rule_learning(feed: FeedRuleLearning) -> None:
629 """Persist a confirm/correct event into the durable rule registry (W5).
630 
631 Signal-with-start on the singleton :class:`RuleRegistryWorkflow`: the first
632 learning event creates it, every later one just delivers the signal, and the
633 workflow folds the event into the rule table the autonomy gate reads (SPEC
634 §9, §14). The conflict policy reuses the running singleton rather than
635 starting a second registry.
636 """
637 from temporalio.common import WorkflowIDConflictPolicy
638 
639 from ynab_agent.workflow.registry_types import (
640 REGISTRY_WORKFLOW_ID,
641 RegistryParams,
642 )
643 from ynab_agent.workflow.temporal_client import client, task_queue
644 
645 temporal = await client()
646 await temporal.start_workflow(
647 "RuleRegistryWorkflow",
648 RegistryParams(),
649 id=REGISTRY_WORKFLOW_ID,
650 task_queue=task_queue(),
651 id_conflict_policy=WorkflowIDConflictPolicy.USE_EXISTING,
652 start_signal="record",
653 start_signal_args=[feed],
654 )
655 
656 
657@activity.defn
658async def record_auto_action(ynab_id: str) -> None:
659 """Record a landed auto-action in the durable breaker ledger (SPEC §0.6).
660 
661 Signal-with-start on the singleton ledger: the first auto-action creates it,
662 every later one just delivers the signal, and the ledger folds it into the
663 counts the hard floor reads (mirrors ``feed_rule_learning`` talking to the
664 registry). Best-effort — a ledger hiccup must never block or fail the
665 categorization it bounds, so any error is logged and swallowed. The breaker
666 tolerates an occasional missed count; the per-txn ceiling still binds, and
667 the ``ynab_id`` key dedups a retry.
668 """
669 from temporalio.common import WorkflowIDConflictPolicy
670 
671 from ynab_agent.workflow.auto_action_types import (
672 AUTO_ACTION_LEDGER_WORKFLOW_ID,
673 LedgerParams,
674 )
675 from ynab_agent.workflow.temporal_client import client, task_queue
676 
677 try:
678 temporal = await client()
679 await temporal.start_workflow(
680 "AutoActionLedgerWorkflow",
681 LedgerParams(),
682 id=AUTO_ACTION_LEDGER_WORKFLOW_ID,
683 task_queue=task_queue(),
684 id_conflict_policy=WorkflowIDConflictPolicy.USE_EXISTING,
685 start_signal="record",
686 start_signal_args=[ynab_id],
687 )
688 except Exception:
689 activity.logger.warning(
690 "auto-action ledger record failed (best-effort): %s", ynab_id
691 )
692 
693 
694@activity.defn
695async def close_thread(thread_id: str) -> None:
696 """Label and close the AgentMail thread on archive."""
697 import asyncio
698 
699 from ynab_agent.mail.client import MailClient
700 from ynab_agent.settings import Settings
701 
702 settings = Settings()
703 mail = MailClient.from_env()
704 await asyncio.to_thread(
705 mail.close, inbox_id=settings.inbox, thread_id=thread_id
706 )

Real copy per purpose, and answerable dead-ends

render_body now has a branch per lifecycle moment, with the SPEC §3 copy: the handoff says it's yours now (and that a reply still works); the couldn't-confirm note says to check YNAB; the archive notice asks for a category or 'handled'; the FYI names the category, the standing rule, and the undo. A sweep test renders every purpose and asserts the placeholder never appears.

Every purpose has copy; the placeholder survives only as the fallback for an unmapped future purpose.

src/ynab_agent/agentic/compose.py · 286 lines
src/ynab_agent/agentic/compose.py286 lines · Python
⋯ 202 lines hidden (lines 1–202)
1"""Render a transaction's email body (SPEC §5).
2 
3A deterministic template — *not* a model call. The model already did the
4thinking upstream (the proposal's category + one-line rationale + alternatives);
5this just lays it out cleanly so a glance is enough to act. Keeping it templated
6(rather than free-form prose) is a deliberate low-cognitive-load choice, and it
7drops a per-email model round-trip.
8 
9The layout, for a proposal:
10 
11 Hulu — $13.07 — May 29
12 <memo, when present — e.g. an Amazon item list>
13 
14 Suggested: Entertainment (or: Streaming, Fun Money)
15 recurring streaming subscription
16 
17 Just reply in your own words — confirm it, suggest a different category, …
18"""
19 
20from __future__ import annotations
21 
22from typing import TYPE_CHECKING
23 
24from ynab_agent.domain.base import Frozen
25from ynab_agent.domain.effects import MessagePurpose
26 
27if TYPE_CHECKING:
28 from ynab_agent.budget.balance import BalanceOption
29 from ynab_agent.domain.money import Money
30 
31_REPLY_HINT = (
32 "Just reply in your own words — confirm it, suggest a different "
33 "category, or ask a question."
35 
36 
37class ComposeRequest(Frozen):
38 """The facts the template lays out for one transaction email."""
39 
40 purpose: str # the MessagePurpose value: proposal / confirm / clarify / ...
41 payee: str
42 amount_display: str
43 txn_date: str # already display-formatted (e.g. "May 29")
44 memo: str | None = None
45 proposed_category: str | None = None # the best-guess category NAME
46 alternatives: tuple[str, ...] = () # other category names to offer
47 rationale: str | None = None # one-line reason for the best guess
48 question: str | None = (
49 None # an explicit question, when the purpose has one
50 )
51 # Message-specific content carried from the state machine (the model's
52 # clarify question, a diverged which-wins comparison) — see the
53 # SendThreadMessage effect.
54 detail: str | None = None
55 # The category NAME a decision actually wrote (confirm / FYI / revision
56 # summary / override notice), so those emails name what happened.
57 decided_category: str | None = None
58 
59 
60def _facts(request: ComposeRequest) -> str:
61 """The transaction header line, plus the memo line when there is one."""
62 header = f"{request.payee}{request.amount_display}{request.txn_date}"
63 if request.memo and request.memo.strip():
64 return f"{header}\n{request.memo.strip()}"
65 return header
66 
67 
68def _proposal_body(request: ComposeRequest, facts: str) -> str:
69 suggested = (
70 f"Suggested: {request.proposed_category or '(needs a category)'}"
71 )
72 if request.alternatives:
73 suggested += f" (or: {', '.join(request.alternatives)})"
74 lines = [facts, "", suggested]
75 if request.rationale:
76 lines.append(request.rationale)
77 lines += ["", _REPLY_HINT]
78 return "\n".join(lines)
79 
80 
81def render_autonomy_offer(payee: str, category: str) -> str:
82 """The one-time "want me to auto-handle this payee?" offer body (§14.7 3b).
83 
84 A standalone yes/no message (its own thread), so a plain "yes"/"no" reply is
85 unambiguous. The owner can also reply with the explicit standing command.
86 """
87 return (
88 f"You've consistently filed {payee} under {category}, and I haven't "
89 "had to correct it.\n\n"
90 f"Want me to start auto-handling {payee} as {category} from now on? "
91 "I'll apply it automatically, flag each one for you, and you can undo "
92 "any of them with a one-word reply.\n\n"
93 "Reply YES to let me, or NO to keep approving each one yourself."
94 )
95 
96 
97def render_command_confirm(payee: str, category: str) -> str:
98 """The read-back for an explicit "always X as Y" command (SPEC §5c, §0.6).
99 
100 A standing command grants autonomy, so the agent echoes its interpretation
101 and waits for a one-word confirm before blessing — a command can arrive on a
102 brand-new thread where the allow-list is the only gate, so a misread or a
103 mistaken send must not silently grant auto-apply.
104 """
105 return (
106 f"You asked me to always categorize {payee} as {category}.\n\n"
107 f"Reply YES to confirm — I'll auto-handle {payee} as {category} from "
108 "now on, flag each one for you, and you can undo any with a one-word "
109 "reply. Reply NO to keep approving each one yourself."
110 )
111 
112 
113def render_offer_accepted(payee: str, category: str) -> str:
114 """The confirmation sent when the owner accepts the offer (§14.7 3b)."""
115 return (
116 f"Great — I'll auto-handle {payee} as {category} from now on, and "
117 "flag each one so you can see (and undo) it. Reply any time to change "
118 "this."
119 )
120 
121 
122def render_offer_declined(payee: str) -> str:
123 """The brief note sent when the owner declines the offer (§14.7 3b)."""
124 return (
125 f"No problem — I'll keep proposing {payee} for you to approve, same "
126 "as before."
127 )
128 
129 
130def render_receipt_unsupported() -> str:
131 """The honest note for a forwarded receipt the join can't process yet (§6).
132 
133 The receipt⇄transaction join (W4) is a deferred increment, so rather than
134 swallow a forwarded receipt silently, the agent acknowledges it and points
135 the owner at the path that does work: replying on the transaction's own
136 email thread.
137 """
138 return (
139 "Thanks for forwarding this. I can't match forwarded receipts to "
140 "transactions yet, so I haven't filed it.\n\n"
141 "To add detail to a specific charge — an item list, a split, or a note "
142 "— just reply on that transaction's own email thread and I'll fold it "
143 "in."
144 )
145 
146 
147def render_balance_options(
148 needy_name: str, options: tuple[BalanceOption, ...]
149) -> str:
150 """The balance offer: ways to cover an overspend, each explained (§8).
151 
152 Numbered so the owner can reply "option 2", but a free-text answer ("take it
153 from dining instead", "only $50") is read just as well. Each option leads
154 with the model's rationale, the plain-English description of the moves.
155 """
156 lines = [
157 f"{needy_name} is over budget. Here are some ways I can cover it by "
158 "moving money between categories (nothing leaves your accounts):",
159 "",
160 ]
161 for index, option in enumerate(options, start=1):
162 lines.append(f"{index}. {option.label}{option.rationale}")
163 lines += [
164 "",
165 "Reply with the option you'd like (or your own tweak — e.g. "
166 '"option 2 but only $50", or "no thanks").',
167 ]
168 return "\n".join(lines)
169 
170 
171def render_balance_applied(needy_name: str, total: Money) -> str:
172 """The confirmation after a coverage plan is applied (SPEC §8)."""
173 return (
174 f"Done — moved {total} into {needy_name} to cover the overspend. "
175 "Reply any time to adjust it."
176 )
177 
178 
179def render_balance_declined(needy_name: str) -> str:
180 """The brief note when the owner declines to cover (SPEC §8)."""
181 return (
182 f"No problem — I'll leave {needy_name} as is. Reply any time if you "
183 "change your mind."
184 )
185 
186 
187def render_balance_could_not_cover(needy_name: str) -> str:
188 """The note when no safe coverage exists from current funds (SPEC §8)."""
189 return (
190 f"{needy_name} is over budget, but I couldn't find a safe way to cover "
191 "it from your current funds. You may want to move money in manually."
192 )
193 
194 
195def render_balance_failed(needy_name: str, reason: str) -> str:
196 """The note when an approved plan can't be applied (SPEC §8)."""
197 return (
198 f"I couldn't cover {needy_name}: {reason}. Nothing was changed — reply "
199 "and we can try another way."
200 )
201 
202 
203def render_body(request: ComposeRequest) -> str:
204 """Lay out the email body for one transaction message (SPEC §5).
205 
206 The subject is templated separately by the caller; this is the body. Every
207 purpose carries real copy for its lifecycle moment (SPEC §3 state notes) —
208 none of these may fall back to a contentless placeholder.
209 """
210 facts = _facts(request)
211 purpose = request.purpose
212 if purpose == MessagePurpose.PROPOSAL.value:
213 return _proposal_body(request, facts)
214 if purpose == MessagePurpose.CONFIRM.value:
215 category = (
216 request.decided_category
217 or request.proposed_category
218 or "the category you picked"
219 )
220 return f"{facts}\n\nDone — set to {category} and approved."
221 if purpose == MessagePurpose.FYI.value:
222 filed = (
223 f"Filed automatically as {request.decided_category}"
224 if request.decided_category
225 else "Filed automatically"
226 )
227 return (
228 f"{facts}\n\n{filed} under your standing rule and flagged in "
229 "YNAB so you can spot it. Reply here any time to change it."
230 )
231 if purpose == MessagePurpose.CLARIFY.value:
232 question = (
233 request.detail
234 or request.question
235 or "Which category should this be?"
236 )
237 return f"{facts}\n\n{question}"
238 if purpose == MessagePurpose.REVISE_SUMMARY.value:
239 updated = (
240 f"Updated — now {request.decided_category}, re-approved."
241 if request.decided_category
242 else "Updated and re-approved."
243 )
244 return f"{facts}\n\n{updated} Reply if that's not right."
245 if purpose == MessagePurpose.HANDOFF.value:
246 return (
247 f"{facts}\n\nI haven't heard back, so I'm leaving this one for "
248 "you to handle in YNAB — no more nudges from me. A reply here "
249 "still works any time."
250 )
251 if purpose == MessagePurpose.POSSIBLY_INCONSISTENT.value:
252 return (
253 f"{facts}\n\nI tried to update this in YNAB and couldn't confirm "
254 "the change landed — please check it there, and reply with what "
255 "you see so we end up in the right place."
256 )
257 if purpose == MessagePurpose.DIVERGED_READBACK.value:
258 comparison = request.detail or (
259 "YNAB now shows something different from what was asked for — "
260 "which should win? Reply with your choice and I'll set it."
261 )
262 return f"{facts}\n\n{comparison}"
263 if purpose == MessagePurpose.ARCHIVE_NOTICE.value:
264 return (
265 f"{facts}\n\nThis one is still uncategorized and its window is "
266 "closing. Reply with a category and I'll file it — or reply "
267 "'handled' if you've taken care of it."
268 )
269 if purpose == MessagePurpose.OVERRIDE_NOTICE.value:
270 what = (
271 f"you set this to {request.decided_category} yourself"
272 if request.decided_category
273 else "you recategorized this one yourself"
274 )
275 return (
276 f"{facts}\n\nNoticed {what} — I've backed off auto-handling this "
277 "payee and will go back to asking."
278 )
279 # An unmapped purpose would mean a new MessagePurpose without copy; say
280 # something honest rather than nothing.
281 note = (
282 request.detail
283 or request.question
284 or ("A quick note on this transaction.")
285 )
286 return f"{facts}\n\n{note}"

Finally, interpret accepts a missing proposal. A flagged verify-failure entry or a NeedsHuman wait has no proposal; the old code answered every reply with the same canned question — an infinite loop. Now a reply that names a category commits it, and only a bare "ok" (nothing to approve) asks.

Approve-with-nothing-to-approve asks; a named category commits — flagged entries are answerable by email.

src/ynab_agent/agentic/interpret.py · 193 lines
src/ynab_agent/agentic/interpret.py193 lines · Python
⋯ 148 lines hidden (lines 1–148)
1"""The reply-interpreting agent: what did the human mean? (SPEC §3, §5).
2 
3The agentic half of W2's ``interpret_inbound``. Given a human's free-text reply
4on a transaction's thread, the model reads the *intent*: approve the current
5proposal, switch to a different category, or — when unclear — ask one clarifying
6question. The model only classifies intent and (for a switch) picks a category;
7the deterministic spine assembles the ``Decision`` (stamping the human as
8decider and the time), so the model never fabricates an approval timestamp or
9an allocation the spine cannot resolve.
10 
11:func:`to_reply_outcome` maps the intent onto the domain ``ReplyOutcome``,
12defaulting to a clarifying question whenever a switch arrives without a category
13— the safe move is always to ask, never to guess a write.
14"""
15 
16from __future__ import annotations
17 
18from enum import StrEnum
19from typing import TYPE_CHECKING, assert_never
20 
21from pydantic import Field
22from pydantic_ai import Agent
23 
24from ynab_agent.agentic.enrich import CandidateCategory
25from ynab_agent.agentic.model import run_structured
26from ynab_agent.domain.allocations import ResolvedCategory
27from ynab_agent.domain.base import Frozen
28from ynab_agent.domain.enums import DecidedBy
29from ynab_agent.domain.ids import CategoryId
30from ynab_agent.domain.proposal import Decision
31from ynab_agent.workflow.types import (
32 AnswerOutcome,
33 ClarifyOutcome,
34 ReplyOutcome,
36 
37if TYPE_CHECKING:
38 import datetime
39 
40 from pydantic_ai.models import Model
41 
42 
43class ReplyIntent(StrEnum):
44 """What a human's reply asked for."""
45 
46 APPROVE = "approve"
47 RECATEGORIZE = "recategorize"
48 CLARIFY = "clarify"
49 
50 
51class InterpretRequest(Frozen):
52 """A human reply and the context needed to read its intent.
53 
54 ``proposed_category_name`` is ``None`` when there is no live proposal (a
55 flagged verify-failure entry, a NeedsHuman wait). The owner can still name
56 a category outright; only a bare "approve" has nothing to approve then.
57 """
58 
59 reply_text: str
60 payee: str
61 amount_display: str
62 proposed_category_name: str | None = None
63 candidates: tuple[CandidateCategory, ...] = Field(min_length=1)
64 
65 
66class Interpretation(Frozen):
67 """The agent's read of the reply (mapped to a domain ReplyOutcome)."""
68 
69 intent: ReplyIntent
70 category_id: str | None = None
71 question: str | None = None
72 memo: str | None = None
73 
74 
75_SYSTEM_PROMPT = """\
76You read one reply a human sent about a proposed transaction categorization. You
77are given the reply text, the payee and amount, the category currently proposed,
78and the candidate categories (id + name).
79 
80Classify the reply's intent: `approve` if they accept the current proposal (e.g.
81"ok", "yes", "sounds good"); `recategorize` if they name or describe a category
82— then set `category_id` to the matching candidate id; or `clarify` if the reply
83is ambiguous or asks a question — then set a short `question` to send back. When
84in doubt, prefer `clarify`: asking again is safe, a wrong write is not.
85 
86There may be NO current proposal (shown as "(none)"): the agent asked an open
87question rather than proposing. A bare yes/ok then has nothing to approve —
88classify it `clarify`; a reply that names a category is still `recategorize`.
89 
90Also set `memo`: if the reply gives any *context or reasoning* beyond the bare
91category — what it was for, who it was for, an occasion ("gift for mom", "kids'
92soccer", "their shopping") — distil it into a short, factual memo (≤ a sentence)
93that will be saved on the transaction. If the reply is only a bare confirmation
94or category with no added context, leave `memo` null. Never invent detail."""
95 
96_AGENT: Agent[None, Interpretation] = Agent(
97 output_type=Interpretation,
98 system_prompt=_SYSTEM_PROMPT,
100 
101 
102def _format_request(request: InterpretRequest) -> str:
103 """Render the request as the agent's user prompt."""
104 lines = [
105 f"Reply: {request.reply_text}",
106 f"Payee: {request.payee}",
107 f"Amount: {request.amount_display}",
108 f"Currently proposed: {request.proposed_category_name or '(none)'}",
109 "Candidate categories:",
110 ]
111 lines.extend(f" - {c.name} (id: {c.id})" for c in request.candidates)
112 return "\n".join(lines)
113 
114 
115async def interpret(
116 request: InterpretRequest, *, model: Model | None = None
117) -> Interpretation:
118 """Run the reply-interpreting agent for one reply (SPEC §3).
119 
120 Args:
121 request: The reply text and its categorization context.
122 model: A model to use; defaults to the configured Ollama/Gemma.
123 
124 Returns:
125 The agent's structured reading of the reply.
126 """
127 return await run_structured(
128 _AGENT,
129 _format_request(request),
130 output_type=Interpretation,
131 model=model,
132 )
133 
134 
135def _human_decision(
136 category: CategoryId,
137 decided_at: datetime.datetime,
138 *,
139 memo: str | None = None,
140) -> Decision:
141 return Decision(
142 allocation=ResolvedCategory(category=category),
143 memo=memo,
144 approved=True,
145 decided_by=DecidedBy.HUMAN,
146 decided_at=decided_at,
147 )
148 
149 
150def to_reply_outcome(
151 interpretation: Interpretation,
152 *,
153 proposed_category: CategoryId | None,
154 decided_at: datetime.datetime,
155) -> ReplyOutcome:
156 """Map the agent's reading onto a domain ReplyOutcome (SPEC §3, §14.4).
157 
158 ``approve`` commits the proposed category (or asks, when nothing was
159 proposed — there is nothing to approve); ``recategorize`` commits the named
160 one (or asks, if none was given); ``clarify`` sends the question back. Any
161 rationale the reply carried rides along as the decision's ``memo`` (the
162 spine writes it to YNAB). The spine, not the model, sets decider and time.
163 """
164 match interpretation.intent:
165 case ReplyIntent.APPROVE:
166 if proposed_category is None:
167 return ClarifyOutcome(
168 question=(
169 "There's no pending suggestion to approve here — "
170 "which category should this be?"
171 )
172 )
173 return AnswerOutcome(
174 decision=_human_decision(
175 proposed_category, decided_at, memo=interpretation.memo
176 )
177 )
178 case ReplyIntent.RECATEGORIZE:
179 if interpretation.category_id:
180 return AnswerOutcome(
181 decision=_human_decision(
182 CategoryId(interpretation.category_id),
183 decided_at,
⋯ 10 lines hidden (lines 184–193)
184 memo=interpretation.memo,
185 )
186 )
187 return ClarifyOutcome(question="Which category should this be?")
188 case ReplyIntent.CLARIFY:
189 return ClarifyOutcome(
190 question=interpretation.question
191 or "Could you clarify how you'd like this categorized?"
192 )
193 assert_never(interpretation.intent)