What changed, and why
A blank Amazon transaction parks in HOLD_AMAZON to wait for Amazon's daily item-detail sync (principle 5: "wait, don't race, on Amazon"). The hold is meant to exit early "if a receipt matches or the memo backfills." The receipt path works. The memo-backfill path was dead.
A held transaction stays unapproved, so W1 re-addresses it every tick. But address_transaction only starts a W2 — a run already in flight raises WorkflowAlreadyStartedError and the activity returns. Nothing ever delivered the W2's notify_snapshot signal. So a held Amazon txn waited out the full ~36h deadline, even though the memo lands hours earlier at the 02:00 sync.
The W2 side was already built — _on_hold consumes notify_snapshot to resolve the hold. The missing half was W1 actually calling it. This change adds that call, gated on the one condition that makes it meaningful.
The plan decision
W1's planner is pure and stateless per tick. It can't observe the empty→present transition of a memo; it only sees the current snapshot. So "is this a backfill to deliver?" reduces to: an Amazon payee that currently carries a memo. That's memo_backfilled, surfaced as notify_existing on the action.
notify_existing rides on the action; is_amazon/memo_backfilled are the pure predicates, and plan_ingest sets the flag.
src/ynab_agent/ingest/plan.py · 105 lines
⋯ 33 lines hidden (lines 1–33)
⋯ 23 lines hidden (lines 73–95)
⋯ 1 line hidden (lines 105–105)
W1 delivers the signal
On the already-running path, when the action is flagged, W1 signals the existing W2's notify_snapshot with the fresh (memo-carrying) snapshot. A settled run can no longer be signalled, so the RPCError is suppressed and the next tick re-tries while the txn stays unapproved — the ~36h deadline remains the backstop.
The one exception to the start-is-a-no-op rule: signal the running W2 on an Amazon memo backfill.
src/ynab_agent/workflow/poll_activities.py · 80 lines
⋯ 54 lines hidden (lines 1–54)
One predicate, two callers — and the W2 side it wakes
W1's signal-gate and W2's hold-entry must agree on what counts as Amazon, or a txn could enter the hold (W2's view) that W1 never signals (or vice versa). So is_amazon is promoted to ingest.plan and shared: W2's _hold_for_amazon now calls the same predicate.
The hold-entry test, now sharing W1's is_amazon — a single source of truth (SPEC §11 leaves the patterns open).
src/ynab_agent/workflow/txn_workflow.py · 549 lines
⋯ 115 lines hidden (lines 1–115)
⋯ 426 lines hidden (lines 124–549)
The receiving side was already there. _on_hold waits on _snapshot_ready; the notify_snapshot signal sets it, and the hold resolves to ENRICHING carrying the backfilled snapshot — exactly the early exit the SPEC describes.
Pre-existing W2 mechanism: a delivered snapshot resolves the hold to its memo, short of the deadline.
src/ynab_agent/workflow/txn_workflow.py · 549 lines
⋯ 386 lines hidden (lines 1–386)
⋯ 149 lines hidden (lines 401–549)
Tests
Each layer is pinned. The pure planner sets notify_existing only for Amazon + memo. The W1 activity (with a fake client) signals notify_snapshot on the already-running path when flagged, and leaves a running W2 untouched otherwise.
memo_backfilled only for Amazon-with-memo; the plan flags it.
tests/ingest/test_plan.py · 135 lines
⋯ 107 lines hidden (lines 1–107)
The end-to-end test proves the resolution path is distinguishable from the deadline fallback: it asserts enrichment ran on the snapshot with the memo. The deadline path would have enriched the blank held snapshot, so the memo assertion can only pass if the signal resolved the hold.
A HOLD_AMAZON run resolves on the backfill signal; enrichment sees the item detail.