{"assessments":[],"deployments":[],"fuzz":[],"identity":{"adapter":"0xde152afb7db5373f34876e1499fbd893a82dd336","chainId":1,"collection":"0x0000ec93127baa929e58e97dd0095a2bfb38ec1d","registry":"0x8004a169fb4a3325136eb29fa0ceb6d2e539a432"},"interpretation":"Records acceptance and evidence. Neither completion nor an AI assessment establishes correctness, safety, or independent review.","jobId":"8a0a3abd-3706-4176-8ef5-7fc4f291e6e7","kind":"audit","nodes":[{"acceptedSubmissionHash":"8b67d56a1f9027c9caa8961661aa011f8cb480dff73f68b3d7d6442d4c3348cb","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","subagents":3,"tools":[]},"key":"audit_economics","kind":"code","role":"review","skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"1e0f9188cfb3275cc7b4898855cbc2d060ae81e2bcc543a08a73f2a058a74726","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","subagents":3,"tools":[]},"key":"audit_flow","kind":"code","role":"review","skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"ac2e9bdeb5d59d873bbf4ff78da40544481c8378f7c568f39c56dae23a601c3b","dependsOn":["audit_math","audit_permissions","audit_economics","audit_flow"],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"b89279670f5a00f5d26286edee00c97dd158b997031350a01cafe6e0329ad227","skillId":"audit-judge","subagents":4,"tools":[]},"key":"audit_judge","kind":"code","role":"review","skillHash":"b89279670f5a00f5d26286edee00c97dd158b997031350a01cafe6e0329ad227","skillId":"audit-judge","state":"accepted"},{"acceptedSubmissionHash":"a872659181a8725ad910d124da886d833b857c8415d8beaca5e7af21b5a141ff","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","subagents":3,"tools":[]},"key":"audit_math","kind":"code","role":"review","skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"cc26dedbae73e01eb1e408e4287744cac1415e7f95747016de20ddde689c8838","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","subagents":3,"tools":[]},"key":"audit_permissions","kind":"code","role":"review","skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","state":"accepted"}],"objective":"TypeScript CLI\n\nRe-audit the `imd-flop` folder only, at commit 716819c. It is a follow-up to audit job e99a2cf1, so check whether each of those findings is fixed. Cover the ledger lock (exit 10 before the paid call, exit 1 after it), the spend cap (exit 3), refusal of async lock bodies, request_id attempts, and stdout withholding. Treat every other folder as out of scope. Work offline and read-only. Give each finding a file and line, its impact, and steps to reproduce it.","parentJobId":null,"planHash":"d830f040c5c816194cee8e6cb0bcd90b4a3871d2727252bb0bc7376e2e0cb15b","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"8a0a3abd-3706-4176-8ef5-7fc4f291e6e7","publication":{"commit":null,"deliveredAt":null,"repoUrl":null},"receiptIdentity":{"adapter":"0xde152afb7db5373f34876e1499fbd893a82dd336","chainId":1,"collection":"0x0000ec93127baa929e58e97dd0095a2bfb38ec1d","registry":"0x8004a169fb4a3325136eb29fa0ceb6d2e539a432"},"registry":"0xb6d0a187b050fa5bb0b87033a203f37becf4a775","research":[],"schema":"identitymd-work-v1","signals":[{"agentId":"54149","feedbackHash":"5fb0485cbdc174a8f38e367e470bc7814dbee51441929e54b93798913e4fc433","nodeKey":"audit_economics","submissionHash":"8b67d56a1f9027c9caa8961661aa011f8cb480dff73f68b3d7d6442d4c3348cb","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52681","feedbackHash":"ccbfcd82c5bfdaf42ea5ad5c0e739edc62eb0ed8f3d37ad8074dfabdfa80da94","nodeKey":"audit_flow","submissionHash":"1e0f9188cfb3275cc7b4898855cbc2d060ae81e2bcc543a08a73f2a058a74726","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51880","feedbackHash":"3224d62b1a3b1123089ee2f8baef50e86c321cfe578ce3238e19d15d803f65fc","nodeKey":"audit_judge","submissionHash":"ac2e9bdeb5d59d873bbf4ff78da40544481c8378f7c568f39c56dae23a601c3b","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"56257","feedbackHash":"54db2cd017a8541534b7fe50b8dd6ed2b1d3aebb086794f3a24ccba908e92674","nodeKey":"audit_math","submissionHash":"a872659181a8725ad910d124da886d833b857c8415d8beaca5e7af21b5a141ff","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"56258","feedbackHash":"7c4ab4151434eaf68ee7c8516a2fc7ab506fba6d872392988ec37f5b5b9006e6","nodeKey":"audit_permissions","submissionHash":"cc26dedbae73e01eb1e408e4287744cac1415e7f95747016de20ddde689c8838","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"7fe80bcfc0839a7c1bca6bdd9818398d67603f66f5e4a71851a7d074384bda95","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"1a7606be97f168d4","findings":[{"citation":"resolved","description":"reclaimIfDead() decides a lock is stale from one read of `path` (line 109) and then renames whatever is at `path` (line 111). Between those two steps another reclaimer can finish the same reclaim and create its own fresh lock at `path`. The second reclaimer then moves that live lock aside. While it reads the moved file and tries to link it back (line 113), `path` does not exist, so any other waiter's openSync(path, \"wx\") succeeds and it enters withLedgerLock while the first reclaimer is still inside its body. The link-back then fails with EEXIST and throws \"ledger lock contested\" (exit 10) for the second reclaimer, but by then two processes are running lockedCheck() + appendLedger() at once: both read the same spent_before, both pass the daily/job-budget check, and both append a `sent` row, so the cap can be exceeded by one padded quote. The first holder's lock file is left stranded at `<lock>.stale.<pid>.<hex>` and its releaseLock() is a no-op. A three-process variant (the first reclaimer finishes while its lock is aside; the second reclaimer links the released owner's file back) leaves a dead-owner lock at `path` that blocks every run with exit 10 for up to 30 s. Preconditions: a holder that crashed leaving a lock older than 30 s, and three or more runs started together (for example an automation retrying after the crash). The windows are microseconds, so this is an atomicity/liveness defect rather than an easy exploit, but the comment on lines 104-106 claims two waiters cannot both act on one stale file, and that no longer holds once the moved file is a live lock. Fix: re-read `path` immediately before the rename and refuse to move it unless its owner is still the dead one (compare pid and token), or reclaim through a hard link of the inode that was read (open + fstat, then rename only if the inode matches); on EEXIST at link-back, unlink the stranded aside file only when its token is the mover's own.","line":111,"path":"imd-flop/src/ledger.ts","reproduction":"State: `<home>/ledger.jsonl.lock` holds {pid:<pid of an exited process>, host:<this host>, token:\"p1\"} with mtime 31 s in the past. Harness: the unmodified src/ledger.ts loaded with `node --experimental-strip-types`, store.ts stubbed only to drop the secret scan (no deps installed). Steps: (1) child W2 calls withLedgerLock(home, body), body prints IN, sleeps 3000 ms, prints OUT; W2 reclaims P1's lock and creates its own at `path` (verified: the file at `path` carries W2's pid). (2) A second reclaimer W3 that read P1's lock as dead before step 1 completed now runs line 111: renameSync(path, aside); `path` no longer exists. The harness performs W3's two file operations itself because the window cannot be scheduled from outside; they are the exact calls on lines 111 and 113. (3) child W4 calls withLedgerLock(home, body): openSync(path, \"wx\") succeeds, W4 prints IN at 528 ms while W2 prints OUT only at 3328 ms. (4) W3 runs line 113: linkSync(aside, path) fails EEXIST, so LedgerLockError(\"ledger lock contested\"). Observed: W2 exit 0, W4 exit 0, overlap true; afterwards no lock at `path` and W2's lock still at `aside`. Expected: at most one process inside withLedgerLock at any time, and a live holder's lock never moved.","severity":"low","snippet":"  try { renameSync(path, aside); } catch { return false; } // someone else moved or released it","title":"Stale-lock reclaim can move a live holder's fresh lock aside, letting a third process enter the ledger critical section concurrently"},{"citation":"resolved","description":"takeLock() creates `ledger.jsonl.lock` with openSync(\"wx\") and only then writes the owner JSON. If the process is killed, hits ENOSPC, or the machine loses power between the two calls (the write is never fsync'd), the lock exists with no owner record. readOwner() returns null for it, provablyDead(null) is false, and reclaimIfDead() refuses it forever regardless of age. Every later `run` then waits 5 s and exits 10 \"refused locally, nothing sent: ledger lock timeout\", and `receipt --refresh` prints \"receipt and ledger disagree\" instead. The message names neither the lock file nor a recovery step, so the holder cannot tell transient contention from a permanent wedge. The v6 tests assert that an unattributable lock is never stolen, which is right for a foreign or hand-made file, but this file is one the CLI itself failed to finish writing. Fix that keeps the fail-closed design: write the owner record to a sibling temp file (write + fsync) and publish it with linkSync(tmp, path) so the lock appears atomically with its owner (EEXIST still means held); or treat a zero-length lock older than the 30 s staleness age as reclaimable, since no live holder can own one; and put the lock path and the recovery step in the LedgerLockError message.","line":122,"path":"imd-flop/src/ledger.ts","reproduction":"State: `<home>/ledger.jsonl.lock` is a zero-byte file with mtime 31 s in the past (what remains after a holder dies between ledger.ts:122 and :123), seat ready with a cap, no imd-flop process running. Command: `imd-flop run --job j1 --prompt hi --provider mock`; equivalently, with the unmodified ledger.ts in a harness, withLedgerLock(home, () => 1). Actual (harness run on the unmodified ledger.ts): waits the full 5 s retry window, then LedgerLockError(\"ledger lock timeout\"), so run exits 10 with \"refused locally, nothing sent: ledger lock timeout\"; the empty lock is still present and the same happens on every retry at any later time. Expected: a lock with no owner record and no live holder is reclaimed once it is older than the staleness age, or the refusal names `ledger.jsonl.lock` so the holder can recover.","severity":"low","snippet":"      const fd = openSync(path, \"wx\");\n      writeSync(fd, JSON.stringify(owner));","title":"Lock file is created and written in two steps; a crash in between leaves an empty lock that is never reclaimable, so every later run exits 10 until it is deleted by hand"},{"citation":"resolved","description":"refuse() appends a refused_local row through appendLedger(), which takes the ledger lock itself. On the estimate-failure and no-usable-cost paths (lines 198 and 200) that append runs outside the lockedCheck lock, so a LedgerLockError thrown there is not the one caught at lines 229-231 and reaches cli.ts as a non-CliError: the run exits 1 with \"imd-flop: ledger lock timeout\". The refused_local row is never written, and the message no longer says that the estimate failed or that nothing was sent, although this is before the paid call, which is the case exit 10 is documented to cover (README.md:46). README.md:48 records it as a known gap, so this is an accepted residual rather than a regression; it is listed so the re-audit of I14 is complete. Fix: catch LedgerLockError inside refuse() and rethrow CliError(REFUSED_LOCK) carrying the original refusal reason, or take the quote inside the locked section.","line":198,"path":"imd-flop/src/run.ts","reproduction":"State: another live process holds `<home>/ledger.jsonl.lock` (the TS-I14 setup: file content {pid: <a live pid>, host: <this host>, token: \"other\"}), seat ready with a cap. Command: `imd-flop run --job j1 --prompt hi --provider mock --mock-scenario estimate_error`. Actual: exit 1, stderr \"imd-flop: ledger lock timeout\", no refused_local row in ledger.jsonl. Expected per README.md:46: exit 10 with \"refused locally, nothing sent\" and the estimate failure named; the same command with the lock free exits 7.","severity":"info","snippet":"    refuse(`estimate failed (${(e as Error).message})`, EXIT.ESTIMATE_FAILED, { raw: null, padded: null }, null);","title":"Estimate failure while another process holds the ledger lock exits 1 and drops the ESTIMATE_FAILED refusal (documented residual of e99a2cf1 I14)"},{"citation":"resolved","description":"The appendLedger() call in the refresh path (line 447) takes the ledger lock itself. Every failure of that call, including LedgerLockError when another process holds `ledger.jsonl.lock`, is swallowed by the bare catch and reported as \"receipt and ledger disagree; reservation held, chain-final green blocked\" with exit 0. The receipt file has already been rewritten with the fresh settlement (line 422), the ledger row still carries the reservation, and the stderr line names a data disagreement that does not exist. The outcome is safe (the reservation is kept and a later --refresh re-applies the settlement because settledKnown !== prev.spend), but a script cannot distinguish \"ledger updated\" from \"ledger not updated\" by exit code, and the holder is told the wrong cause. The same path is what the v3 test asserts for a read-only ledger, so this is a message/exit-code precision issue, not a spend defect. Fix: catch LedgerLockError separately and report \"ledger lock timeout; settlement not booked, run --refresh again\" with a non-zero exit (REFUSED_LOCK), keeping the disagree wording for genuine identity or amount conflicts.","line":449,"path":"imd-flop/src/cli.ts","reproduction":"State: a completed mock run with request_id R whose ledger row is `estimated` with cap_charge 0.186; `<home>/ledger.jsonl.lock` holds {pid: <a live pid>, host: <this host>, token: \"other\"}. Command: `imd-flop receipt R --refresh --json` with a provider whose receipt is {request_id: R, state: \"settled\", settled_spend: 0.05}. Actual: after the 5 s lock wait, exit 0, stderr \"receipt and ledger disagree; reservation held, chain-final green blocked\", receipts/R.json now says settled 0.05, ledger latest row for R unchanged (spend null, cap_charge 0.186). Expected: a non-zero exit naming the lock timeout and stating the settlement was not booked.","severity":"info","snippet":"                  io.err(disagree);","title":"receipt --refresh reports a ledger lock failure as \"receipt and ledger disagree\" and exits 0"}],"hash":"1e0f9188cfb3275cc7b4898855cbc2d060ae81e2bcc543a08a73f2a058a74726","nodeId":"18ca36e4-01b2-496b-8a10-0ed858906081","outcome":"completed","summary":"The review is complete. `.imd-findings.json` sits in the repository root with four findings, none above low. No files in `imd-flop` were changed.\n\n**Prior audit e99a2cf1: all fourteen findings are fixed at 716819c.** I traced each against the code rather than running the suite, because the folder has no `node_modules` and the task is offline.\n\n- **M1 to M4** (refresh identity from the raw receipt, refresh never rewrites an unusable charge as 0, redaction of keys containing `.` or `[`, text kept when the scan hit is elsewhere): fixed in `cli.ts` and `run.ts`, with the regression tests in `test/audit-v7.test.ts` matching the code paths.\n- **L5 to L11** (settlement booked under an unchanged state, refresh redaction fallback, key-shaped receipt state, redacted fields kept off stdout, UTC day fixed inside the lock, malformed cap fails closed, mint transport failure is pre-send): fixed.\n- **I12** (async lock bodies): `withLedgerLock` now refuses any thenable return after releasing the lock. Production callers are all synchronous, so the guard is correct, though the refused body's tail still runs, which the test acknowledges.\n- **I13** (64-hex job id is a usage error) and **I14** (lock refusal before the paid call is exit 10): fixed. Exit 1 after the paid call is as documented.\n\n**New findings, all in the lock path.** I verified them with the unmodified `ledger.ts` loaded through Node's type stripping, stubbing only the store module's secret scan.\n\n- **Low, `ledger.ts:111`.** With a dead lock older than 30 seconds and three concurrent runs, a second reclaimer can rename the first reclaimer's fresh live lock aside, and a third waiter then takes the lock while the first is still inside. Both pass the cap check, so the cap can be exceeded by one padded quote. Reproduced with two real lock holders overlapping. The windows are microseconds, so this is an atomicity defect, not an easy exploit.\n- **Low, `ledger.ts:122`.** The lock file is created and then written. A crash between the two leaves an empty lock that is never attributable, so every later run waits five seconds and exits 10 until someone deletes the file by hand, and the message does not name it.\n- **Info, `run.ts:198`.** An estimate failure while another process holds the lock exits 1, not 10, and loses the refusal row. This is the gap the README already records on line 48.\n- **Info, `cli.ts:449`.** A lock failure during `receipt --refresh` is reported as \"receipt and ledger disagree\" with exit 0, so scripts cannot tell the ledger was not updated.\n\n**Clean areas.** The spend cap (exit 3), per-call and daily and requester-budget checks, the `request_id` identity rules, and stdout withholding of redacted response and receipt fields all held up against the failing inputs I tried by trace. I also checked that a corrupt seed file cannot leak the seed through a `JSON.parse` message, since V8 caps the snippet at about ten characters.\n\n**Limits.** No test suite ran. The findings rest on code tracing plus the harness runs described in each reproduction.","treeHash":null,"usage":{"cachedInputTokens":2099184,"cost":{"source":"runtime","usdMicros":6563081},"inputTokens":482,"model":"claude-fable-5-1","outputTokens":50872,"runtime":"claude","turns":46,"wallClockMs":735626}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"06d00e98fbdd1ee9","findings":[{"citation":"resolved","description":"runJob requests the gateway estimate (which carries the full system+user prompt) before it reads the ledger. The 'daily cap reached' check (line 216) and the requester 'remaining budget' check (line 220) only run inside the lock after the estimate returned. When spentToday >= cap.daily, or jobSpent >= jobBudget, the outcome is decidable from local state alone, yet the prompt is posted to /models/estimate and only then is the run refused with 'refused locally, nothing sent' and exit 3. The sibling spend-guard tool was fixed for exactly this class in commit 5c5b881 (audit 355a3703 I11: 'the prompt does not leave the machine for a request that was always going to be refused'); imd-flop still has it for the two ledger-derived limits. No FLOP is spent (the estimate is free) but the refusal message is inaccurate and the prompt is disclosed to the gateway for a call that can never be sent. Fix: before provider.estimate, read spentToday/jobSpent once and refuse (same reason strings, same exit 3, same refused_local row with quote_raw/quote_padded null) when left <= 0; keep the in-lock re-check unchanged for the race.","line":196,"path":"imd-flop/src/run.ts","reproduction":"State: seat 9001, cap set 0.186 (per-call 0.186), one mock run already booked today (cap_charge 0.186). Input: run --seat 9001 --job b --prompt 'Say hi' --max-tokens 8 --provider mock with a provider that counts estimate() calls. Expected: exit 3, 'daily cap reached', estimate count 0. Actual: exit 3, 'daily cap reached (0.186/0.186 FLOP today, UTC)', estimate count 1. Same with config set payer requester, --job-budget 0.2 after one 0.186 run on job j: exit 3 'over this job's remaining requester budget 0.014' with estimate count 1 (expected 0). Verified with vitest in a temp copy of imd-flop at 716819c.","severity":"low","snippet":"    q = padQuote(await provider.estimate({ model, prompt: estPrompt, max_tokens: o.maxTokens }));","title":"Daily cap or job budget already exhausted: prompt still leaves the machine for an estimate before the local exit-3 refusal"},{"citation":"resolved","description":"reclaimIfDead reads the owner (line 109), then renames whatever is at `path` (line 111). If, between those two steps, another waiter already reclaimed the dead lock and created its own live lock file at `path`, the rename moves the live holder's file aside. The code anticipates this and links it back (line 113), but between the rename and the link-back `path` does not exist, so any process calling takeLock in that window succeeds with openSync('wx') while the first holder is still inside its lockedCheck. The link-back then fails with EEXIST, that waiter exits 10 'contested', and the live holder's file is left as a leaked *.stale.* file. Result: two cap checks can pass against the same spent_before and both append `sent`, so the daily cap can be exceeded by one padded quote. Preconditions: a lock file older than 30 s whose recorded pid is dead on this host (a crash), plus three near-simultaneous runs; two scheduler preemptions at narrow points are needed, so it is rare but the code's own comment acknowledges the first half. Fix: do not reclaim by rename; instead open the existing file, fstat it, and only unlink it if its inode still matches what was read and the owner is provably dead, or drop automatic reclaim and print the lock path for a human to remove (the current message names no file).","line":111,"path":"imd-flop/src/ledger.ts","reproduction":"Emulated interleaving in a vitest test in a temp copy at 716819c: (1) child process A runs withLedgerLock(home, body) where body prints HOLDING and sleeps 3 s via Atomics.wait; (2) after HOLDING, the test performs the step reclaimIfDead takes after a stale read, renameSync(home/ledger.jsonl.lock, home/ledger.jsonl.lock.stale.emulated); (3) the test calls withLedgerLock(home, () => 'entered') in its own process. Expected: the call waits until A releases (about 3 s) or exits 10. Actual: it returns 'entered' after 1 ms while A is still inside its body (DONE not yet printed). Output: {\"entered\":true,\"holderStillRunning\":true,\"ms\":1}.","severity":"low","snippet":"  try { renameSync(path, aside); } catch { return false; } // someone else moved or released it","title":"Stale-lock reclaim has a window in which a live holder's lock file is absent, letting a second process into the cap check"},{"citation":"resolved","description":"scanForSecrets visits values and uses the key only as the field name for the allowlist/secret-name rules. A key that itself contains a loaded secret, a Bearer token, a flop_ek_ token or a 64-hex run is never matched, so writeJsonSafe accepts the object and the string reaches disk. The gateway controls the keys of `usage` (and of the receipt), which run stores unmodified in responses/<request_id>.json. Rule 1 says the pre-write scan rejects 'anything that looks like a key'; it does not for key positions. stdout is unaffected (run --json does not print usage), so this is a file-only gap. Fix: in the object branch also run the string rules on `k` (treat it as a value under its parent field name) and, on a hit, redact by deleting the key rather than nulling its value.","line":84,"path":"imd-flop/src/secrets.ts","reproduction":"Input: a mock provider whose complete() returns usage { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2, 'ee…ee' (64 hex chars): 1 }; run --seat 9001 --job k --prompt 'Say hi' --max-tokens 8 --provider mock --json. Expected: the response file is written without the 64-hex string (field redacted) or the write is refused with 'secret scan blocked'. Actual: exit 0, scanForSecrets({usage:{'ee…ee':1}}) returns 0 findings, and responses/<request_id>.json contains the 64-hex string as a key. Verified in a temp copy at 716819c.","severity":"low","snippet":"      for (const [k, x] of Object.entries(v as Record<string, unknown>)) visit(x, path ? `${path}.${k}` : k, [...keys, k], k);","title":"Secret scan never inspects object keys, so a key-shaped gateway field name is written verbatim to the response file"},{"citation":"resolved","description":"Re-audit of e99a2cf1 I14: the lock taken at line 228 now maps LedgerLockError to exit 10 and nothing is sent (verified: TS-I14 passes, exit 10 with 'refused locally, nothing sent'). One pre-call path still does not: when the estimate fails or returns no usable cost, refuse() calls appendLedger, which takes the lock itself; a LedgerLockError there is not a CliError, so main exits 1 with 'ledger lock timeout', no 'refused locally' prefix, and no refused_local row. README line 48 documents this as a known gap, so it is reported for completeness of the exit-code contract (README line 46 states the unconditional rule two lines earlier). Nothing is sent and no FLOP is at risk. Fix: in refuse(), catch LedgerLockError around appendLedger and rethrow as CliError(`refused locally, nothing sent: ${e.message}`, EXIT.REFUSED_LOCK), or let the original refusal code win since the row is only informational.","line":189,"path":"imd-flop/src/run.ts","reproduction":"State: seat 9001 ready with cap 5; write home/ledger.jsonl.lock containing {pid: <a live pid on this host>, host: <hostname>, token: 'other'} (a lock held by a live process). Input: run --seat 9001 --job e --prompt 'Say hi' --max-tokens 8 --provider mock with a provider whose estimate() throws. Expected per README line 46: exit 10 (or at least 7) with 'refused locally, nothing sent'. Actual: after the 5 s lock wait, exit 1, stderr 'imd-flop: ledger lock timeout', complete() never called, no ledger row written. Verified in a temp copy at 716819c.","severity":"info","snippet":"    appendLedger(h, { ...base, request_id: null, quote_raw: q.raw, quote_padded: q.padded, spent_before: spentBefore, state: \"refused_local\", reason, spend: null, cost_reported: null, cap_charge: 0, http: null });","title":"Pre-call lock contention during the estimate-failure refusal exits 1, not 10 (documented gap remains)"},{"citation":"resolved","description":"Re-audit of e99a2cf1 I12: fixed as far as a runtime check can go. syncResult only sees the promise after fn() has already run to its first await, so an async body's pre-await part executes under the lock, the TypeError is thrown to the caller, the lock is released, and the body's post-await part still runs unlocked (test/audit-v7d.test.ts asserts afterAwait === true). The signature `fn: () => T` still accepts async functions at compile time, so a future caller in the imd worker gets the error only when the code path executes. Production callers (lockedCheck, appendLedger) are synchronous, so no current spend path is affected. Fix: narrow the type so a promise-returning body fails tsc, e.g. `withLedgerLock<T>(h: string, fn: () => T extends PromiseLike<unknown> ? never : T): T`, keeping the runtime check as a backstop.","line":149,"path":"imd-flop/src/ledger.ts","reproduction":"Input: withLedgerLock(home, async () => { appendLedger(home, rowA); await Promise.resolve(); appendLedger(home, rowB); }). Expected: neither append happens, or both happen under the lock. Actual: a TypeError is thrown to the caller, rowA was appended under the lock, and rowB is appended on the next microtask after the lock file was removed (no lock held). tsc accepts the call.","severity":"info","snippet":"    throw new TypeError(\"withLedgerLock body must be synchronous: an async body would run its awaited part after the lock is released\");","title":"Async-body refusal in withLedgerLock is runtime-only and cannot stop the body's tail from running outside the lock"}],"hash":"8b67d56a1f9027c9caa8961661aa011f8cb480dff73f68b3d7d6442d4c3348cb","nodeId":"60db3731-8aab-4ea2-8a33-4bf4b7798462","outcome":"completed","summary":"The re-audit of `imd-flop` at 716819c is complete. The findings are in `.imd-findings.json` at the repository root, with all five path, line and snippet entries verified against the tree. No repository file was modified; the suite and my probes ran in a temporary copy outside the repo.\n\n**Prior audit e99a2cf1: every finding is fixed.** The full suite passes at this commit (14 files, 152 tests, run with vitest since the code is TypeScript, not Foundry). I confirmed the five assigned areas by reading the code and running the regression files:\n\n- **Ledger lock.** `withLedgerLock` around the cap check maps a lock timeout or contest to exit 10 before anything is sent. After the paid call, lock failure in the result append exits 1 with the `sent` reservation still charging the cap. Both match the README.\n- **Spend cap.** Per-call, daily and requester budget refusals exit 3, write a `refused_local` row and never call `complete`. Malformed caps and unusable `cap_charge` rows fail closed.\n- **Async lock bodies.** A promise-returning body throws a TypeError and leaves no lock file behind.\n- **request_id attempts.** Identity comes from the raw gateway receipt only. A refresh cannot rewrite an unusable charge as zero, settlement is booked only on exact identity, and the pre-mint `refused_local` row supersedes the `sent` line.\n- **stdout withholding.** Flagged text is null on stdout and in the file. A hit elsewhere keeps clean text, and redacted receipt fields never reach stdout.\n\n**Residual defects found, none above low:**\n\n- **Low, run.ts:196.** With the daily cap already reached, or the requester budget already spent, the prompt is still posted to the estimate endpoint before the exit 3 refusal. The sibling spend-guard tool was fixed for this exact class in commit 5c5b881. No FLOP is spent, but \"nothing sent\" is inaccurate.\n- **Low, ledger.ts:111.** Stale-lock reclaim moves a live holder's file aside before linking it back. During that gap a third process takes the lock, so two cap checks can pass at once. It needs a crashed lock plus three concurrent runs, so it is rare. Reproduced by emulating the interleaving.\n- **Low, secrets.ts:84.** The scan never inspects object keys. A 64-hex key name in gateway usage is written verbatim to the response file. Stdout is unaffected.\n- **Info, run.ts:189.** Estimate failure under a held lock exits 1, not 10. The README already documents this gap.\n- **Info, ledger.ts:149.** The async-body refusal is runtime only. The tail still runs unlocked, and the type signature accepts async functions.\n\n**Trust assumptions, not defects.** Requester mode disables the daily cap by design and the budget comes from a per-run flag that is never persisted, so a caller can pass a larger number each time. The README states both. Automatic stale-lock reclaim treats a reused PID as alive, so a crash can leave FLOP off until a human deletes the lock file, which the error message does not name.","treeHash":null,"usage":{"cachedInputTokens":958142,"cost":{"source":"runtime","usdMicros":5427382},"inputTokens":258,"model":"claude-fable-5-1","outputTokens":42000,"runtime":"claude","turns":30,"wallClockMs":656000}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"7dc4da3dfd5ce366","findings":[{"citation":"resolved","description":"The README documents that a timed-out attempt has a ledger row but no receipt file and is checked later with `imd-flop receipt <request_id> --refresh`. In that state `saved` is null, so the merged receipt is `{...c, request_id, ...}` built only from `classifyReceipt`, which carries no `seat`, `did`, `ss58` or `network`. The file written at cli.ts:422 therefore lacks seat and did. `matchReceipt` (src/match.ts:30-31, 43-44) then reports fields `seat`, `did`, `seat(link)`, `did(link)` and the attempt shows `match=mismatch` in `receipt`, `receipt --refresh` and `status`. Every later refresh spreads the same `saved` (still without seat/did), so the mismatch is permanent. Meanwhile the ledger booking at cli.ts:435-447 keys off `identityOk` alone, so a settled amount IS booked against a receipt whose match says mismatch, which contradicts the documented rule that `matches` means the receipt agrees with its records and that a mismatch keeps the reservation. Operators are trained by the README to treat `mismatch` as a revoked/rotated-DID or identity signal; a false alarm after every timeout either sends them chasing a non-issue or teaches them to ignore mismatches. The non-refresh path (`receipt <id>` with no file, cli.ts:458) does copy `seat`/`did` from the ledger row, so only the refresh path is wrong. Fix: when `saved` is null, seed `seat`, `did`, `ss58` and `network` from `ledgerRow` (and `mock_scenario` for mock) before writing, exactly as run.ts:297 does for a fresh receipt.","line":418,"path":"imd-flop/src/cli.ts","reproduction":"In a temp IMD_FLOP_HOME: `imd-flop init --seat 9001`, `imd-flop link --token 9001 --agent 7`, `imd-flop cap set 5`, then `imd-flop run --seat 9001 --job j1 --prompt 'Say hi' --max-tokens 8 --mock-scenario timeout --timeout-ms 50` (exit 6; stderr names the request_id, no receipts/<id>.json exists). `imd-flop receipt <id> --json` before refresh prints match pending with seat 9001 and the seat's did. Then `imd-flop receipt <id> --refresh --json --seat 9001`: exit 0, but receipts/<id>.json has no `seat` or `did` key and the printed match is {\"match\":\"mismatch\",\"fields\":[\"seat\",\"did\",\"job(correlation_id)\",\"seat(link)\",\"did(link)\"]}. A second `--refresh` and `imd-flop status --json` show the same mismatch. Expected: the refreshed receipt carries the ledger row's seat and did and matches (fields empty) when the gateway receipt agrees. Verified with a scratch vitest at commit 716819c: file.seat and file.did are undefined after refresh; with a provider returning state settled / settled_spend 0.05 the ledger row is still booked to 0.05 despite the mismatch.","severity":"medium","snippet":"            ? { ...saved, ...c, request_id: typeof rawId === \"string\" ? rawId : \"\", ...(identityOk ? {} : { settled: null, chain_final: false }) }","title":"receipt --refresh on a ledger-only attempt (after a timeout) writes a receipt without seat/did, so match is 'mismatch' forever while the settlement is still booked"},{"citation":"resolved","description":"`scanForSecrets` visits only values; each key `k` is used solely as the field name for the SECRET_FIELD / PUBLIC_HEX_FIELDS lookups and is never itself tested against the registry, the bearer/FLOP_TOKEN/PEM regexes or HEX64. run.ts:261 stores the raw gateway `usage` object in the response record, and run.ts:267 relies on `writeJsonSafe` to block anything key-shaped. A gateway (or a buggy proxy) that echoes the child token, the parent bearer or any 64-hex string as a *property name* in `usage` therefore passes the scan with zero findings, `withheld` is never set, and the key material lands in a 0644 file under responses/. `redactFlagged` could not clear it either, since `setNull` only nulls values. Rule 1 (\"a pre-write scan rejects anything shaped like a key\") is thus bypassable through key names. stdout is not affected (`run --json` does not print `usage`). Fix: in `visit`, run the string checks on each key as well (treat the key as a string value with field name `k`), and in `redactFlagged` delete or rename offending keys.","line":84,"path":"imd-flop/src/secrets.ts","reproduction":"Register a secret (as `FlopProvider` does for the child token) and run with a mock provider whose completion returns usage `{ prompt_tokens: 1, completion_tokens: 1, total_tokens: 2, \"flop_ek_SYNTHETICCHILDTOKEN0123456789\": 1, \"abab...(64 hex)\": 2 }`. Expected: `writeJsonSafe` throws, the field is withheld and `withheld` is set. Actual (verified at 716819c via a scratch vitest): run exits 0, `withheld` is undefined, and `readFileSync(response_file)` contains both the registered `flop_ek_...` string and the 64-hex string verbatim. Unit form: `scanForSecrets({ usage: { [\"ab\".repeat(32)]: 1 } })` returns [] and `scanForSecrets({ [\"Bearer SYNTHETICTOKEN12345\"]: 1 })` returns [].","severity":"low","snippet":"      for (const [k, x] of Object.entries(v as Record<string, unknown>)) visit(x, path ? `${path}.${k}` : k, [...keys, k], k);","title":"Secret scan never inspects object keys: a loaded secret or 64-hex value used as a key name in gateway usage is persisted to the response file"},{"citation":"resolved","description":"`takeLock` creates the lock file and writes the owner record in two steps. If the process dies (SIGKILL, power loss) or `writeSync` fails (ENOSPC, EIO) between them, the lock exists with no owner JSON. On the ENOSPC path the error is not EEXIST, so line 126 rethrows without unlinking the just-created file. `readOwner` (line 88-94) returns null for an empty/malformed file and `provablyDead(null)` is false by design (\"never provably stale\"), so `reclaimIfDead` never fires no matter how old the file is. Every subsequent `run` then loops for 5 s and exits 10 with 'refused locally, nothing sent: ledger lock timeout' (the pre-call exit code), `receipt --refresh` reports the misleading 'receipt and ledger disagree' message, and nothing in the CLI tells the operator which file to remove. By contrast a lock whose owner JSON names a dead pid on this host is reclaimed after 30 s. Fix: write the owner record to a temp file and `linkSync`/`renameSync` it into place so the lock is never observable without an owner; on a write failure unlink the path before rethrowing; and have `takeLock` include the lock path in the LedgerLockError message.","line":122,"path":"imd-flop/src/ledger.ts","reproduction":"In a ready home (init/link/cap set 5): `: > $IMD_FLOP_HOME/ledger.jsonl.lock && touch -d '2 minutes ago' $IMD_FLOP_HOME/ledger.jsonl.lock`, then `imd-flop run --seat 9001 --job c1 --prompt 'Say hi' --provider mock` twice. Expected: a zero-byte lock older than the 30 s stale threshold is reclaimed, or at least the refusal names the file. Actual (verified at 716819c): both runs exit 10 after ~5 s with 'refused locally, nothing sent: ledger lock timeout', provider.complete is never called, and the lock file is still present. Control: writing `{\"pid\":2147483000,\"host\":\"<hostname>\",\"token\":\"x\"}` into the same aged lock makes the next run exit 0 (reclaimed).","severity":"low","snippet":"      const fd = openSync(path, \"wx\");\n      writeSync(fd, JSON.stringify(owner));","title":"An empty or malformed ledger.jsonl.lock (crash or ENOSPC between openSync and writeSync) is never reclaimed, so every run exits 10 until a human deletes it"},{"citation":"resolved","description":"`readJsonl` parses every non-blank line with no error handling. `appendJsonlSafe` uses `appendFileSync` with no fsync, so a crash or disk-full event mid-append can leave a partial last line. After that, `latestAttempts` throws SyntaxError on every call: `run` fails inside `lockedCheck` (fail-closed, nothing sent, but exit 1 rather than the documented exit 3 for an unusable ledger), and `status`, `cap show` and `receipt` all exit 1 with the bare parser message, which does not name the ledger file or the line. The README's exit-code table reserves 3 for 'a ledger cap_charge is unusable' and 1 for 'any unexpected error', so a script branching on 3 to alert on ledger problems never fires, and the operator has no diagnostic command left that works. Fix: catch parse failures per line in `readJsonl`, raise a typed error naming the file and line number, map it to `UnusableLedgerCharge`/REFUSED_CAP in run, and let `status` report the corrupt line instead of dying.","line":34,"path":"imd-flop/src/store.ts","reproduction":"In a ready home run one successful mock call, then `printf '{\"ts\":\"2026-11-10T00:00:00Z\",\"request_id\":\"trunc\"' >> $IMD_FLOP_HOME/ledger.jsonl` (no closing brace, no newline). Then: `imd-flop run --seat 9001 --job d1 --prompt 'Say hi' --provider mock` exits 1 with \"imd-flop: Expected ',' or '}' after property value in JSON at position 49\" (provider not called); `imd-flop status --seat 9001` exits 1 with the same message; `imd-flop cap show --seat 9001` exits 1 with the same message. Expected: run refuses with exit 3 naming the ledger problem, status/cap show still render and name the bad line. Verified at 716819c with a scratch vitest.","severity":"low","snippet":"    .map((l) => JSON.parse(l) as T);","title":"A truncated or corrupt ledger line makes run, status, cap show and receipt all exit 1 with a raw JSON SyntaxError instead of a local refusal"},{"citation":"resolved","description":"`sumCharges` throws for any latest row whose `cap_charge` is missing, null, non-numeric, negative or non-finite. `runJob` maps that to REFUSED_CAP (exit 3) at run.ts:210, which is correct. But `status` (cli.ts:375) and `cap show` (cli.ts:263) call `spentToday` unguarded, so the same ledger state makes both diagnostic commands exit 1 with 'ledger cap_charge unusable; next call refused' and no row identifier. The row that needs fixing (request_id, line) is never shown anywhere, and `receipt <id> --refresh`, the only command the README offers to repair a ledger row (TS-M2 path at cli.ts:437-440), requires the operator to already know the request_id. Fix: make `spentToday`/`jobSpent` return a structured result (or let `UnusableLedgerCharge` carry the request_id), have `status` and `cap show` print the offending request_id(s) with today's known-good total instead of throwing, and keep `run` on exit 3.","line":67,"path":"imd-flop/src/ledger.ts","reproduction":"In a ready home write one ledger row for seat 9001, provider mock, payer holder, today's UTC day, state sent, with `\"cap_charge\":\"nope\"`. `imd-flop run --seat 9001 --job d2 --prompt 'Say hi' --provider mock` exits 3 (correct). `imd-flop status --seat 9001` exits 1 with 'imd-flop: ledger cap_charge unusable; next call refused'; `imd-flop cap show --seat 9001` exits 1 with the same text. Expected: status and cap show exit 0 and name the request_id whose cap_charge is unusable. Verified at 716819c.","severity":"low","snippet":"    if (!usableCapCharge(row.cap_charge)) throw new UnusableLedgerCharge(\"ledger cap_charge unusable; next call refused\");","title":"UnusableLedgerCharge is not a CliError: status and cap show exit 1 whenever run is refusing with exit 3, so the operator cannot inspect the ledger state that caused the refusal"},{"citation":"resolved","description":"After the refreshed receipt has already been written to receipts/<id>.json (cli.ts:422), the ledger append at cli.ts:447 runs under its own `withLedgerLock`. If another process holds the lock (or the lock is stale and unreclaimable, see the empty-lock finding), `appendLedger` throws `LedgerLockError`, the bare `catch` prints the fixed string 'receipt and ledger disagree; reservation held, chain-final green blocked' and the command returns EXIT.OK. The message is false (the receipt and ledger do not disagree; the ledger could not be written), the exit code tells a script the refresh succeeded, and the two stores are now inconsistent: the receipt file says state settled / settled 0.05 while the ledger's latest row is still state estimated, spend null, cap_charge 0.186. `receipt <id>` without `--refresh` then shows settled=0.05 next to `ledger: state estimated, spend` blank. Only a later refresh that happens to get the lock repairs it. Fix: catch `LedgerLockError` separately, report 'ledger lock timeout; settlement not booked, re-run --refresh', and exit non-zero (10 fits the documented meaning: lock not taken, nothing written).","line":448,"path":"imd-flop/src/cli.ts","reproduction":"In a ready home run one mock call (exit 0, note request_id). Create `$IMD_FLOP_HOME/ledger.jsonl.lock` containing `{\"pid\":<a live pid>,\"host\":\"<hostname>\",\"token\":\"other\"}`. Run `imd-flop receipt <id> --refresh --json --seat 9001` with a provider whose receipt reports state settled and settled_spend 0.05 (scripted mock). Actual (verified at 716819c): exit 0, stderr 'receipt and ledger disagree; reservation held, chain-final green blocked', printed `status` carries the same text, receipts/<id>.json has settled 0.05 / state settled, the ledger's latest row for <id> is {state: estimated, spend: null, cap_charge: 0.186} and `ledger_spend` prints null. Expected: a non-zero exit naming the lock failure, or the receipt file not updated ahead of the ledger.","severity":"low","snippet":"                } catch {\n                  io.err(disagree);","title":"receipt --refresh swallows a ledger-lock failure as 'receipt and ledger disagree' and exits 0, leaving the receipt file settled while the ledger still holds the reservation"},{"citation":"resolved","description":"The README accepts exit 1 for a post-call lock failure, and the pre-call/post-call split (10 vs 1) is implemented as documented. But the failure mode is harsher than the README describes. Because `appendLedger` here (and at run.ts:250 for the timeout/error branch) is not wrapped, the raw `LedgerLockError` escapes `runJob`, so cli.ts:475 prints only 'imd-flop: ledger lock timeout' and nothing is written to stdout even with `--json`. The operator is not given the request_id, the response file path, or the receipt line, although the paid call completed and both files were written; the only way to find the attempt is `status`. On the timeout branch the same failure exits 1 instead of 6, the ledger's latest row stays `sent` (not `timeout_unknown`), and the 'Spend is UNKNOWN ... Check later with imd-flop receipt <id>' guidance is lost. On the success branch a `cost_reported` above the padded quote (e.g. 0.25 vs 0.186) is never recorded, so the exposure raise that the ledger-reservation tests guarantee is silently dropped and `spentToday` under-counts until a later refresh settles the row. Fix: catch `LedgerLockError` around both post-call appends, print the request_id, response path and 'ledger row not written: <state> pending' to stderr (and the normal JSON to stdout), then exit with the branch's own code (6 on timeout) or 1 as documented.","line":322,"path":"imd-flop/src/run.ts","reproduction":"In a ready home run with a mock provider whose `complete()` first creates `$IMD_FLOP_HOME/ledger.jsonl.lock` with `{\"pid\":<live pid>,\"host\":\"<hostname>\",\"token\":\"other\"}` and then returns usage `cost_flop: 0.25` (padded quote 0.186). Actual (verified at 716819c): exit 1, stdout empty even with --json, stderr exactly 'imd-flop: ledger lock timeout', provider called once, responses/ and receipts/ files written, ledger rows for the id: [sent, 0.186] only. Timeout variant (provider creates the lock then never answers, `--timeout-ms 50`): exit 1 instead of 6, stderr 'imd-flop: ledger lock timeout', ledger latest row state `sent`, no request_id shown. Expected: the request_id and outcome on the terminal, and on the timeout branch exit 6.","severity":"low","snippet":"  appendLedger(h, { ...sent, ts: new Date().toISOString(), state: classified.state ?? \"sent\", reason: classified.flag, spend: settledKnown, cost_reported: costReported, cap_charge: capCharge, http: rr.http });","title":"A ledger-lock failure after a successful paid call exits 1 with only 'ledger lock timeout': the request_id, the response path and a higher reported cost are all dropped"},{"citation":"resolved","description":"`num()` is used for `cap set <FLOP/day>`, `--per-call` and `--job-budget`. `Number()` accepts hexadecimal (`0x10` is 16), binary/octal prefixes, exponent notation (`1e-7`), and surrounding whitespace, so `cap set 0x10` silently sets a 16 FLOP/day cap and `--per-call 1e-7` sets a per-call cap below the 1e-6 quote granularity that `ceil6` produces, after which every run is refused with exit 3 and a confusing 'padded quote 0.000186 FLOP is over the per-call cap 1e-7'. `run --max-tokens` goes through `Number()` too (cli.ts:327) but is then integer-range checked; `quote --max-tokens` (cli.ts:289) has no check, so `quote --max-tokens abc` exits 0 and prints estimated_output_tokens null / estimated_cost_flop null / padded null, and with `--provider flop` would send `max_tokens: null` to the gateway. None of this spends FLOP (quote is free, caps only ever refuse), so this is a hardening note. Fix: validate FLOP amounts with a decimal regex (`^\\d+(\\.\\d{1,6})?$`) and reuse the run path's integer check for quote.","line":71,"path":"imd-flop/src/cli.ts","reproduction":"In a ready home: `imd-flop cap set 0x10 --seat 9001 --json` exits 0 with cap {daily: 16, perCall: 16}; `imd-flop cap set ' 5 ' --per-call 1e-7 --seat 9001 --json` exits 0 with perCall 1e-7, after which `imd-flop run --seat 9001 --job f1 --prompt 'Say hi' --provider mock` exits 3; `imd-flop quote --prompt hi --max-tokens abc` exits 0 and prints padded null. Expected: exit 2 (USAGE) for each malformed number. Verified at 716819c.","severity":"info","snippet":"  const n = Number(v);","title":"Numeric CLI arguments are parsed with Number(): hex, exponent and padded strings are accepted for caps and budgets, and quote --max-tokens is not validated at all"}],"hash":"a872659181a8725ad910d124da886d833b857c8415d8beaca5e7af21b5a141ff","nodeId":"4b873a14-4895-470c-9be9-c4f2cb4d4635","outcome":"completed","summary":"Review complete. The findings file is written at the repository root with 8 entries, every path, line and snippet verified against the tree at 716819c, and the scratch test directory has been removed.\n\n**Prior audit e99a2cf1 status.** All fourteen tracked items (M1 to M4, L5 to L11, I12 to I14) are fixed in code and covered by the regression tests, which pass (147 of 147). The lock exits 10 before the paid call and 1 after it, cap refusals and unusable ledger charges exit 3, promise-returning lock bodies are refused, and withheld response text stays off stdout and disk.\n\n**New findings in `.imd-findings.json`, all reproduced locally with a scratch vitest:**\n\n- **Medium, cli.ts:418.** After a timeout, `receipt --refresh` builds the receipt from `classifyReceipt` alone, so the file has no seat or did. The match is \"mismatch\" forever, yet a settled amount is still booked against it.\n- **Low, secrets.ts:84.** The secret scan tests values only. A registered child token or 64-hex string used as a property name in gateway `usage` is written to the response file with no `withheld` marker.\n- **Low, ledger.ts:122.** A zero-byte or malformed lock file, left by a crash or ENOSPC between open and write, is never reclaimed. Every run exits 10 until a human deletes it, and nothing names the file.\n- **Low, store.ts:34.** One truncated ledger line makes run, status, cap show and receipt all exit 1 with a raw JSON parser message rather than exit 3.\n- **Low, ledger.ts:67.** An unusable cap_charge correctly refuses run with exit 3, but status and cap show exit 1 too, so the operator cannot see which row to repair.\n- **Low, cli.ts:448.** A lock failure during refresh is reported as \"receipt and ledger disagree\" with exit 0, leaving the receipt file settled and the ledger reservation unchanged.\n- **Low, run.ts:322.** A post-call lock failure prints only \"ledger lock timeout\", drops the request_id and response path, exits 1 instead of 6 on the timeout branch, and loses a reported cost above the padded quote.\n- **Info, cli.ts:71.** Caps and budgets go through `Number()`, so `cap set 0x10` sets 16 FLOP and `--per-call 1e-7` refuses every run; `quote --max-tokens` is not validated.\n\n**Scope and limits.** Only the `imd-flop` folder was reviewed, offline, with mock providers. No Foundry proofs apply because the code is TypeScript. The ceil6 and round6 arithmetic, the padded-quote formula, the day boundary inside the lock, and the reservation-raising logic were traced and hold for realistic inputs. No change was made to any tracked file.","treeHash":null,"usage":{"cachedInputTokens":1515143,"cost":{"source":"runtime","usdMicros":5915267},"inputTokens":450,"model":"claude-fable-5-1","outputTokens":48437,"runtime":"claude","turns":35,"wallClockMs":745683}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"09078b7cdfb673fe","findings":[{"citation":"resolved","description":"loadConfig (imd-flop/src/config.ts:20) spreads config.json over DEFAULT_CONFIG without checking that payer is exactly \"holder\" or \"requester\". runJob only demands --job-budget when payer === \"requester\" (run.ts:159), and the cap branch at run.ts:214-221 treats every non-\"holder\" value as the requester case. With payer \"sponsor\" and no --job-budget, o.jobBudget is undefined, so left = NaN at run.ts:219 and `q.padded > NaN` is false: no refusal ever fires. Every ledger row then carries payer \"sponsor\", which spentToday (ledger.ts:74 filters payer === \"holder\") and jobSpent (ledger.ts:78 filters payer === \"requester\") both skip, so nothing counts against any cap and `status`/`cap show` print today's spend as 0 while calls keep going out. The sibling field caps[seat] fails closed for malformed input (run.ts:154-157, prior finding TS-L10); payer does not. Precondition: a writer of config.json other than `config set payer` (hand edit, a future writer, or the agent operating the CLI). Fix: validate payer in loadConfig or next to the cap check and refuse with EXIT.REFUSED_CAP when it is not exactly holder|requester; make the run.ts:218 branch `else if (cfg.payer === \"requester\")` with a final fail-closed refuse.","line":219,"path":"imd-flop/src/run.ts","reproduction":"1. IMD_FLOP_HOME=$(mktemp -d); imd-flop init --seat 9001; imd-flop link --token 9001 --agent 7; imd-flop cap set 0.2. 2. Edit $IMD_FLOP_HOME/config.json: \"payer\": \"sponsor\". 3. Run three times with --job p1, p2, p3: imd-flop run --seat 9001 --job p1 --prompt 'Say hi' --max-tokens 8 --provider mock --json (each padded quote is 0.186 FLOP against a 0.2 FLOP/day cap). Expected: at most one run exits 0, the rest exit 3 'refused locally, nothing sent' (or all three exit 3 for a malformed payer). Actual at 716819c (vitest, specialist proof section A, re-run by the judge): exit codes [0, 0, 0], provider.complete called 3 times, all 6 ledger rows have payer \"sponsor\", `status --json` today_utc = {flop: 0, mock: 0}.","severity":"medium","snippet":"      const left = Math.round((o.jobBudget! - spentBefore) * 1e6) / 1e6;","title":"config.json payer outside holder|requester disables the daily cap and the job budget (fail-open) while status reports 0 spent"},{"citation":"resolved","description":"When provider.complete() rejects with PreSendError (FlopProvider.complete wraps every assertPaidNetwork failure this way, providers.ts:139-143, and mintChildKey failures too), the catch block builds reason = `nothing sent: ${err.message}` with only `0x`+64-hex stripped (run.ts:249) and appends the refused_local row with cap_charge 0 (run.ts:250). appendLedger runs assertNoSecrets on the row. Any other 64-or-more hex run (HEX64 at secrets.ts:52 matches bare hex and decimal digit runs), a `Bearer ...` or `flop_ek_...` shaped substring in the message is not stripped. Those strings come from the RPC/faucet the recheck talks to (gateway.ts: `eth_chainId error: ${j.error.message}`, `rpc error: ...`, `Ethereum chain id ${chainId} ...`; a chain id of 0xff..ff prints as a 78-digit decimal). When the scan hits, appendLedger throws CliError(REFUSED_RULE) from inside the catch: the refused_local line is never written, the pre-call `sent` row (cap_charge = padded quote) stays the latest line for that request_id, and the CLI exits 5 'secret scan blocked writing ledger.jsonl' instead of 1 'nothing sent'. spentToday counts the phantom reservation for the rest of the UTC day, so the next run is refused with 'daily cap reached' although nothing was minted or sent. `receipt <id> --refresh` cannot clear it (no gateway receipt exists for an unminted request; cli.ts:442 keeps prev.cap_charge) and `status` lists it as pending. This breaks the README invariant for this path ('the latest ledger line is refused_local with cap charge 0'), which TS-L11 tests only with a clean message. Fix: build the ledger reason from a fixed vocabulary (e.g. `nothing sent: pre-send refusal`) or pass it through redactFlagged before the append, and if the append still throws, still write a minimal refused_local row and throw the EXIT.ERROR 'nothing sent' CliError; never let a scan failure keep the sent reservation live.","line":250,"path":"imd-flop/src/run.ts","reproduction":"1. init --seat 9001; link --token 9001 --agent 7; cap set 0.186 (cap equals one padded quote). 2. Run with a provider whose complete() rejects with new PreSendError('eth_chainId error: ' + 'ee'.repeat(32)) (what FlopProvider.complete throws when ETH_RPC_URL answers eth_chainId with {\"error\":{\"message\":\"<64 hex>\"}} on the recheck; a chain id result 0x followed by 64 'f' gives the same via its 78-digit decimal): imd-flop run --seat 9001 --job m1 --prompt 'Say hi' --max-tokens 8 --provider mock --json. Expected: exit 1 'nothing sent', latest ledger row state refused_local cap_charge 0, and a following run --job m2 exits 0. Actual at 716819c (specialist proof section B and B2, re-run by the judge): exit 5 'imd-flop: secret scan blocked writing ledger.jsonl: reason: 64-or-more-hex (key-shaped) value'; ledger rows [['sent', 0.186]]; the following run exits 3 'refused locally, nothing sent: daily cap reached (0.186/0.186 FLOP today, UTC)' with complete() never called. Both the 64-hex and the 78-digit decimal variants reproduce.","severity":"medium","snippet":"    appendLedger(h, { ...sent, ts: new Date().toISOString(), state: timedOut ? \"timeout_unknown\" : preSend ? \"refused_local\" : \"error\", reason, spend: null, cap_charge: preSend ? 0 : sent.cap_charge, http });","title":"Pre-mint refusal whose error text trips the secret scan leaves the pre-call `sent` reservation as the latest ledger line: daily cap consumed with nothing sent, exit 5 instead of 1"},{"citation":"resolved","description":"A timed-out attempt has a ledger row but no receipt file; the README says to check it later with `receipt <id> --refresh`. In that state `saved` is null, so the merged receipt at cli.ts:418 is built only from classifyReceipt, which carries no seat, did, ss58 or network. The file written at cli.ts:422 therefore lacks seat and did. matchReceipt (src/match.ts:30-31, 43-44) then reports fields seat, did, seat(link), did(link), and the attempt shows match=mismatch in `receipt`, `receipt --refresh` and `status`. Every later refresh spreads the same `saved` (still without seat/did), so the mismatch is permanent. Meanwhile the ledger booking at cli.ts:435-447 keys off identityOk alone, so the settled amount IS booked against a receipt whose match says mismatch, contradicting the documented rule that `matches` means the receipt agrees with its records. Operators are told to treat mismatch as a revoked/rotated-DID signal; a false alarm after every timeout either sends them chasing a non-issue or teaches them to ignore mismatches. The non-refresh path (cli.ts:458) does copy seat/did from the ledger row, so only the refresh path is wrong. Fix: when `saved` is null, seed seat, did, ss58 and network from ledgerRow (and mock_scenario for mock) before writing, as run.ts:297 does.","line":418,"path":"imd-flop/src/cli.ts","reproduction":"In a temp IMD_FLOP_HOME: imd-flop init --seat 9001; imd-flop link --token 9001 --agent 7; imd-flop cap set 5; imd-flop run --seat 9001 --job j1 --prompt 'Say hi' --max-tokens 8 --mock-scenario timeout --timeout-ms 50 (exit 6; stderr names the request_id; receipts/<id>.json does not exist). `imd-flop receipt <id> --json` prints match pending with seat 9001. Then `imd-flop receipt <id> --refresh --json --seat 9001` with a provider whose receipt is {request_id: <id>, state: settled, settled_spend: 0.05}. Expected: receipts/<id>.json carries seat 9001 and the seat's did and match is {matches, []}. Actual at 716819c (judge probe H): exit 0, file.seat undefined, file.did undefined, printed match {\"match\":\"mismatch\",\"fields\":[\"seat\",\"did\",\"seat(link)\",\"did(link)\"]}, ledger latest row for <id> is state settled, spend 0.05, cap_charge 0.05 (booked despite the mismatch). A second --refresh and `status --json` show the same mismatch.","severity":"medium","snippet":"            ? { ...saved, ...c, request_id: typeof rawId === \"string\" ? rawId : \"\", ...(identityOk ? {} : { settled: null, chain_final: false }) }","title":"receipt --refresh on a ledger-only attempt (after a timeout) writes a receipt without seat/did, so match is 'mismatch' forever while the settlement is still booked"},{"citation":"resolved","description":"scanForSecrets visits only values. For an object it recurses into each value and passes the key as the field name, but the key string itself is never matched against the registry (loaded secrets), HEX64, FLOP_TOKEN, BEARER or PEM. A completion whose usage object (stored verbatim in the response record, run.ts:261) carries a 64-hex string, a flop_ek_ token, a Bearer string or a loaded secret as a property name with a harmless value passes writeJsonSafe and lands in responses/<id>.json (mode 0644); redactFlagged cannot clear it either because setNull only nulls values. The README's rule 1 says 'any 64-hex value' and 'flop_ek_... tokens' are rejected before anything touches disk, and the gateway (which knows the child token it minted) is the party whose output this scan exists to filter. stdout is unaffected (run --json does not print usage). Fix: in visit(), also run the string checks on each key k (treat it as a string value under field name k), and in redactFlagged delete or rename a flagged key. Merged from three specialist reports of the same defect.","line":84,"path":"imd-flop/src/secrets.ts","reproduction":"Ready a seat (init --seat 9001; link --token 9001 --agent 7; cap set 5). Use a provider whose complete() returns usage { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2, ['ee'.repeat(32)]: 1 } (or the key 'flop_ek_SYNTHETICNOTAKEY0123456789'). Run: imd-flop run --seat 9001 --job k1 --prompt 'Say hi' --max-tokens 8 --provider mock --json. Expected: the response file does not contain the 64-hex / flop_ek_ string (withheld or redacted) and `withheld` is set. Actual at 716819c (specialist proof sections C and C2, re-run by the judge): exit 0, `withheld` undefined, readFileSync(response_file).includes('ee'.repeat(32)) === true; same for the flop_ek_ key. Unit form: scanForSecrets({ usage: { ['ab'.repeat(32)]: 1 } }) returns [].","severity":"low","snippet":"      for (const [k, x] of Object.entries(v as Record<string, unknown>)) visit(x, path ? `${path}.${k}` : k, [...keys, k], k);","title":"Secret scan never inspects object keys, so a key-shaped JSON property name from the gateway is written to the response file unredacted"},{"citation":"resolved","description":"takeLock creates ledger.jsonl.lock with openSync(path, 'wx') (ledger.ts:122) and only then writes the owner record {pid, host, token} (ledger.ts:123), with no fsync. A SIGKILL, OOM kill, power loss, or a writeSync failure (ENOSPC/EIO, which is not EEXIST so line 126 rethrows without unlinking the just-created file) leaves an existing lock with no readable owner. readOwner returns null, provablyDead(null) is false by design ('unreadable or malformed: never provably stale', ledger.ts:92), so reclaimIfDead never fires regardless of age, and every subsequent withLedgerLock waits 5 s and throws LedgerLockError. Every `run` on that home exits 10 'refused locally, nothing sent: ledger lock timeout' forever, `receipt --refresh` prints the misleading 'receipt and ledger disagree', and no message names the lock file or a recovery step. The same permanent state arises when the recorded host no longer equals hostname(). Fail-closed, so no spend is at risk; the cost is an outage of the paid path that the CLI cannot recover from. Fix: make the owner record and the lock one atomic step (write the JSON to a private temp file in the same directory, then linkSync(temp, lockPath), which fails EEXIST exactly like 'wx'; unlink the temp), unlink the path before rethrowing a write failure, and/or treat a zero-byte lock older than the 30 s threshold as reclaimable; print the lock path in the exit-10 message. Merged from three specialist reports.","line":123,"path":"imd-flop/src/ledger.ts","reproduction":"1. Ready a seat: init --seat 9001; link --token 9001 --agent 7; cap set 5. 2. Create the crash artefact: : > $IMD_FLOP_HOME/ledger.jsonl.lock; touch -d '10 minutes ago' $IMD_FLOP_HOME/ledger.jsonl.lock. 3. imd-flop run --seat 9001 --job g1 --prompt hi --max-tokens 8 --provider mock. Expected: a lock older than 30 s with no live owner is reclaimed and the run exits 0 (or the refusal names ledger.jsonl.lock). Actual at 716819c (judge probe G): the run blocks 5050 ms and exits 10 'imd-flop: refused locally, nothing sent: ledger lock timeout'; the empty lock file is still present afterwards and every further run behaves the same. Control: writing {\"pid\":2147483000,\"host\":\"<hostname>\",\"token\":\"x\"} into the same aged lock makes the next run exit 0 (reclaimed).","severity":"low","snippet":"      writeSync(fd, JSON.stringify(owner));","title":"Lock file is created and its owner written in two steps; a crash between them leaves a zero-byte lock that is never reclaimed, so every later run exits 10 until it is deleted by hand"},{"citation":"resolved","description":"runJob requests provider.estimate() (which carries the full system+user prompt) before it reads the ledger. The 'daily cap reached' check (run.ts:216) and the requester 'remaining budget' check (run.ts:220) run only inside lockedCheck after the estimate returned. When spentToday >= cap.daily, or jobSpent >= jobBudget, or a ledger cap_charge is unusable, the outcome is decidable from local state alone, yet the prompt is posted to /models/estimate (on --provider flop) and only then is the run refused with 'refused locally, nothing sent' and exit 3. When that estimate fails the user sees exit 7 ESTIMATE_FAILED and a refused_local row with reason 'estimate failed', not the cap refusal. The sibling spend-guard tool was fixed for exactly this in commit 5c5b881 (audit 355a3703 I11: 'the prompt does not leave the machine for a request that was always going to be refused'); imd-flop was not. No FLOP is spent (the estimate is free); the prompt is disclosed to the gateway for a call that can never be sent and the exit code is wrong. Fix: before provider.estimate, compute spentBefore and refuse with REFUSED_CAP (same reason strings, same refused_local row with quote_raw/quote_padded null) when left <= 0 or the ledger charge is unusable; keep the authoritative in-lock check for the per-call cap and the race. Merged from two specialist reports.","line":196,"path":"imd-flop/src/run.ts","reproduction":"Ready a seat with cap set 0.186 and complete one mock run (padded quote 0.186, so the day is full). Second run with a provider whose estimate() throws 'estimate HTTP 503': imd-flop run --seat 9001 --job d2 --prompt 'Say hi' --max-tokens 8 --provider mock --json. Expected: exit 3 'daily cap reached' with estimate() never called. Actual at 716819c (specialist proof section D, re-run by the judge): estimate() is called once, exit 7 'refused locally, nothing sent: estimate failed (estimate HTTP 503)'. With a working estimate the same state exits 3 'daily cap reached (0.186/0.186 FLOP today, UTC)' but still with estimate count 1. Same shape with config set payer requester and --job-budget 0.2 after one 0.186 run on the same job.","severity":"low","snippet":"    q = padQuote(await provider.estimate({ model, prompt: estPrompt, max_tokens: o.maxTokens }));","title":"Daily cap or job budget already exhausted: the prompt still leaves the machine for a gateway estimate before the locally decidable exit-3 refusal, and an estimate failure then exits 7 instead of 3"},{"citation":"resolved","description":"readJsonl parses every non-blank line with no error handling. appendJsonlSafe uses appendFileSync with no fsync, so a crash or disk-full event mid-append can leave a partial last line. After that, latestAttempts throws SyntaxError on every call: `run` fails inside lockedCheck (fail-closed, nothing sent, but exit 1 rather than the documented exit 3 for an unusable ledger), and `status`, `cap show` and `receipt` all exit 1 with the bare parser message, which names neither the ledger file nor the line. The README's exit-code table reserves 3 for 'a ledger cap_charge is unusable' and 1 for 'any unexpected error', so a script branching on 3 to alert on ledger problems never fires, and the operator has no diagnostic command left that works. Fix: catch parse failures per line in readJsonl, raise a typed error naming the file and line number, map it to UnusableLedgerCharge/REFUSED_CAP in run, and let status and cap show report the corrupt line instead of dying.","line":34,"path":"imd-flop/src/store.ts","reproduction":"In a ready home run one successful mock call, then: printf '{\"ts\":\"2026-11-10T00:00:00Z\",\"request_id\":\"trunc\"' >> $IMD_FLOP_HOME/ledger.jsonl (no closing brace). Then: imd-flop run --seat 9001 --job i1 --prompt 'Say hi' --provider mock; imd-flop status --seat 9001; imd-flop cap show --seat 9001; imd-flop receipt mock-9001-x --seat 9001. Expected: run refuses with exit 3 naming the ledger problem; status and cap show still render and name the bad line. Actual at 716819c (judge probe I): all four exit 1 with \"imd-flop: Expected ',' or '}' after property value in JSON at position 49 (line 1 column 50)\"; provider.complete not called.","severity":"low","snippet":"    .map((l) => JSON.parse(l) as T);","title":"A truncated or corrupt ledger line makes run, status, cap show and receipt all exit 1 with a raw JSON SyntaxError instead of a local refusal naming the file"},{"citation":"resolved","description":"sumCharges (ledger.ts:64-71) throws UnusableLedgerCharge for any latest row whose cap_charge is missing, null, non-numeric, negative or non-finite. runJob maps that to REFUSED_CAP (exit 3) at run.ts:210, which is correct. But `status` (cli.ts:375) and `cap show` (cli.ts:263) call spentToday unguarded, so the same ledger state makes both diagnostic commands exit 1 with 'ledger cap_charge unusable; next call refused' and no row identifier. The request_id that needs repair is never shown anywhere, and `receipt <id> --refresh`, the only command the README offers to repair a ledger row (cli.ts:437-440), requires the operator to already know the request_id. Fix: let UnusableLedgerCharge carry the request_id(s), have status and cap show print the offending request_id(s) with today's known-good total instead of throwing, and keep run on exit 3.","line":375,"path":"imd-flop/src/cli.ts","reproduction":"In a ready home run one mock call, then append a copy of its latest ledger row with request_id 'mock-9001-bad' and \"cap_charge\":\"nope\" to $IMD_FLOP_HOME/ledger.jsonl. imd-flop run --seat 9001 --job j1 --prompt 'Say hi' --provider mock exits 3 'refused locally, nothing sent: ledger cap_charge unusable; next call refused' (correct). imd-flop status --seat 9001 and imd-flop cap show --seat 9001: expected exit 0 naming the request_id whose cap_charge is unusable. Actual at 716819c (judge probe J): both exit 1 with 'imd-flop: ledger cap_charge unusable; next call refused'.","severity":"low","snippet":"          today_utc: { flop: spentToday(h, seat, \"flop\"), mock: spentToday(h, seat, \"mock\") },","title":"UnusableLedgerCharge is not a CliError: status and cap show exit 1 whenever run is refusing with exit 3, so the operator cannot inspect the ledger state that caused the refusal"},{"citation":"resolved","description":"After the refreshed receipt has already been written to receipts/<id>.json (cli.ts:422), the ledger append at cli.ts:447 runs under its own withLedgerLock. If another process holds the lock (or the lock is stale and unreclaimable, see the zero-byte-lock finding), appendLedger throws LedgerLockError, the bare catch at cli.ts:448 prints the fixed string 'receipt and ledger disagree; reservation held, chain-final green blocked' and the command returns EXIT.OK. The message is false (the ledger could not be written; nothing disagrees), the exit code tells a script the refresh succeeded, and the two stores are now inconsistent: the receipt file says state settled / settled 0.05 while the ledger's latest row is still state estimated, spend null, cap_charge 0.186. Only a later refresh that happens to get the lock repairs it. The outcome is safe for spend (the reservation is kept), but the operator is told the wrong cause and a script cannot tell 'ledger updated' from 'not updated'. Fix: catch LedgerLockError separately, report 'ledger lock timeout; settlement not booked, re-run --refresh', and exit non-zero (10 fits the documented meaning: lock not taken, nothing written); or write the receipt file only after the ledger append succeeds. Merged from two specialist reports.","line":448,"path":"imd-flop/src/cli.ts","reproduction":"In a ready home run one mock call (exit 0, note request_id R). Create $IMD_FLOP_HOME/ledger.jsonl.lock containing {\"pid\":<a live pid>,\"host\":\"<hostname>\",\"token\":\"other\"}. Run imd-flop receipt R --refresh --json --seat 9001 with a provider whose receipt is {request_id: R, state: settled, settled_spend: 0.05}. Expected: a non-zero exit naming the lock failure, or the receipt file not updated ahead of the ledger. Actual at 716819c (judge probe K): after the 5 s lock wait, exit 0, stderr 'receipt and ledger disagree; reservation held, chain-final green blocked', receipts/R.json now says state settled / settled 0.05, the ledger's latest row for R is still {state: estimated, spend: null, cap_charge: 0.186}, and the printed ledger_spend is null.","severity":"low","snippet":"                } catch {","title":"receipt --refresh swallows a ledger-lock failure as 'receipt and ledger disagree' and exits 0, leaving the receipt file settled while the ledger still holds the reservation"},{"citation":"resolved","description":"The README accepts exit 1 for a post-call lock failure, and the pre-call/post-call split (10 vs 1) is implemented as documented (TS-I14 passes). But the failure mode is harsher than described. appendLedger at run.ts:322 (success) and run.ts:250 (timeout/error branch) is not wrapped, so the raw LedgerLockError escapes runJob and cli.ts:475 prints only 'imd-flop: ledger lock timeout'; nothing is written to stdout even with --json. The operator gets neither the request_id, the response file path nor the receipt line, although the paid call completed and both files were written; the only way to find the attempt is `status`. On the timeout branch the same failure exits 1 instead of 6, the ledger's latest row stays `sent` (not timeout_unknown), and the 'Spend is UNKNOWN ... Check later with imd-flop receipt <id>' guidance is lost. On the success branch a cost_reported above the padded quote (0.25 vs 0.186) is never recorded, so the exposure raise the ledger-reservation tests guarantee is silently dropped and spentToday under-counts until a later refresh. Fix: catch LedgerLockError around both post-call appends, print the request_id, response path and 'ledger row not written: <state> pending' to stderr (and the normal JSON to stdout), then exit with the branch's own code (6 on timeout) or 1 as documented.","line":322,"path":"imd-flop/src/run.ts","reproduction":"In a ready home run with a mock provider whose complete() first creates $IMD_FLOP_HOME/ledger.jsonl.lock with {\"pid\":<live pid>,\"host\":\"<hostname>\",\"token\":\"other\"} and then returns usage cost_flop 0.25 (padded quote 0.186): imd-flop run --seat 9001 --job l1 --prompt 'Say hi' --max-tokens 8 --provider mock --json. Actual at 716819c (judge probe L1): exit 1, stdout empty, stderr exactly 'imd-flop: ledger lock timeout', provider called once, responses/ and receipts/ written, ledger rows for the id: [sent, 0.186, cost_reported null] only. Timeout variant (provider creates the lock then never answers; --timeout-ms 50; judge probe L2): exit 1 instead of 6, stderr 'imd-flop: ledger lock timeout', ledger latest row state sent, no request_id shown. Expected: the request_id and outcome on the terminal, and exit 6 on the timeout branch.","severity":"low","snippet":"  appendLedger(h, { ...sent, ts: new Date().toISOString(), state: classified.state ?? \"sent\", reason: classified.flag, spend: settledKnown, cost_reported: costReported, cap_charge: capCharge, http: rr.http });","title":"A ledger-lock failure after the paid call exits 1 with only 'ledger lock timeout': request_id, response path and a higher reported cost are dropped, and the timeout branch exits 1 instead of 6"},{"citation":"resolved","description":"reclaimIfDead decides a lock is stale from one read of `path` (ledger.ts:109) and then renames whatever is at `path` (ledger.ts:111) without re-checking. Between those two steps another waiter can finish the same reclaim and create its own live lock at `path`. The second reclaimer then moves that live lock aside. The code anticipates this and links it back (ledger.ts:113), but between the rename and the link-back `path` does not exist, so any other waiter's openSync(path, 'wx') succeeds and it runs lockedCheck() while the first holder is still inside its body. The link-back then fails EEXIST and that reclaimer exits 10 'contested', and the live holder's file is left stranded as <lock>.stale.<pid>.<hex> (its releaseLock is a no-op). Result: two cap checks read the same spent_before, both pass, both append `sent`, so the daily cap can be exceeded by one padded quote. Preconditions: a holder that crashed leaving a lock older than 30 s with a dead pid on this host, plus three or more runs started together (e.g. automation retrying after a crash); the windows are microseconds, so this is an atomicity defect rather than an easy exploit. The comment at ledger.ts:104-106 claims two waiters cannot both act on one stale file; that no longer holds once the moved file is a live lock. Fix: re-read `path` immediately before the rename and refuse to move it unless its owner is still the dead one (compare pid and token), or reclaim via open + fstat and only unlink if the inode still matches what was read; on EEXIST at link-back unlink the aside file only if its token is the mover's own. Merged from two specialist reports.","line":111,"path":"imd-flop/src/ledger.ts","reproduction":"Emulated interleaving at 716819c (judge probe Q, using the unmodified src/ledger.ts): (1) child process A runs withLedgerLock(home, body) where body prints HOLDING, sleeps 3 s via Atomics.wait, prints DONE; A's own live pid is in the lock file. (2) After HOLDING, perform the step reclaimIfDead takes after a stale owner read, exactly ledger.ts:111: renameSync(home/ledger.jsonl.lock, home/ledger.jsonl.lock.stale.emulated). (3) Call withLedgerLock(home, () => 'entered') in another process. Expected: the call waits until A releases (about 3 s) or exits 10. Actual: it returns 'entered' after 0 ms while A is still inside its body (DONE not yet printed): entered=true, holderStillRunning=true. Step 4 in the real sequence, linkSync(aside, path) at ledger.ts:113, then fails EEXIST and throws 'ledger lock contested'.","severity":"low","snippet":"  try { renameSync(path, aside); } catch { return false; } // someone else moved or released it","title":"Stale-lock reclaim renames whatever is at the lock path after a separate owner read, so a live holder's fresh lock can be moved aside and a third process enters the ledger critical section concurrentl"},{"citation":"resolved","description":"The `quote` command prints `est`, the parsed body of POST /models/estimate, as-is. Every other place gateway JSON reaches stdout (run --json receipt, receipt, status) goes through writeJsonSafe/redactFlagged first so a bearer-, flop_ek_- or key-shaped string from the gateway is withheld (TS-L8, stdout-withhold tests). `quote` has no such step, so any extra field in the estimate response is echoed. No local secret is loaded on this path (no gateway key is read), so the exposure is gateway-supplied content only, in a command the README lists as 'live, free, opt-in'. Consistency gap rather than a leak of local material. Fix: print only the fields padQuote consumes (model, estimated_input_tokens, estimated_output_tokens, estimated_cost_flop), or pass est through redactFlagged before print.","line":294,"path":"imd-flop/src/cli.ts","reproduction":"Supply a fetch whose /models/estimate response is {\"estimated_cost_flop\":0.066,\"estimated_input_tokens\":3,\"estimated_output_tokens\":8,\"debug\":\"Bearer SYNTHETICNOTAKEY0123456789\"} and run: imd-flop quote --prompt hi --provider flop. Expected: the Bearer-shaped string is withheld from stdout. Actual at 716819c (specialist proof section F, re-run by the judge): exit 0 and stdout contains 'Bearer SYNTHETICNOTAKEY0123456789'.","severity":"info","snippet":"        print({ provider: pname, estimate: est, padded: padQuote(est), note: \"padded = raw + 20 tokens for the Qwen chat template\" });","title":"quote --provider flop prints the gateway's estimate JSON to stdout verbatim, without the redaction every other gateway-derived stdout path applies"},{"citation":"resolved","description":"num() is used for `cap set <FLOP/day>`, --per-call and --job-budget. Number() accepts hexadecimal (0x10 is 16), binary/octal prefixes, exponent notation (1e-7) and surrounding whitespace, so `cap set 0x10` silently sets a 16 FLOP/day cap and `--per-call 1e-7` sets a per-call cap below the 1e-6 quote granularity that ceil6 produces, after which every run is refused with exit 3 and the message 'padded quote 0.186 FLOP is over the per-call cap 1e-7'. run --max-tokens goes through Number() (cli.ts:327) but is then integer-range checked in runJob; quote --max-tokens (cli.ts:289) has no check, so `quote --max-tokens abc` exits 0 and prints estimated_output_tokens null / estimated_cost_flop null / padded null, and with --provider flop would send max_tokens: null to the gateway. Nothing here spends FLOP (quote is free, caps only ever refuse): a hardening note. Fix: validate FLOP amounts with a decimal regex (^\\d+(\\.\\d{1,6})?$) and reuse the run path's integer check for quote.","line":71,"path":"imd-flop/src/cli.ts","reproduction":"In a ready home: imd-flop cap set 0x10 --seat 9001 --json exits 0 with cap {daily: 16, perCall: 16}; imd-flop cap set ' 5 ' --per-call 1e-7 --seat 9001 --json exits 0 with perCall 1e-7, after which imd-flop run --seat 9001 --job m1 --prompt 'Say hi' --provider mock exits 3 'padded quote 0.186 FLOP is over the per-call cap 1e-7'; imd-flop quote --prompt hi --max-tokens abc exits 0 and prints estimated_output_tokens null, padded null. Expected: exit 2 (USAGE) for each malformed number. Verified at 716819c (judge probe M).","severity":"info","snippet":"  const n = Number(v);","title":"Numeric CLI arguments are parsed with Number(): hex, exponent and padded strings are accepted for caps and budgets, and quote --max-tokens is not validated at all"},{"citation":"resolved","description":"Re-audit of e99a2cf1 I14: the lock taken at run.ts:228 now maps LedgerLockError to exit 10 and nothing is sent (TS-I14 passes). One pre-call path still does not: when the estimate fails or returns no usable cost (run.ts:198, 200), refuse() appends a refused_local row through appendLedger (run.ts:189), which takes the lock itself. A LedgerLockError there is not a CliError, so main exits 1 with 'imd-flop: ledger lock timeout', without the 'refused locally' prefix, without naming the estimate failure, and without the refused_local row. README.md:48 records this as a known gap, two lines after README.md:46 states the unconditional rule, so it is an accepted residual rather than a regression; listed so the re-audit of I14 is complete. Nothing is sent and no FLOP is at risk. Fix: in refuse(), catch LedgerLockError around appendLedger and rethrow CliError('refused locally, nothing sent: ...', EXIT.REFUSED_LOCK), or let the original refusal code win since the row is informational. Merged from two specialist reports.","line":189,"path":"imd-flop/src/run.ts","reproduction":"State: seat 9001 ready with cap 5; $IMD_FLOP_HOME/ledger.jsonl.lock contains {\"pid\":<a live pid on this host>,\"host\":\"<hostname>\",\"token\":\"other\"}. Command: imd-flop run --seat 9001 --job n1 --prompt hi --provider mock --mock-scenario estimate_error. Expected per README.md:46: exit 10 (or at least 7) with 'refused locally, nothing sent'. Actual at 716819c (judge probe N): after the 5 s lock wait, exit 1, stderr 'imd-flop: ledger lock timeout', no ledger row written, complete() never called. The same command with the lock free exits 7.","severity":"info","snippet":"    appendLedger(h, { ...base, request_id: null, quote_raw: q.raw, quote_padded: q.padded, spent_before: spentBefore, state: \"refused_local\", reason, spend: null, cost_reported: null, cap_charge: 0, http: null });","title":"Estimate failure while another process holds the ledger lock exits 1 and drops the ESTIMATE_FAILED refusal (documented residual of e99a2cf1 I14)"},{"citation":"resolved","description":"Re-audit of e99a2cf1 I12: fixed as far as a runtime check can go (TS-I12 passes; a promise-returning body throws TypeError and leaves no lock behind). syncResult (ledger.ts:146-152) only sees the promise after fn() has already run to its first await, so an async body's pre-await part executes under the lock, the TypeError is thrown to the caller, the lock is released, and the body's post-await part still runs unlocked (test/audit-v7d.test.ts itself asserts afterAwait === true). The signature `fn: () => T` at ledger.ts:155 still accepts async functions at compile time, so a future caller gets the error only when the code path executes. Production callers (lockedCheck, appendLedger) are synchronous, so no current spend path is affected. Fix: narrow the type so a promise-returning body fails tsc, e.g. `fn: () => T extends PromiseLike<unknown> ? never : T`, keeping the runtime check as a backstop.","line":155,"path":"imd-flop/src/ledger.ts","reproduction":"withLedgerLock(home, async () => { appendLedger(home, rowA); await Promise.resolve(); appendLedger(home, rowB); }) in a ready home. Expected: neither append happens, or both happen under the lock. Actual at 716819c (judge probe O): a TypeError 'withLedgerLock body must be synchronous...' is thrown to the caller, rowA (request_id A) was appended under the lock, and rowB (request_id B) is appended on the next microtask after the lock file was removed (ledger contains both A and B; lock file absent). tsc -p tsconfig.json --noEmit accepts the call.","severity":"info","snippet":"export function withLedgerLock<T>(h: string, fn: () => T): T {","title":"Async-body refusal in withLedgerLock is runtime-only: the body's pre-await part runs under the lock and its post-await part still runs outside it, and tsc accepts the call"}],"hash":"ac2e9bdeb5d59d873bbf4ff78da40544481c8378f7c568f39c56dae23a601c3b","nodeId":"6d212830-3ffd-45ca-b14f-476045dda577","outcome":"completed","summary":"The review is complete. `.imd-findings.json` in the repository root holds 15 merged findings, each with a reproduction run against commit 716819c. No repository files were changed.\n\n**How I checked.** I copied `imd-flop` to a temp directory, installed its locked dependencies, and ran the full suite plus both specialist proof files and my own probe file. The two specialist proofs are byte-identical vitest files, not Foundry tests, so none of the findings carries a `proof` field. None is critical or high, so none needs one.\n\n**Prior audit e99a2cf1.** All 149 existing tests pass, including TS-M1 to M4, L5 to L11, I12, I13 and I14. Those fixes hold. Two residuals remain and are reported as info: the estimate-failure-under-held-lock path still exits 1 (documented in the README as a known gap), and the async-body refusal is runtime-only, so the body's tail still runs outside the lock.\n\n**Every specialist finding reproduced.** Nothing was dropped. Duplicates were merged as follows:\n\n- Secret scan ignoring object keys: 3 reports merged into one low.\n- Zero-byte lock never reclaimed: 3 reports merged into one low.\n- Estimate requested before a locally decidable cap refusal: 2 reports merged into one low.\n- `receipt --refresh` swallowing a lock failure with exit 0: 2 reports merged into one low.\n- Stale-lock reclaim race: 2 reports merged into one low.\n- Estimate failure under a held lock: 2 reports merged into one info.\n\n**Severity summary.**\n\n| Severity | Count | Findings |\n|---|---|---|\n| medium | 3 | unvalidated `payer` fails open and status shows 0 spent; pre-send error text tripping the secret scan leaves a phantom `sent` reservation that burns the day's cap and exits 5; `receipt --refresh` after a timeout writes a receipt without seat/did so match is permanently `mismatch` while the settlement is still booked |\n| low | 8 | key-name scan gap; zero-byte lock wedge; estimate before cap refusal; corrupt ledger line exits 1 everywhere; status and cap show exit 1 on an unusable cap_charge; refresh lock swallow; post-call lock failure drops request_id and exits 1 instead of 6 on timeout; reclaim rename race |\n| info | 4 | `quote --provider flop` echoes gateway JSON unredacted; `Number()` parsing of caps; estimate failure under held lock (documented); async-body check is runtime-only |\n\n**Scope coverage.** The lock contract is otherwise as documented: exit 10 before the paid call and exit 1 after it were confirmed directly. Exit 3 cap refusals hold for holder and requester payers when the payer value is valid. Request_id attempts and stdout withholding behave as the existing tests assert, with the gaps above being the only deviations found.","treeHash":null,"usage":{"cachedInputTokens":974338,"cost":{"source":"runtime","usdMicros":4591083},"inputTokens":290,"model":"claude-fable-5-1","outputTokens":35429,"runtime":"claude","turns":25,"wallClockMs":522583}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"8028959046c6ea91","findings":[{"citation":"resolved","description":"loadConfig (imd-flop/src/config.ts:18-21) spreads config.json over DEFAULT_CONFIG without checking that payer is \"holder\" or \"requester\". runJob only requires --job-budget when payer === \"requester\" (run.ts:159), and the cap check at run.ts:214-221 treats every non-\"holder\" value as the requester branch. There o.jobBudget is undefined, so left = NaN and `q.padded > NaN` is false: the run is never refused. Every ledger row then carries payer \"sponsor\", which spentToday (ledger.ts:74, payer === \"holder\") and jobSpent (ledger.ts:78, payer === \"requester\") both skip, so no attempt ever counts against any cap, and `status`/`cap show` report today's spend as 0 while calls keep going out. The sibling field caps[seat] was made to fail closed for exactly this class of input (prior audit TS-L10, run.ts:154-157); payer was not. Who can trigger it: anything that writes config.json other than `config set payer` (the agent operating the CLI, a hand edit, or a future writer). The holder's caps are the only brake on that agent, and this bypass leaves no trace in `cap show`.\nExpected: a payer outside holder|requester refuses every run with exit 3 (REFUSED_CAP) and nothing sent, as a malformed cap does. Actual: every run exits 0 and is sent.\nFix: validate payer in loadConfig (or at run.ts:152 next to the cap check) and refuse with EXIT.REFUSED_CAP when it is not exactly \"holder\" or \"requester\"; make the run.ts:218 branch explicit (`else if (cfg.payer === \"requester\")`) with a final fail-closed refuse.","line":219,"path":"imd-flop/src/run.ts","proof":"/**\n * Re-audit probes (not part of the repo). Each test asserts the SAFE outcome, so a failure here is a lead.\n */\nimport { existsSync, readFileSync, writeFileSync } from \"node:fs\";\nimport { hostname } from \"node:os\";\nimport { join } from \"node:path\";\nimport { describe, expect, it } from \"vitest\";\nimport type { CompleteReq, CompleteRes, Provider } from \"../src/providers.js\";\nimport { PreSendError } from \"../src/gateway.js\";\nimport { cli, cliP, readLines, readySeat, tmpHome } from \"./helpers.js\";\n\nconst SEAT = \"9001\";\nconst HEX = \"ee\".repeat(32);\nconst quote = { estimated_cost_flop: 0.066, estimated_input_tokens: 3, estimated_output_tokens: 8 };\n\nclass P implements Provider {\n  readonly name = \"mock\" as const;\n  calls = 0;\n  estimates = 0;\n  constructor(private readonly o: { usage?: Record<string, unknown>; complete?: (req: CompleteReq) => Promise<CompleteRes>; estimate?: () => Promise<unknown> } = {}) {}\n  async estimate() { this.estimates += 1; if (this.o.estimate) return this.o.estimate() as never; return quote; }\n  async complete(req: CompleteReq): Promise<CompleteRes> {\n    this.calls += 1;\n    if (this.o.complete) return this.o.complete(req);\n    return { request_id: req.requestId, text: \"BENIGN-CONTROL-output\", usage: (this.o.usage ?? { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }) as CompleteRes[\"usage\"] };\n  }\n  async readReceipt(requestId: string) {\n    return { http: 200, error: null, receipt: { request_id: requestId, correlation_id: \"imd-job-j1\", model: \"audit\", reserved_spend: 0.186, settled_spend: null, settled_spend_raw: null, terminal_event: null, channel_id: null, created_at: 0, state: \"estimated\" as const, response_status: 200 } };\n  }\n}\nconst runArgs = (job = \"j1\", json = true) => [\"run\", \"--seat\", SEAT, \"--job\", job, \"--prompt\", \"Say hi\", \"--max-tokens\", \"8\", \"--provider\", \"mock\", ...(json ? [\"--json\"] : [])];\ntype Row = { request_id: string | null; state: string; spend: number | null; cap_charge: unknown; payer: string };\nconst ledger = (home: string) => (existsSync(join(home, \"ledger.jsonl\")) ? (readLines(join(home, \"ledger.jsonl\")) as Row[]) : []);\n\ndescribe(\"A: config payer outside holder|requester must fail closed\", () => {\n  it(\"payer 'sponsor' with daily cap 0.2: second run must be refused (3), nothing sent\", async () => {\n    const home = tmpHome();\n    await readySeat(home, SEAT, \"0.2\");\n    const cfgPath = join(home, \"config.json\");\n    const cfg = JSON.parse(readFileSync(cfgPath, \"utf8\"));\n    cfg.payer = \"sponsor\";\n    writeFileSync(cfgPath, JSON.stringify(cfg));\n    const p = new P();\n    const codes: number[] = [];\n    for (const j of [\"p1\", \"p2\", \"p3\"]) codes.push((await cliP(home, runArgs(j), p)).code);\n    const status = (await cli(home, [\"status\", \"--seat\", SEAT, \"--json\"])).json();\n    console.log(\"A codes\", codes, \"calls\", p.calls, \"today\", status.today_utc, \"payers\", ledger(home).map((r) => r.payer));\n    expect(p.calls).toBeLessThanOrEqual(1);\n    expect(codes.slice(1).every((c) => c === 3)).toBe(true);\n  });\n});\n\ndescribe(\"B: pre-mint refusal whose message trips the secret scan\", () => {\n  it(\"PreSendError with a 64-hex in its message: latest row refused_local cap 0, exit 1, next run allowed\", async () => {\n    const home = tmpHome();\n    await readySeat(home, SEAT, \"0.186\");\n    const p = new P({ complete: async () => { throw new PreSendError(`eth_chainId error: ${HEX}`); } });\n    const r = await cliP(home, runArgs(\"m1\"), p);\n    const rows = ledger(home);\n    console.log(\"B code\", r.code, \"err\", r.err, \"rows\", rows.map((x) => [x.state, x.cap_charge]));\n    const next = new P();\n    const r2 = await cliP(home, runArgs(\"m2\"), next);\n    console.log(\"B next code\", r2.code, \"err\", r2.err, \"calls\", next.calls);\n    expect(rows.at(-1)).toMatchObject({ state: \"refused_local\", cap_charge: 0 });\n    expect(r.code).toBe(1);\n    expect(r2.code).toBe(0);\n  });\n  it(\"PreSendError with a decimal chain id of 78 digits (BigInt of 0xff..ff) also trips it\", async () => {\n    const home = tmpHome();\n    await readySeat(home, SEAT, \"0.186\");\n    const big = BigInt(\"0x\" + \"f\".repeat(64)).toString();\n    const p = new P({ complete: async () => { throw new PreSendError(`Ethereum chain id ${big} is not 1 (seat collection 0x0000ec93127baa929e58e97dd0095a2bfb38ec1d is on chain 1)`); } });\n    const r = await cliP(home, runArgs(\"m1\"), p);\n    const rows = ledger(home);\n    console.log(\"B2 code\", r.code, \"err\", r.err, \"rows\", rows.map((x) => [x.state, x.cap_charge]));\n    expect(rows.at(-1)).toMatchObject({ state: \"refused_local\", cap_charge: 0 });\n  });\n});\n\ndescribe(\"C: secret scan ignores object keys\", () => {\n  it(\"usage with a 64-hex key name is written to the response file\", async () => {\n    const home = tmpHome();\n    await readySeat(home, SEAT, \"5\");\n    const p = new P({ usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2, [HEX]: 1 } });\n    const r = await cliP(home, runArgs(\"k1\"), p);\n    const file = readFileSync(r.json().response_file, \"utf8\");\n    console.log(\"C code\", r.code, \"fileHasHex\", file.includes(HEX), \"withheld\", r.json().withheld);\n    expect(file.includes(HEX)).toBe(false);\n  });\n  it(\"usage with a flop_ek_ token as a key name is written to the response file\", async () => {\n    const home = tmpHome();\n    await readySeat(home, SEAT, \"5\");\n    const tok = \"flop_ek_SYNTHETICNOTAKEY0123456789\";\n    const p = new P({ usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2, [tok]: 1 } });\n    const r = await cliP(home, runArgs(\"k2\"), p);\n    const file = readFileSync(r.json().response_file, \"utf8\");\n    console.log(\"C2 fileHasTok\", file.includes(tok));\n    expect(file.includes(tok)).toBe(false);\n  });\n});\n\ndescribe(\"D: daily cap already reached: estimate is still called; estimate failure wins over cap refusal\", () => {\n  it(\"cap 0.186 reached: second run calls estimate; failing estimate exits 7 not 3\", async () => {\n    const home = tmpHome();\n    await readySeat(home, SEAT, \"0.186\");\n    expect((await cliP(home, runArgs(\"d1\"), new P())).code).toBe(0);\n    const p = new P({ estimate: async () => { throw new Error(\"estimate HTTP 503\"); } });\n    const r = await cliP(home, runArgs(\"d2\"), p);\n    console.log(\"D code\", r.code, \"estimates\", p.estimates, \"err\", r.err);\n    expect(p.estimates).toBe(0);\n    expect(r.code).toBe(3);\n  });\n});\n\ndescribe(\"E: lock held after the paid call (coverage)\", () => {\n  it(\"lock appears during complete(): exit 1, latest row sent\", async () => {\n    const home = tmpHome();\n    await readySeat(home, SEAT, \"5\");\n    const p = new P({ complete: async (req) => {\n      writeFileSync(join(home, \"ledger.jsonl.lock\"), JSON.stringify({ pid: process.pid, host: hostname(), token: \"other\" }));\n      return { request_id: req.requestId, text: \"ok\", usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 } };\n    } });\n    const r = await cliP(home, runArgs(\"e1\"), p);\n    console.log(\"E code\", r.code, \"err\", r.err, \"rows\", ledger(home).map((x) => [x.state, x.cap_charge]), \"receipt\", existsSync(join(home, \"receipts\")));\n    expect(r.code).toBe(1);\n  }, 20000);\n});\n\ndescribe(\"F: quote --provider flop echoes the gateway body\", () => {\n  it(\"prints the estimate JSON verbatim\", async () => {\n    const home = tmpHome();\n    await readySeat(home, SEAT, \"5\");\n    const f = (async () => new Response(JSON.stringify({ ...quote, debug: `Bearer SYNTHETICNOTAKEY0123456789` }), { status: 200, headers: { \"content-type\": \"application/json\" } })) as unknown as typeof fetch;\n    const r = await cli(home, [\"quote\", \"--prompt\", \"hi\", \"--provider\", \"flop\"], { fetch: f });\n    console.log(\"F code\", r.code, \"outHasBearer\", r.out.includes(\"Bearer SYNTHETICNOTAKEY\"));\n    expect(r.out.includes(\"Bearer SYNTHETICNOTAKEY\")).toBe(false);\n  });\n});","reproduction":"1. IMD_FLOP_HOME=$(mktemp -d); imd-flop init --seat 9001; imd-flop link --token 9001 --agent 7; imd-flop cap set 0.2\n2. Edit $IMD_FLOP_HOME/config.json and set \"payer\": \"sponsor\" (any string other than holder/requester).\n3. Run three times: imd-flop run --seat 9001 --job p1 --prompt 'Say hi' --max-tokens 8 --provider mock --json (then p2, p3). Each padded quote is 0.186 FLOP against a 0.2 FLOP/day cap.\nExpected: run 2 and 3 exit 3 'refused locally, nothing sent' (or all three exit 3 for a malformed payer). Actual (observed with vitest at 716819c): exit codes [0, 0, 0], provider.complete called 3 times, ledger rows all payer \"sponsor\", `status --json` today_utc = {flop: 0, mock: 0}. Probe test: section A of the vitest file in `proof`.","severity":"medium","snippet":"      const left = Math.round((o.jobBudget! - spentBefore) * 1e6) / 1e6;","title":"Unvalidated config.payer disables both the daily cap and the job budget (fail-open), while status still shows 0 spent"},{"citation":"resolved","description":"When provider.complete() rejects with PreSendError (FlopProvider.complete wraps every assertPaidNetwork failure and every mintChildKey failure this way, providers.ts:139-146), the catch block builds reason = `nothing sent: ${err.message...}` (run.ts:249) and appends the refused_local row with cap_charge 0 (run.ts:250). appendLedger runs assertNoSecrets on that row. Only `0x` + 64 hex is stripped from the message; any other 64-or-more hex run, a `Bearer ...` or `flop_ek_...` shaped substring, or a loaded secret is not. Those strings come from the RPC/faucet the recheck talks to: gateway.ts:227 `eth_chainId error: ${j.error.message}`, gateway.ts:181 `rpc error: ${msg.error.message}`, gateway.ts:259 `Ethereum chain id ${chainId} ...` (a chain id of 0xff..ff (64 f) prints as a 78-digit decimal, which HEX64 at secrets.ts:52 matches because digits are hex characters). When the scan hits, appendLedger throws CliError(REFUSED_RULE) from inside the catch: the refused_local line is never written, the pre-call `sent` row (cap_charge = padded quote) stays the latest line for that request_id, and the CLI exits 5 'secret scan blocked writing ledger.jsonl' instead of 1 'nothing sent'. spentToday then counts the phantom reservation for the rest of the UTC day, so the next run is refused with 'daily cap reached' although no child key was minted and nothing was sent. `receipt <id> --refresh` cannot clear it: the gateway has no receipt for an unminted request, so capCharge stays prev.cap_charge (cli.ts:442). `status` lists it as a pending paid attempt. This is the invariant the README states for this path ('the latest ledger line is refused_local with cap charge 0') and that TS-L11 tests only with a clean message. Trigger: the ETH_RPC_URL endpoint or the FLOP devnet RPC/faucet answering the pre-call check correctly and the in-provider recheck with such an error (a hostile operator, or an inconsistent load-balanced RPC whose error text carries a hash without 0x).\nFix: build the ledger reason from a fixed vocabulary (for example `nothing sent: pre-send refusal`) or pass the message through redactFlagged/scan before the append, and if the append still throws, still throw the EXIT.ERROR 'nothing sent' CliError after writing a minimal refused_local row; never let a scan failure keep the sent reservation live.","line":250,"path":"imd-flop/src/run.ts","proof":"/**\n * Re-audit probes (not part of the repo). Each test asserts the SAFE outcome, so a failure here is a lead.\n */\nimport { existsSync, readFileSync, writeFileSync } from \"node:fs\";\nimport { hostname } from \"node:os\";\nimport { join } from \"node:path\";\nimport { describe, expect, it } from \"vitest\";\nimport type { CompleteReq, CompleteRes, Provider } from \"../src/providers.js\";\nimport { PreSendError } from \"../src/gateway.js\";\nimport { cli, cliP, readLines, readySeat, tmpHome } from \"./helpers.js\";\n\nconst SEAT = \"9001\";\nconst HEX = \"ee\".repeat(32);\nconst quote = { estimated_cost_flop: 0.066, estimated_input_tokens: 3, estimated_output_tokens: 8 };\n\nclass P implements Provider {\n  readonly name = \"mock\" as const;\n  calls = 0;\n  estimates = 0;\n  constructor(private readonly o: { usage?: Record<string, unknown>; complete?: (req: CompleteReq) => Promise<CompleteRes>; estimate?: () => Promise<unknown> } = {}) {}\n  async estimate() { this.estimates += 1; if (this.o.estimate) return this.o.estimate() as never; return quote; }\n  async complete(req: CompleteReq): Promise<CompleteRes> {\n    this.calls += 1;\n    if (this.o.complete) return this.o.complete(req);\n    return { request_id: req.requestId, text: \"BENIGN-CONTROL-output\", usage: (this.o.usage ?? { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }) as CompleteRes[\"usage\"] };\n  }\n  async readReceipt(requestId: string) {\n    return { http: 200, error: null, receipt: { request_id: requestId, correlation_id: \"imd-job-j1\", model: \"audit\", reserved_spend: 0.186, settled_spend: null, settled_spend_raw: null, terminal_event: null, channel_id: null, created_at: 0, state: \"estimated\" as const, response_status: 200 } };\n  }\n}\nconst runArgs = (job = \"j1\", json = true) => [\"run\", \"--seat\", SEAT, \"--job\", job, \"--prompt\", \"Say hi\", \"--max-tokens\", \"8\", \"--provider\", \"mock\", ...(json ? [\"--json\"] : [])];\ntype Row = { request_id: string | null; state: string; spend: number | null; cap_charge: unknown; payer: string };\nconst ledger = (home: string) => (existsSync(join(home, \"ledger.jsonl\")) ? (readLines(join(home, \"ledger.jsonl\")) as Row[]) : []);\n\ndescribe(\"A: config payer outside holder|requester must fail closed\", () => {\n  it(\"payer 'sponsor' with daily cap 0.2: second run must be refused (3), nothing sent\", async () => {\n    const home = tmpHome();\n    await readySeat(home, SEAT, \"0.2\");\n    const cfgPath = join(home, \"config.json\");\n    const cfg = JSON.parse(readFileSync(cfgPath, \"utf8\"));\n    cfg.payer = \"sponsor\";\n    writeFileSync(cfgPath, JSON.stringify(cfg));\n    const p = new P();\n    const codes: number[] = [];\n    for (const j of [\"p1\", \"p2\", \"p3\"]) codes.push((await cliP(home, runArgs(j), p)).code);\n    const status = (await cli(home, [\"status\", \"--seat\", SEAT, \"--json\"])).json();\n    console.log(\"A codes\", codes, \"calls\", p.calls, \"today\", status.today_utc, \"payers\", ledger(home).map((r) => r.payer));\n    expect(p.calls).toBeLessThanOrEqual(1);\n    expect(codes.slice(1).every((c) => c === 3)).toBe(true);\n  });\n});\n\ndescribe(\"B: pre-mint refusal whose message trips the secret scan\", () => {\n  it(\"PreSendError with a 64-hex in its message: latest row refused_local cap 0, exit 1, next run allowed\", async () => {\n    const home = tmpHome();\n    await readySeat(home, SEAT, \"0.186\");\n    const p = new P({ complete: async () => { throw new PreSendError(`eth_chainId error: ${HEX}`); } });\n    const r = await cliP(home, runArgs(\"m1\"), p);\n    const rows = ledger(home);\n    console.log(\"B code\", r.code, \"err\", r.err, \"rows\", rows.map((x) => [x.state, x.cap_charge]));\n    const next = new P();\n    const r2 = await cliP(home, runArgs(\"m2\"), next);\n    console.log(\"B next code\", r2.code, \"err\", r2.err, \"calls\", next.calls);\n    expect(rows.at(-1)).toMatchObject({ state: \"refused_local\", cap_charge: 0 });\n    expect(r.code).toBe(1);\n    expect(r2.code).toBe(0);\n  });\n  it(\"PreSendError with a decimal chain id of 78 digits (BigInt of 0xff..ff) also trips it\", async () => {\n    const home = tmpHome();\n    await readySeat(home, SEAT, \"0.186\");\n    const big = BigInt(\"0x\" + \"f\".repeat(64)).toString();\n    const p = new P({ complete: async () => { throw new PreSendError(`Ethereum chain id ${big} is not 1 (seat collection 0x0000ec93127baa929e58e97dd0095a2bfb38ec1d is on chain 1)`); } });\n    const r = await cliP(home, runArgs(\"m1\"), p);\n    const rows = ledger(home);\n    console.log(\"B2 code\", r.code, \"err\", r.err, \"rows\", rows.map((x) => [x.state, x.cap_charge]));\n    expect(rows.at(-1)).toMatchObject({ state: \"refused_local\", cap_charge: 0 });\n  });\n});\n\ndescribe(\"C: secret scan ignores object keys\", () => {\n  it(\"usage with a 64-hex key name is written to the response file\", async () => {\n    const home = tmpHome();\n    await readySeat(home, SEAT, \"5\");\n    const p = new P({ usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2, [HEX]: 1 } });\n    const r = await cliP(home, runArgs(\"k1\"), p);\n    const file = readFileSync(r.json().response_file, \"utf8\");\n    console.log(\"C code\", r.code, \"fileHasHex\", file.includes(HEX), \"withheld\", r.json().withheld);\n    expect(file.includes(HEX)).toBe(false);\n  });\n  it(\"usage with a flop_ek_ token as a key name is written to the response file\", async () => {\n    const home = tmpHome();\n    await readySeat(home, SEAT, \"5\");\n    const tok = \"flop_ek_SYNTHETICNOTAKEY0123456789\";\n    const p = new P({ usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2, [tok]: 1 } });\n    const r = await cliP(home, runArgs(\"k2\"), p);\n    const file = readFileSync(r.json().response_file, \"utf8\");\n    console.log(\"C2 fileHasTok\", file.includes(tok));\n    expect(file.includes(tok)).toBe(false);\n  });\n});\n\ndescribe(\"D: daily cap already reached: estimate is still called; estimate failure wins over cap refusal\", () => {\n  it(\"cap 0.186 reached: second run calls estimate; failing estimate exits 7 not 3\", async () => {\n    const home = tmpHome();\n    await readySeat(home, SEAT, \"0.186\");\n    expect((await cliP(home, runArgs(\"d1\"), new P())).code).toBe(0);\n    const p = new P({ estimate: async () => { throw new Error(\"estimate HTTP 503\"); } });\n    const r = await cliP(home, runArgs(\"d2\"), p);\n    console.log(\"D code\", r.code, \"estimates\", p.estimates, \"err\", r.err);\n    expect(p.estimates).toBe(0);\n    expect(r.code).toBe(3);\n  });\n});\n\ndescribe(\"E: lock held after the paid call (coverage)\", () => {\n  it(\"lock appears during complete(): exit 1, latest row sent\", async () => {\n    const home = tmpHome();\n    await readySeat(home, SEAT, \"5\");\n    const p = new P({ complete: async (req) => {\n      writeFileSync(join(home, \"ledger.jsonl.lock\"), JSON.stringify({ pid: process.pid, host: hostname(), token: \"other\" }));\n      return { request_id: req.requestId, text: \"ok\", usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 } };\n    } });\n    const r = await cliP(home, runArgs(\"e1\"), p);\n    console.log(\"E code\", r.code, \"err\", r.err, \"rows\", ledger(home).map((x) => [x.state, x.cap_charge]), \"receipt\", existsSync(join(home, \"receipts\")));\n    expect(r.code).toBe(1);\n  }, 20000);\n});\n\ndescribe(\"F: quote --provider flop echoes the gateway body\", () => {\n  it(\"prints the estimate JSON verbatim\", async () => {\n    const home = tmpHome();\n    await readySeat(home, SEAT, \"5\");\n    const f = (async () => new Response(JSON.stringify({ ...quote, debug: `Bearer SYNTHETICNOTAKEY0123456789` }), { status: 200, headers: { \"content-type\": \"application/json\" } })) as unknown as typeof fetch;\n    const r = await cli(home, [\"quote\", \"--prompt\", \"hi\", \"--provider\", \"flop\"], { fetch: f });\n    console.log(\"F code\", r.code, \"outHasBearer\", r.out.includes(\"Bearer SYNTHETICNOTAKEY\"));\n    expect(r.out.includes(\"Bearer SYNTHETICNOTAKEY\")).toBe(false);\n  });\n});","reproduction":"1. Ready a seat with the cap equal to one padded quote: init --seat 9001; link --token 9001 --agent 7; cap set 0.186.\n2. Run with a provider whose complete() rejects with `new PreSendError('eth_chainId error: ' + 'ee'.repeat(32))` (this is exactly what FlopProvider.complete throws when ETH_RPC_URL answers eth_chainId with {\"error\":{\"message\":\"<64 hex>\"}} on the recheck; a chain id result of 0x followed by 64 'f' gives the same via its 78-digit decimal form). Command shape: imd-flop run --seat 9001 --job m1 --prompt 'Say hi' --max-tokens 8 --provider mock --json.\nExpected: exit 1 'nothing sent', latest ledger row state refused_local cap_charge 0, and a following run (--job m2) exits 0.\nActual at 716819c: exit 5 'imd-flop: secret scan blocked writing ledger.jsonl: reason: 64-or-more-hex (key-shaped) value'; ledger rows [['sent', 0.186]]; the following run exits 3 'refused locally, nothing sent: daily cap reached (0.186/0.186 FLOP today, UTC)' with complete() never called. Probe: section B of the vitest file in `proof` (both the 64-hex and the 78-digit variants fail).","severity":"medium","snippet":"    appendLedger(h, { ...sent, ts: new Date().toISOString(), state: timedOut ? \"timeout_unknown\" : preSend ? \"refused_local\" : \"error\", reason, spend: null, cap_charge: preSend ? 0 : sent.cap_charge, http });","title":"Pre-mint refusal whose error text trips the secret scan leaves the `sent` reservation as the latest ledger line: the daily cap is consumed with nothing sent, exit 5 instead of 1"},{"citation":"resolved","description":"scanForSecrets visits only values: for an object it recurses into each value and passes the key as the field name, but the key string itself is never matched against the registry, HEX64, FLOP_TOKEN, BEARER or PEM. A completion whose usage object (stored verbatim in the response record, run.ts:261) carries a 64-hex string or a flop_ek_... token as a property name with a harmless value therefore passes writeJsonSafe and lands in responses/<id>.json (mode 0644), and redactFlagged cannot null it either. The README's rule 1 says 'any 64-hex value' and 'flop_ek_... tokens' are rejected before anything touches disk; the gateway (which knows the child token it minted) is exactly the party whose output this scan exists to filter. Impact is limited to gateway-supplied content reaching a world-readable file; the registry check for our own loaded secrets has the same gap.\nFix: in visit(), also run the string checks on each key k (treat it as a string value with field name k), and in redactFlagged delete or rename a flagged key.","line":84,"path":"imd-flop/src/secrets.ts","reproduction":"Ready a seat (init/link/cap set 5). Use a provider whose complete() returns usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2, ['ee'.repeat(32)]: 1 } (or the key 'flop_ek_SYNTHETICNOTAKEY0123456789'). Run: imd-flop run --seat 9001 --job k1 --prompt 'Say hi' --max-tokens 8 --provider mock --json.\nExpected: the response file does not contain the 64-hex / flop_ek_ string (withheld or redacted). Actual at 716819c: exit 0, `withheld` undefined, and readFileSync(response_file).includes('ee'.repeat(32)) === true (same for the flop_ek_ key). Probe: section C of the vitest file in the first finding's proof.","severity":"low","snippet":"      for (const [k, x] of Object.entries(v as Record<string, unknown>)) visit(x, path ? `${path}.${k}` : k, [...keys, k], k);","title":"Secret scan never looks at object keys, so a key-shaped JSON key from the gateway is written to the response file unredacted"},{"citation":"resolved","description":"The cap check lives entirely inside lockedCheck, which runs only after provider.estimate() returned. Whether today's holder spend already equals the daily cap, or a ledger cap_charge is unusable, is known before any estimate (spend-guard fixed the same ordering as IMD audit 355a3703 I11, commit 5c5b881; imd-flop did not). Consequences: a run that will be refused anyway sends a prompt to the gateway's /models/estimate (a free call, but the prompt leaves the machine), and when that estimate fails the user sees exit 7 ESTIMATE_FAILED and a refused_local row with reason 'estimate failed', not the cap refusal. The per-call check genuinely needs the quote and is correctly inside the lock; only the 'daily cap reached'/'unusable ledger charge' refusals are locally decidable. Not a spend risk.\nFix: before the estimate, compute spentBefore under the lock (or unlocked, as an early exit only) and refuse with REFUSED_CAP when left <= 0 or the ledger charge is unusable; keep the authoritative check inside the lock.","line":196,"path":"imd-flop/src/run.ts","reproduction":"Ready a seat with cap set 0.186 and complete one mock run (padded quote 0.186, so the day is full). Second run with a provider whose estimate() throws 'estimate HTTP 503': imd-flop run --seat 9001 --job d2 --prompt 'Say hi' --max-tokens 8 --provider mock --json.\nExpected: exit 3 'daily cap reached' without calling estimate. Actual at 716819c: estimate() is called once, exit 7 'refused locally, nothing sent: estimate failed (estimate HTTP 503)'. Probe: section D of the vitest file in the first finding's proof.","severity":"info","snippet":"    q = padQuote(await provider.estimate({ model, prompt: estPrompt, max_tokens: o.maxTokens }));","title":"Daily cap already exhausted: the (network) estimate is still requested first, and an estimate failure then exits 7 instead of the locally decidable 3"},{"citation":"resolved","description":"The `quote` command prints `est`, the parsed body of POST /models/estimate, as-is. Every other place gateway JSON reaches stdout (run --json receipt, receipt, status) goes through writeJsonSafe/redactFlagged first so a bearer-, flop_ek_- or key-shaped string from the gateway is withheld (TS-L8, stdout-withhold tests). `quote` has no such step, so any extra field in the estimate response is echoed. No local secret is loaded on this path (no gateway key is read), so the exposure is gateway-supplied content only, in a command the README lists as 'live, free, opt-in'. Consistency gap rather than a leak of local material.\nFix: print only the fields padQuote consumes (model, estimated_input_tokens, estimated_output_tokens, estimated_cost_flop), or pass est through redactFlagged before print.","line":294,"path":"imd-flop/src/cli.ts","reproduction":"Supply a fetch whose /models/estimate response is {\"estimated_cost_flop\":0.066,\"estimated_input_tokens\":3,\"estimated_output_tokens\":8,\"debug\":\"Bearer SYNTHETICNOTAKEY0123456789\"} and run: imd-flop quote --prompt hi --provider flop.\nExpected: the Bearer-shaped string is withheld from stdout. Actual at 716819c: exit 0 and stdout contains 'Bearer SYNTHETICNOTAKEY0123456789'. Probe: section F of the vitest file in the first finding's proof.","severity":"info","snippet":"        print({ provider: pname, estimate: est, padded: padQuote(est), note: \"padded = raw + 20 tokens for the Qwen chat template\" });","title":"quote --provider flop prints the gateway's estimate JSON to stdout verbatim, without the secret scan applied to every other gateway-derived output"},{"citation":"resolved","description":"takeLock creates ledger.jsonl.lock with openSync(path, 'wx') (ledger.ts:122) and only then writes the owner record {pid, host, token} (ledger.ts:123). A SIGKILL, OOM kill, power loss or a thrown error between those two calls (or a write that is lost before fsync) leaves an existing lock file with no readable owner. readOwner then returns null, provablyDead(null) is false by design ('unreadable or malformed: never provably stale', ledger.ts:92), so reclaimIfDead never fires no matter how old the file is, and every subsequent withLedgerLock waits 5 s and throws LedgerLockError. Every `run` on that home exits 10 'refused locally, nothing sent' forever, `receipt --refresh` silently reports 'receipt and ledger disagree', and nothing in the CLI tells the user that the lock file is empty or how to recover. The same permanent state arises when the recorded host no longer equals hostname() (hostname change, home directory moved to another machine). Fail-closed, so no spend is at risk; the cost is an unrecoverable-by-CLI outage of the paid path.\nFix: make the owner record and the lock file one atomic step: write the JSON to a private temp file in the same directory, then linkSync(temp, lockPath) (fails with EEXIST exactly like 'wx') and unlink the temp; alternatively treat a 0-byte lock older than the 30 s threshold as reclaimable, and print the lock path with the exit-10 message so a human can inspect it.","line":123,"path":"imd-flop/src/ledger.ts","reproduction":"1. Ready a seat: init --seat 9001; link --token 9001 --agent 7; cap set 5.\n2. Create the crash artefact: : > $IMD_FLOP_HOME/ledger.jsonl.lock; touch -d '10 minutes ago' $IMD_FLOP_HOME/ledger.jsonl.lock\n3. imd-flop run --seat 9001 --job g1 --prompt hi --max-tokens 8 --provider mock; repeat with --job g2.\nExpected: a lock older than 30 s with no live owner is reclaimed and the runs exit 0. Actual at 716819c: both runs block for 5 s each and exit 10 ('ledger lock timeout'), the empty lock file is still there afterwards, and every further run behaves the same (observed: codes [10, 10], 10063 ms, lock still present). Probe: vitest test G (writeFileSync(lock, '') + utimesSync 600 s in the past, then two cliP runs).","severity":"low","snippet":"      writeSync(fd, JSON.stringify(owner));","title":"Lock owner is written after the lock file is created, so a crash in between leaves a 0-byte lock that is never reclaimed: every later run exits 10 until it is deleted by hand"}],"hash":"cc26dedbae73e01eb1e408e4287744cac1415e7f95747016de20ddde689c8838","nodeId":"e23705f8-da8a-4118-937c-202f6aca709c","outcome":"completed","summary":"The re-audit of `imd-flop` at 716819c is complete. The findings are in `.imd-findings.json` at the repository root (6 entries). No repository file was changed; all checks ran on a copy under the temp directory.\n\n**Prior audit e99a2cf1: every finding is fixed.** The typecheck and the full suite (147 tests) pass at this commit, including every regression test the fixes added, and I confirmed each by reading the code paths:\n\n- Ledger lock: exit 10 before the paid call (TS-I14, `REFUSED_LOCK` is unique) and exit 1 after it. I reproduced the post-call case too: a lock appearing during the paid call exits 1 with the `sent` row kept.\n- Spend cap exit 3: per-call, daily, job budget, and unusable `cap_charge` all refuse with nothing sent.\n- Async lock bodies: a promise-returning body, outer or re-entrant, throws and leaves no lock (TS-I12).\n- `request_id` attempts: receipt identity is decided on the raw gateway `request_id` in both `run` and `receipt --refresh`; mismatches never settle.\n- Stdout withholding: withheld text, redacted receipt fields, and the fallback record all hold.\n\n**New findings, each reproduced with a test against this commit:**\n\n- **Medium, `run.ts:219`.** `config.payer` is never validated. Any value other than holder or requester takes the requester branch with no budget, so `left` is NaN and the comparison passes. Those rows count toward neither cap. Three runs at a 0.2 cap all went out and `status` showed 0 spent.\n- **Medium, `run.ts:250`.** On a pre-send refusal, the ledger reason embeds the RPC's error text. If that text carries a bare 64-hex run, a Bearer or `flop_ek_` shape, or a 78-digit chain id, the secret scan blocks the `refused_local` append. The `sent` reservation stays live, the CLI exits 5 instead of 1, and the next run is refused as \"daily cap reached\" with nothing ever sent. Refresh cannot clear it.\n- **Low, `secrets.ts:84`.** The scan checks values only. A 64-hex or `flop_ek_` string used as a JSON key in the gateway's usage object is written to the 0644 response file.\n- **Low, `ledger.ts:123`.** The owner record is written after the lock file is created. A zero-byte lock from a crash in that window is never reclaimed, so every run exits 10 until it is deleted by hand.\n- **Info, `run.ts:196`.** With the daily cap already full, the estimate is still sent, and an estimate failure then exits 7 rather than 3.\n- **Info, `cli.ts:294`.** `quote --provider flop` prints the gateway's estimate JSON unscanned.\n\nEach entry carries the exact source line, the failing input, expected versus actual, and a suggested fix. The two medium entries also carry the vitest probe file that fails on this code.\n\nNot covered in depth: `link.ts` and `keys.ts` were read for error-string trust gaps only, since the assignment centred on the lock, cap, async-body, request_id and stdout paths.","treeHash":null,"usage":{"cachedInputTokens":1135233,"cost":{"source":"runtime","usdMicros":6015656},"inputTokens":322,"model":"claude-fable-5-1","outputTokens":48967,"runtime":"claude","turns":37,"wallClockMs":716748}}],"verification":[]}