Repository navigation
Description
Problem
In pkgs/id/src/currency_poc, neither the executor's submission path nor the recovery scanner checks that LedgerBackend::create_transfers returned one result per leg. A backend that returns a short — or empty — result vector is treated as complete success, and the conversion is persisted as Posted with nothing (or only part) in the ledger.
executor.rs:264-282—submit()iteratesrecord.legs.iter().zip(results.iter())(line 270).zipstops at the shorter side, so legs with no corresponding result are never examined; the record then advances toIntentState::Postedat line 282 unconditionally.recovery.rs:111-124— theResubmitpath computesfailedby scanningresultsfor a non-Ok/Existsentry. An empty vector has no failing entry, so the record is persistedPostedand reportedReconcileOutcome::Resubmitted.ledger.rs:134— theLedgerBackend::create_transferstrait contract documents the linked-chain rule a production binding must implement, but never states that the returned vector must carry exactly one result per submitted transfer. The in-repoInMemoryLedgerhappens to returnbatch.len()results; nothing enforces that for the future TigerBeetle client binding this seam exists for (id: PoC: conversion executor — linked transfer pairs, write-ahead intent, recovery scanner #2289).
This is the same vacuous-success shape the module already hardened against once: #4133 fixed reconcile marking an empty/duplicate leg vector Posted because matched == legs.len() held vacuously (0 == 0). The result-vector side was left unchecked — a short result vector makes "no leg failed" true vacuously in both callers.
Verified this run (laptop probe, verbatim sources @ 0428638)
Probe crate ~/git/fleet/probe-2026-11-02-laneA-shortresults/ on the laptop compiles the verbatim currency_poc sources (sha256-matched: executor.rs d6ad6d84…, recovery.rs 3d96f415…) against a SilentBackend whose create_transfers drops the batch, posts nothing, and returns Vec::new(), with accounts/balances delegated to a funded InMemoryLedger:
- Control: real
InMemoryLedger→execute()returnsPosted, 3 legs findable byuser_data_128 = 777. - Executor probe:
execute()returnedOkwith record statePostedwhile 0 legs were in the ledger. - Recovery probe: a genuine
Recordedrecord (legs built bybuild_legs) reconciled underRecoveryPolicy::Resubmitagainst the silent backend → outcomeResubmitted, record statePosted, 0 legs in the ledger.
cargo test --offline on the probe: 2 passed (both probes assert the buggy outcome).
Why it matters
The intent record is the recovery source of truth. A conversion marked Posted that never posted is invisible to the recovery scanner afterwards (the record is resolved), so the user's source funds never move and no operator signal fires — the worst failure mode for a write-ahead design whose whole point (#2289, #2063) is that state on disk never claims more than the ledger proves. A real client binding that batches, pages, or errors per-request rather than per-transfer can plausibly return a short vector exactly when something else already went wrong.
Acceptance criteria
-
ConversionExecutor::submitfails (record staysSubmitted, error names the conversion and the expected/actual result counts) whenresults.len() != record.legs.len(). -
reconcile's resubmission path treats a short result vector as a rejected resubmission (ResubmissionRejected, record left unresolved, scan continues) rather thanResubmitted/Posted. - The
LedgerBackend::create_transferstrait doc states the one-result-per-transfer contract explicitly. - Regression tests in
currency_poc/tests.rscover a short-result backend (empty and partial, e.g. 1 of 3) for bothsubmitand the recovery resubmission path; existing scenario tests still pass.
Related: #4133 (same vacuous-success class, leg-vector side), #2289 (the PoC), #2063 (design). Distinct from #4578 (FileIntentStore listing).
Activity
dezren39 commentedon Nov 3, 2026
🤖 Agent note · filed
~/git/fleet/probe-2026-11-02-laneA-shortresults/(laptop); related issue id(currency_poc): reconcile counts legs per entry, not per distinct transfer — an empty or duplicate leg list is marked Posted with nothing (or half) in the ledger #4133, issue id: PoC: conversion executor — linked transfer pairs, write-ahead intent, recovery scanner #2289dezren39 commentedon Nov 3, 2026
🤖 Agent note · triaged — CONFIRMED on current main
pkgs/id/src/currency_poc/executor.rs:289-290—create_transfersresults are consumed withrecord.legs.iter().zip(results.iter())and noresults.len() == legs.len()check anywhere in submit();IntentState::Postedis persisted unconditionally after the loop. Legs beyond the result vector are never examined.pkgs/id/src/currency_poc/recovery.rs(Resubmit path, present==0) — the failure scan iteratesresultsonly, so an empty/short vector contains no failing entry and the record is persisted Posted as Resubmitted.pkgs/id/src/currency_poc/ledger.rs— theLedgerBackend::create_transferscontract documents the linked-chain rule but no one-result-per-transfer requirement.60 remaining items
dezren39 commentedon Nov 4, 2026
🤖 Agent note · parked
dezren39 commentedon Nov 4, 2026
🤖 Agent note · handed off
opencodeper §1b — handed off to the opencode claimant, not worked directly. Revalidated fresh: issue id(currency_poc): executor submit() and recovery resubmission treat a short create_transfers result vector as success — a backend returning no results marks the conversion Posted with nothing in the ledger #4669 OPEN; PR id(currency_poc): reject short create_transfers result vectors #4701 OPEN, author dezren39 (Drew) — Drew-owned PR hard stop applies: do not alter, review, merge, or duplicate it. AT CAP: attempt 24/24 — if the lease lapses unbound, the task becomes unclaimable without a cap raise.dezren39 commentedon Nov 5, 2026
🤖 Agent note · handed off