Follow-up from the #761 Lightspark Grid adapter review · Grounded in current code state ·
Three options · For decision before implementer dispatch
The Grid adapter classifies an uncataloguedOUTGOING_PAYMENT.<token> — a money-movement status Grid may add after we
ship — as family: "ignored", the same bucket as benign
CUSTOMER.* / VERIFICATION.* events
(lightspark.ts:939-950, slice/761). That was the correct fix for an earlier
"unknown token wedges the receiver" bug — but it trades a wedge for a
silent stall: if Grid ships a new terminal token before we update the
adapter, a real settlement transition lands as an ignored receipt, nothing pages, and the
offramp can sit in provider_submitted / provider_processing
forever with no reconciliation.
The question is what to build and where: an alert that distinguishes a
money-shaped ignore from a benign one, an independent reconcile cron that
un-strands non-terminal offramps via the authoritative getProviderTransaction
read, or both — and how it coordinates with the #763 webhook-receiver contract that already
owns "records ignored events" and "reconciles authoritative facts". This page maps the code
as it is today, presents three genuinely different shapes with diagrams, scores them on an
equal-weight rubric, recommends one with a file-by-file blast radius, and ends with an
acceptance gate you can run.
Two tables carry the state. providerEvents journals every webhook — including
its real eventType and family — so the discriminator we need
("ignored and money-shaped") is already recoverable with no new
field. offramps holds the durable money state and already has a
by_status index — which no code uses.
Current schema / shape
schema.ts:555-574 (providerEvents) & schema.ts:469-499
(offramps). The by_status index exists at schema.ts:498; grep finds
zero withIndex("by_status") readers on this table.
The inconsistency
Concrete gaps, with file:line
lightspark.ts:939-950 — an OUTGOING_PAYMENT.<unknown> whose
token misses mapWebhookStatus (:976 → null) returns
family: "ignored", byte-identical in family to a CUSTOMER.*
ignore. The reason string differs, but nothing consumes it.
webhookHttp.ts:139-152 — the receiver still fails closed on any
family !== "money_movement" (documented #763 temporary limit,
:18-22). The risk fires the moment §17.5's "records ignored events" fan-out
relaxes this to a 200 + durable ignored receipt.
_shared/init.ts:22-26,34 — reportError currently only
console.error("[REPORT]", …); no PostHog/Sentry/pager is wired. It is the
one operator channel every other fault already uses.
cashOutOrchestrator.ts:1409-1571 — the entire status-transition ladder is
reachable from exactly one caller (webhookHttp.ts:196) and is
strictly event-driven: there is no offramp-driven reconcile entry point.
A stranded offramp has nothing that re-checks it.
Today's flow — the silent stall
A verified, real settlement transition dead-ends as an ignored receipt. The offramp stays
non-terminal with no path back to settled / failed.
What the code tells us:
The detection data already exists — providerEvents.eventType +
family. No migration. The problem is that nobody reads it as "money-shaped
ignore".
The remediation primitives already exist — offramps.by_status for
the scan, getProviderTransaction for the authoritative read, and the full
transition ladder in applyVerifiedEvent. What's missing is an
offramp-driven caller; the ladder only fires on an inbound event.
The alert path is entangled with #763: today's receiver fails closed on ignored events,
so an alert can't fire end-to-end until the §17.5 fan-out lands. The reconcile cron is
independent — it works against present tables now.
2 · The three options
Each option shows its data touch, the lifecycle it drives, the new backend surface, and how
it is exercised. All three land on claude/epic-jepsen-1077af (the reconciliation
base that slice/761 sits on top of), not on the adapter slice.
Option A — Alert at the seam (detect, don't remediate)
When an ignored receipt is finalized (the §17.5 fan-out), check
eventType.startsWith("OUTGOING_PAYMENT."); if so, reportError a
distinct "money-shaped event ignored — catalog is stale" fault. No cron, no money-state
writes. It tells an operator to update mapWebhookStatus, fast — but the stuck
offramp stays stuck until someone acts.
Data touch
Read-only over providerEvents (the receipt already being written). No new
table, no new index, no offramp writes.
Lifecycle
New surface
moneyMovement/
webhookHttp.ts ignored-fan-out branch (part of #763 §17.5)
└─ if eventType.startsWith("OUTGOING_PAYMENT.")
void reportError(new Error("money-shaped event ignored — stale catalog"),
{ source, phase: "ignoredMoneyShaped", eventType })
Exercised by
# hermetic: force an ignored money-shaped receipt, spy the reporter
setReporter(spy); postWebhook("OUTGOING_PAYMENT.NEW_TOKEN")
expect(spy).toHaveBeenCalledWith(/stale catalog/, { phase: "ignoredMoneyShaped" })
Pros & cons
Pros
Cons
Tiny, reversible, zero money-state writes.
Fastest possible time-to-detect a new Grid token.
Fits §17.5 exactly — it's a one-line guard inside the fan-out.
Never un-strands money — only signals.
Dormant until the §17.5 fan-out lands; can't fire end-to-end today.
Blind to offramps stranded by other causes (dropped webhook, provider
silence).
Option B — Reconcile cron (remediate, don't specifically alert)
An internalAction runs on an interval, scans non-terminal offramps via the
unused by_status index, gates on
capabilities.completion === "provider_webhook", reads
getProviderTransaction, synthesizes a ProviderWebhookEvent from
the authoritative fact, and feeds the existing applyVerifiedEvent ladder. It
un-strands money from any cause — unknown token, dropped webhook, silent
provider — but you learn about a stale catalog only indirectly (settlements lag, the cron
drives them late).
Data touch
Reads offramps.by_status (existing index). Writes offramp state + a
reconcile op row — the same journaled path the webhook settle already uses
(cashOutOrchestrator.ts:1457-1512). No schema change.
Actually recovers stranded money — the real failure mode.
Cause-agnostic: covers unknown tokens and dropped webhooks.
Provable now against present tables + the fake provider.
Reuses the whole transition ladder — no new money logic.
Introduces an autonomous money-state-writing cron — bigger blast
radius.
Slow, indirect signal for a stale catalog (no fast page).
Duplicates §17.6's "reconcile before settlement" out-of-band unless coordinated.
Must capability-gate (directTransfer chain_receipt throws).
Option C — Both, as the #763 receiver-finalization
Defense in depth, coordinated with the receiver contract rather than bolted on:
(1) the money-shaped alert (A) is folded into §17.5's ignored
fan-out as a hard acceptance criterion — the fan-out cannot ship without it;
(2) the reconcile cron (B) lands now on epic-jepsen as §17.6's
out-of-band safety net, capability-gated. The alert gives the fast "your catalog is stale"
page; the cron guarantees no offramp strands even in the window before an operator acts.
Data touch
Union of A + B: read-only on providerEvents for the alert; existing
by_status scan + journaled reconcile writes for the cron. Still no schema
change.
Coverage matrix
A detects fast but never recovers; B recovers everything but signals slowly; C gets both.
New surface (the difference vs B)
= Option B (reconciliation.ts + listNonTerminalOfframps + crons.interval)
+ Option A alert wired into the §17.5 ignored fan-out
+ docs/planning/PROVIDER-MODULE-DESIGN-740.md §17.5 acceptance: "ignored
OUTGOING_PAYMENT.* MUST reportError" (so #763 cannot regress it)
Pros & cons
Pros
Cons
Fast page and guaranteed recovery — neither leaks.
Explicitly realizes §17.5 + §17.6 instead of racing them.
Cron-half ships + proves now; alert-half is a locked acceptance criterion for #763.
Cron's divergence log is a second backstop if the alert regresses.
Most work of the three (still modest — ~7 files).
Alert-half's live proof waits on the #763 fan-out slice.
Two mechanisms to keep coherent as the receiver evolves.
3 · Comparison & scorecard
Five criteria, equal weight, constructed for this decision. Three dots = excellent, two =
good, one = weak. Total out of 15.
Criterion
A — alert
B — cron
C — both ★
Un-strands money — does it recover a stuck offramp, not just notice?
●○○
●●●
●●●
Time-to-detect a new token — how fast an operator learns the catalog is stale.
●●●
●○○
●●●
Money-write safety / blast radius — does it add autonomous settlement writes?
●●●
●○○
●●○
Provable now — not blocked on the #763 ignored fan-out.
●○○
●●●
●●○
Fits §17.5 / §17.6 — realizes the receiver contract vs bolted-on.
●●○
●○○
●●●
TOTAL
10 / 15
9 / 15
13 / 15
Why C sweeps:
A and B each own one half of the problem and leak the other — A never recovers money,
B never pages fast. The failure the review is actually about (money silently stranded)
needs both a signal and a net.
C's only real cost is B's autonomous-write blast radius, and that is bounded: it reuses
the existing journaled reconcile op and CAS-versioned transitions, so a
cron run is idempotent and can't double-settle.
Coordinating with #763 turns the alert from a bolt-on into a contract obligation — the
receiver literally cannot ship its ignored fan-out without it, closing the gap the review
flagged permanently rather than patching this one adapter.
4 · Recommendation & blast radius
Option C. Build the reconcile cron (B) now on
claude/epic-jepsen-1077af where it's independently provable, and lock the
money-shaped alert (A) as a §17.5 acceptance criterion so the #763 receiver-finalization
slice can't ship the ignored fan-out without it.
Concrete decisions baked into the spec
Cron target — a new internalAction in moneyMovement/reconciliation.ts; getProviderTransaction needs action context, so it cannot be a query/mutation.
Scan — new internalQuery listNonTerminalOfframps over the existing offramps.by_status index, for provider_submitted + provider_processing.
Age gate — only offramps with updatedAt < now − 10 min, so a live webhook wins the race first and the cron never fights fresh deliveries (mirrors UNRESOLVED_REPORT_MIN_AGE_MS at webhookHttp.ts:58).
Capability gate — skip providers where capabilities().completion !== "provider_webhook"; directTransfer's chain_receipt has no authoritative read (directTransfer.ts:158).
Reuse, don't reinvent — the action synthesizes a ProviderWebhookEvent from the read and calls the existing applyVerifiedEvent; no new transition logic, no orchestrator change.
Alert channel — the existing reportError ([REPORT] today, inherits PostHog capture when that slice lands). No new pager.
Alert home — inside the §17.5 ignored fan-out branch in webhookHttp.ts, gated on eventType.startsWith("OUTGOING_PAYMENT."); filed as a #763 acceptance criterion, not shipped blind here.
Cadence — crons.interval({ minutes: 15 }); cheap, and the age gate makes the interval non-load-bearing.
§17.5 acceptance: ignored OUTGOING_PAYMENT.* MUST reportError
+10
.changeset/*.mdnew
changeset entry
+10
Total
~7 files · ~350 LOC
Out of scope: no new schema/index (both indexes exist), no new pager, no change to the
adapter's ignored classification (that fix stays), no live Grid-sandbox proof of the alert
(hermetic here; live proof rides the #763 fan-out slice).
5 · Acceptance gate
Patterned on __tests__/webhookReceiver.test.ts (convex-test + the singleton fake
provider). The cron-half proves live-in-test today; the alert-half proves hermetically now
and rides #763 for its end-to-end proof. Runner: vp test <path>
(package.json:9).
The walkthrough
# 1 — cron un-strands a stalled offramp (no webhook delivered)
seed offramp → provider_submitted # driveToSubmission(), webhookReceiver.test.ts:164
fake.setTransactionStatus(ref, "completed") # fake.ts:289 — authoritative read only
await t.action(internal.moneyMovement.reconciliation.reconcileNonTerminalOfframps, {})
expect(offramp).toMatchObject({ status: "settled" }) # ladder ran, no event# 2 — idempotent + age gate: fresh offramp is skipped, second run is a no-op
seed offramp updatedAt = now → not returned by listNonTerminalOfframps
run twice on a settled offramp → no double-settle (CAS version guard)
# 3 — capability gate: chain_receipt provider is skipped, not thrown
directTransfer offramp in provider_submitted → skipped (completion !== "provider_webhook")
# 4 — alert (hermetic): money-shaped ignore pages; benign ignore is silent
setReporter(spy); finalize ignored eventType="OUTGOING_PAYMENT.NEW_TOKEN"
expect(spy).toHaveBeenCalledWith(match(/stale catalog/), { phase: "ignoredMoneyShaped" })
finalize ignored eventType="CUSTOMER.UPDATED" → expect(spy).not.toHaveBeenCalled()
What gets automated
Cron unit/behaviour test in __tests__/reconciliation.test.ts — patterned on webhookReceiver.test.ts's persistenceFor + fake-override harness.
Alert test — setReporter spy (init.ts:28) asserting the money-shaped vs benign split; the first test to cover the family: "ignored" path at all (none exists today).
(Follow-up on #763) — the §17.5 fan-out slice inherits the alert as an acceptance criterion and adds the live Grid-sandbox proof.
6 · Open questions for the implementer (defaults stated)
Age threshold before the cron touches an offramp:
10 min ← recommended. Long enough that a normal webhook settles first; matches the receiver's existing race window.
1 min — more aggressive recovery, but risks racing live deliveries and doubling provider-read load.
What the cron does on an unexpected provider status (e.g. read says "processing" but no transition applies):
reportError + leave the offramp ← recommended. The cron is a net, not an authority; surface the divergence, don't force state.
Silently skip — loses the signal that a genuinely-stuck offramp exists.
Should the cron also emit the money-shaped ignore alert (fold A fully into B)?
No — keep A at the receiver seam ← recommended. The seam pages in seconds; the cron would page on the next tick. Cron divergence log is the backstop.
Yes — one mechanism, but slower detection and couples two concerns.
Batch size per cron run:
Bounded page (e.g. 50) oldest-first ← recommended. Predictable provider-read load; the next tick picks up the rest.
Unbounded .collect() — simpler, fine at current volume, but an unbounded scan is a latent footgun.
7 · Decision
╭─────────────────────────────────────────────────────────────────────────╮
│ Ignored OUTGOING_PAYMENT.* reconciliation · Final Recommendation │
├─────────────────────────────────────────────────────────────────────────┤
│ Score A:10 B:9 C:13 ★ (out of 15) │
│ Recommended C — reconcile cron now + money-shaped alert as #763 gate │
│ Blast radius ~7 files · ~350 LOC · no schema change │
│ Acceptance hermetic convex-test: cron un-strands; alert splits │
│ money-shaped vs benign ignores │
├─────────────────────────────────────────────────────────────────────────┤
│ NEXT GATE — pick: │
│ (1) Confirm Option C; produce implementer handoff & dispatch agent │
│ (2) Pick A or B instead (state which) │
│ (3) Modify Option C (e.g. change age gate, batch, or alert home) │
│ (4) Dig into a specific section first (ladder, capability gate, #763) │
╰─────────────────────────────────────────────────────────────────────────╯
After your call, I tighten this into a file-level implementer handoff with the exact test
cases as the agent's prompt. The agent builds; you walk the acceptance gate; if it passes,
we ship.