Silently-ignored OUTGOING_PAYMENT.* events — Reconciliation & alerting

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 uncatalogued OUTGOING_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.

1 · What's in the code today

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

providerEvents PK _id provider string eventId string eventType string "OUTGOING_PAYMENT.X" family money_movement|verification|ignored providerTxnRef string? status received|applied|ignored|failed receivedAt number indexes: by_provider_event · by_transaction · by_subject no index on family / eventType / receivedAt offramps PK offrampId provider string FK providerTxnRef → provider txn status provider_submitted / provider_processing (non-terminal) version number updatedAt number indexes: by_offramp_id · by_subject · by_provider_transaction by_status ✓ present — but no reader anywhere

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 (:976null) 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,34reportError 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

Grid parseWebhook receiver + journal offramp OUTGOING_PAYMENT.NEW_TOKEN sig verifies ✓ mapWebhookStatus → null family: "ignored" journaled ignored no page · no reconcile provider_submitted frozen — forever The money already moved at Grid; our state never learns it settled or failed.

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

ignored receipt eventType starts withOUTGOING_PAYMENT.? reportError (page) benign · silent yes no offramp: unchanged

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

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

Flow

cron (15 min) reconcile action provider (Grid) offramp fire listNonTerminalOfframps (by_status, updatedAt < now − 10 min) getProviderTransaction status: completed / failed synthesize event → applyVerifiedEvent settled / failed ✓

New surface

moneyMovement/
  reconciliation.ts               new  internalAction reconcileNonTerminalOfframps
    ├─ runQuery listNonTerminalOfframps      (offramps.by_status, age-gated)
    ├─ per offramp: build orchestrator (same deps as webhookHttp.ts:180)
    ├─ getProviderTransaction({ transactionRef })
    ├─ synthesize ProviderWebhookEvent { family: money_movement, transactionStatus }
    └─ applyVerifiedEvent(event, { offrampId, expectedStatus, expectedVersion })
  queries.ts                            +  internalQuery listNonTerminalOfframps
  crons.ts                              +  crons.interval(... reconciliation ...)

Pros & cons

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

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

  1. Cron target — a new internalAction in moneyMovement/reconciliation.ts; getProviderTransaction needs action context, so it cannot be a query/mutation.
  2. Scan — new internalQuery listNonTerminalOfframps over the existing offramps.by_status index, for provider_submitted + provider_processing.
  3. 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).
  4. Capability gate — skip providers where capabilities().completion !== "provider_webhook"; directTransfer's chain_receipt has no authoritative read (directTransfer.ts:158).
  5. Reuse, don't reinvent — the action synthesizes a ProviderWebhookEvent from the read and calls the existing applyVerifiedEvent; no new transition logic, no orchestrator change.
  6. Alert channel — the existing reportError ([REPORT] today, inherits PostHog capture when that slice lands). No new pager.
  7. 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.
  8. Cadencecrons.interval({ minutes: 15 }); cheap, and the age gate makes the interval non-load-bearing.

Blast radius — files the implementer touches

FileChange~LOC
moneyMovement/reconciliation.ts new internalAction: scan → capability-gate → getProviderTransaction → synthesize event → applyVerifiedEvent; divergence reportError +130
moneyMovement/queries.ts internalQuery listNonTerminalOfframps (by_status, age-gated) +30
crons.ts crons.interval → the reconcile action +8
webhookHttp.ts money-shaped alert inside the §17.5 ignored fan-out (coordinated w/ #763) +12
__tests__/reconciliation.test.ts new hermetic: seed provider_submitted → fake completed → run action → assert settled; divergence + capability-skip cases +150
docs/planning/PROVIDER-MODULE-DESIGN-740.md §17.5 acceptance: ignored OUTGOING_PAYMENT.* MUST reportError +10
.changeset/*.md new 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

  1. Cron unit/behaviour test in __tests__/reconciliation.test.ts — patterned on webhookReceiver.test.ts's persistenceFor + fake-override harness.
  2. Alert testsetReporter 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).
  3. (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)

  1. 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.
  2. 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.
  3. 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.
  4. 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.