{"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":"b4e8e103-53a5-4c65-a67f-f10a5ea50542","kind":"audit","nodes":[{"acceptedSubmissionHash":"325cfe647afaa67bcfd17bd08b6f9dfdd34ba0e0b371c243f3097718a3db8d37","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":"fe149ef1b8f16ac76fedd09598191e1c9231af023c360e5f7ed81a1f6678a182","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":"78ea3bd692b8a0d9e14e3fb66797c0706ebe583f7f9a75697620ce312d387f82","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":"a76d07cf875e5da535b283298616dd0ec85628b1efb48916efb92dcaedef9c3a","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":"2e14754fe7fdc6a4a34cddafdbca5ff586d031f760a2bab2e8d67c8a360c9325","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":"Audit the FLOP spend path of uxp-labs/flop-devnet-lab at this commit. It is the follow up to the audit of 8ddc5d2 (report: https://api.imd.fun/jobs/769d5aeb-83f2-4d1b-b7d1-2549abca21c1/report.md). Since then the booking of receipts moved into one step under the ledger lock, the daily cap was made to cover every payer, an exact amount is recorded beside the float one, and the cap charge got a floor: from the moment a call is sent it is never below the padded quote the call was admitted with.\nScope: imd-flop/ (TypeScript CLI that lets an IMD worker pay FLOP for inference), viewer/ (Python), spend-guard/ (Python) and contract/ (shared rules, test vectors and raw byte fixtures). Leave observatory/, imd-hire/ and tclk-rehearsal/ out.\nWork offline and read only: no network, no faucet, no keys, no paid calls. Do not run as root: some imd-flop tests need a read only file.\nRun first: in imd-flop, npm ci then npm test (742 tests). In spend-guard and in viewer, python3 -m unittest discover -s . -p 'test_*.py' (66 and 135 tests).\nThe promise under audit is stated once, in imd-flop/README.md and contract/rules.json CAP_PROMISE: for a ledger only this tool writes and a gateway that enforces the single use child key's cap, the day's cap and a job budget are not passed without a row that carries PAST_ADMITTED. A ledger that was edited, moved or damaged is outside the promise: there the tool must fail closed where it can see the damage and name the line. Crashes of the tool itself (a torn line, a dead lock holder) are inside the promise.\nLook for:\n1. The hold floor and PAST_ADMITTED (imd-flop/src/settlement.ts decideBooking, holdFloor, pastAdmitted, carryPastAdmitted; booking.ts). Any sequence of calls, reads and reports, in any order and from any number of processes, after which the day's total or a job's total is above its limit and no row of the day carries PAST_ADMITTED, or a cap charge went down other than the one release from a row still in state sent when nothing was sent. Include a requester job budget beside the daily cap, a UTC day change between admission and booking, and a seed row written again.\n2. The ledger lock and readers (ledger.ts takeLock, reclaimIfDead, createLock, ledgerLines, withLedgerLock). Any interleaving or crash that loses a booking, lets two holders in at once, reclaims a live holder's lock, names a line as damaged while another process is still writing it, or locks a seat out with no way back that the message explains. A holder that dies inside the lock blocks other commands until the lock is 30 s old: say whether that window can be made shorter safely.\n3. Rows that cannot be placed and damaged lines (ledger.ts placed, UnusableLedgerRow, ledger-text.ts). Any row whose charge should count toward a total and is dropped silently, and any refusal that names no line or the wrong one.\n4. The ledger text contract (contract/README.md, rules.json LEDGER_TEXT, contract/fixtures/ledger-text/, imd-flop/src/ledger-text.ts, viewer/build-lane.py read_ledger_text). Any byte sequence the two readers cut into different lines, read as different values or group differently. Any verdict on which imd-flop and the viewer differ for the same ledger and receipts, including on rows an older version wrote.\n5. Amounts (amount.ts knownAmount, exactAmount, AMOUNT_MAX; rounding.ts; policy.ts usableLimit, parseAmount). Any input read as a known amount that is not one, any unknown amount counted as 0, any way rounding admits a call beyond the stated residual R-g, and any path where spend_raw describes another settlement than the float spend beside it.\n6. Secrets and redaction (secrets.ts, store.ts, receipt-store.ts storeRead). Any way a key shaped value, a gateway bearer or the Ethereum RPC URL reaches the ledger, a receipt, a response file, stdout or stderr, and any way a receipt that had to be redacted proves a settlement or lowers a charge.\n7. Words. Any sentence in imd-flop/README.md, contract/rules.json or contract/README.md that the code does not keep, and any exit code that differs from the table.\n8. The tests. Try to keep a wrong rule alive against imd-flop/test/decision-properties.test.ts and the stress blocks in test/review-fixes.test.ts, review-hold.test.ts and review-hold-2.test.ts (STRESS_PROCS, STRESS_OPS, STRESS_ROUNDS set their size).\nFor every finding give file and line, severity, impact and a reproduction that runs offline, and say whether it is inside or outside the stated promise. Say which checks you ran and which you only read.","parentJobId":null,"planHash":"b60b99233dbb366f0d339d0e42bbf322d3f9f89acbd098e18c0075a77e335e6b","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"b4e8e103-53a5-4c65-a67f-f10a5ea50542","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":"51452","feedbackHash":"36c872ed077296ec23417150c719f39166b7905db1c469c8b257dff9995f6218","nodeKey":"audit_economics","submissionHash":"325cfe647afaa67bcfd17bd08b6f9dfdd34ba0e0b371c243f3097718a3db8d37","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51742","feedbackHash":"cfde1bc8b38c07277dee5496f793cc8f7589d1cd46e783e0b92553b2be548625","nodeKey":"audit_flow","submissionHash":"fe149ef1b8f16ac76fedd09598191e1c9231af023c360e5f7ed81a1f6678a182","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51447","feedbackHash":"a8bf9f0c003db385111576aa807b3e0766eadd13032c2098efbd001e076ae5c5","nodeKey":"audit_judge","submissionHash":"78ea3bd692b8a0d9e14e3fb66797c0706ebe583f7f9a75697620ce312d387f82","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51298","feedbackHash":"52c0f58c19efdc6f31fb1b9de833981f59875779055b21480e2b28671887bc90","nodeKey":"audit_math","submissionHash":"a76d07cf875e5da535b283298616dd0ec85628b1efb48916efb92dcaedef9c3a","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"50953","feedbackHash":"79e101253a0304a313f56d26786dd3f9cad9c4177b6f6001402c31dc8a2909a9","nodeKey":"audit_permissions","submissionHash":"2e14754fe7fdc6a4a34cddafdbca5ff586d031f760a2bab2e8d67c8a360c9325","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"83300affa9787af8eada478e2f66447dbe2e0405651d44a6d9b01dce5300dcc8","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"aad1250d716d3f82","findings":[{"citation":"resolved","description":"Inside the stated promise (a dead lock holder is named as inside it). Look-for item 2: two holders at once, a live holder's lock reclaimed, a lost booking.\n\nreclaimIfDead reads the owner at the lock path (ledger.ts:291) and then renames whatever is at that path (ledger.ts:293). The two steps are not one step. When a holder died inside the lock and two or more waiters meet the old lock, waiter A can finish the whole reclaim (rename, unlink) and create its own lock between waiter B's read of the dead owner and B's rename. B then moves A's live lock aside. B sees at ledger.ts:294 that the owner is alive and links it back at ledger.ts:295, but while the lock is aside the lock path is empty:\n\n(1) Any third process creates the lock (createLock, ledger.ts:331) and is inside together with A. B's link back then fails with EEXIST, B throws \"ledger lock contested\", and ledger.jsonl.lock.stale.<pid>.<hex> stays behind for good (nothing removes it). With two holders, two `run` admissions (run.ts:207-216) read the same day total and both append their sent row: the day's total, or a job's, is above its limit and no row carries PAST_ADMITTED. Two booking steps (booking.ts:68) can likewise both read the same latest row of a request.\n\n(2) With only A and B: if A leaves its critical section while its lock is aside, releaseLock (ledger.ts:359) finds no owner at the path and returns without removing anything. B links A's lock back. The lock is now held by nobody but names a live pid, so it is never reclaimable while that process lives: every command times out with \"ledger lock timeout ... is held\", and A's own next booking step does too. A `run` whose paid call is in flight then exits 1 and its receipt read is not booked (the row stays sent). The message tells the holder nothing about the cause or the way back (wait for the named process to end and 30 s more, or remove the lock file).\n\nMeasured, nothing mocked: one old lock of a dead pid and 16 processes calling withLedgerLock at the same moment gave two holders inside at once in 4 of 40 rounds on a 6 core machine, each with one \"ledger lock contested\" error and a leftover .stale file. With the schedule forced (waiter B paused once between ledger.ts:291 and 293, and once before 295; the holders are real processes running dist/ledger.js spentToday + appendLedger under withLedgerLock as run.ts lockedCheck does, daily cap 1, padded quote 0.6): both rows record spent_before=0, the day's total is 1.2 against the cap of 1, and 0 rows carry PAST_ADMITTED. Case (2) forced the same way: B exits \"ledger lock timeout: <home>/ledger.jsonl.lock (4 s old) is held\", the lock file names A's live pid after A released, and A's next withLedgerLock fails with the same timeout.\n\nNo test reaches this: with the re-check at ledger.ts:294 removed, so that a lock moved aside is always deleted whoever owns it, all 742 imd-flop tests still pass (see the finding on the tests).\n\nOn the 30 s window (asked in item 2): for a lock that carries an owner on this host whose pid gives ESRCH, the age adds no proof, since createLock writes the owner with the lock, so that window could be cut below the 5 s waiter timeout and waiters would ride through a crash instead of exiting 10 or 1. It is not safe to do so before this race is fixed, and two things must stay: the 30 s for a lock with no readable owner, and readOwner must tell ENOENT from \"no owner\" (today a lock released between statSync at ledger.ts:349 and readOwner at ledger.ts:291 reads as ownerless and reclaimable, and the rename can then take a fresh live lock; only the age gate keeps that path out of reach, because a live holder never holds for 30 s).\n\nFix direction: never rename a path whose owner was read earlier. Make \"read the owner, find it dead, remove the lock\" one exclusive step, for example under a second lock file created by link as createLock does, and re-read the owner inside it.","line":293,"path":"imd-flop/src/ledger.ts","reproduction":"Offline, no keys. In imd-flop: npm ci && npm run build, save the script below as repro-lock.mjs there, then: node repro-lock.mjs\n\nState: ledger.jsonl.lock holds {\"pid\":<pid of a process that has exited>,\"host\":<this host>,\"token\":\"dead\"} with mtime 60 s ago, and 16 processes call withLedgerLock(home, body) at the same moment.\nExpected: in every round the bodies run one at a time (E X E X ...), no error, no lock file left.\nActual (6 cores, Node 24): \"rounds with two holders inside the lock at once: 4 of 40; \"ledger lock contested\" errors: 4; rounds that left a lock file behind: 4\". The count varies per run and machine; any round above 0 is the defect.\n\n// From imd-flop, after `npm ci && npm run build`:  node repro-lock.mjs\n// Each round: one old lock whose owner pid is dead (a holder that died inside the lock), then 16 processes call\n// withLedgerLock at the same moment. Nothing is mocked. Each holder logs E on entry and X on exit.\nimport fs from \"node:fs\"; import os from \"node:os\"; import path from \"node:path\";\nimport { spawn, spawnSync } from \"node:child_process\"; import { fileURLToPath, pathToFileURL } from \"node:url\";\nconst DIST = pathToFileURL(path.resolve(\"dist\")).href;\nconst { withLedgerLock } = await import(`${DIST}/ledger.js`); const { ledgerFile } = await import(`${DIST}/paths.js`);\nif (process.argv[2] === \"racer\") {\n  const [h, deadline] = process.argv.slice(3); const log = `${h}/race.log`;\n  while (Date.now() < Number(deadline)) { /* common start */ }\n  try { withLedgerLock(h, () => { fs.appendFileSync(log, \"E\\n\"); Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 3); fs.appendFileSync(log, \"X\\n\"); }); }\n  catch (e) { fs.appendFileSync(log, `ERR ${e.message.split(\":\")[0]}\\n`); }\n} else {\n  let two = 0, contested = 0, left = 0;\n  for (let r = 0; r < 40; r++) {\n    const h = fs.mkdtempSync(path.join(os.tmpdir(), \"lock-\")); const lock = `${ledgerFile(h)}.lock`;\n    fs.writeFileSync(lock, JSON.stringify({ pid: spawnSync(process.execPath, [\"-e\", \"\"]).pid, host: os.hostname(), token: \"dead\" }));\n    fs.utimesSync(lock, Date.now() / 1000 - 60, Date.now() / 1000 - 60);\n    const deadline = Date.now() + 900;\n    await Promise.all(Array.from({ length: 16 }, () => new Promise((res) => spawn(process.execPath, [fileURLToPath(import.meta.url), \"racer\", h, String(deadline)], { stdio: \"ignore\" }).on(\"exit\", res))));\n    let inside = 0, ov = false;\n    for (const l of fs.readFileSync(`${h}/race.log`, \"utf8\").split(\"\\n\")) { if (l === \"E\" && ++inside > 1) ov = true; if (l === \"X\") inside--; if (l.includes(\"contested\")) contested++; }\n    if (ov) two++;\n    if (fs.readdirSync(h).some((f) => f.includes(\".lock\"))) left++;\n    fs.rmSync(h, { recursive: true, force: true });\n  }\n  console.log(`rounds with two holders inside the lock at once: ${two} of 40; \"ledger lock contested\" errors: ${contested}; rounds that left a lock file behind: ${left}`);\n}","severity":"high","snippet":"  try { renameSync(path, aside); } catch { return false; } // someone else moved or released it","title":"Lock reclaim moves a live holder's lock aside: two holders at once after a holder dies, cap passed with no PAST_ADMITTED"},{"citation":"resolved","description":"Look-for item 6 (an Ethereum RPC URL reaching the ledger or stderr) and item 7 (a sentence the code does not keep). Not part of the cap promise: it is rule 1, keys stay local, and the comment above postEthRpc (gateway.ts:216-219): \"The URL often carries a key (userinfo or a path token), so no message from here quotes it.\"\n\npostEthRpc replaces the URL only in a transport error (gateway.ts:238). The JSON-RPC error text the server itself sends is quoted as it comes (gateway.ts:261), and nothing registers the RPC URL as a secret (registerSecret is called only for the seat seed, the gateway bearer and the child token), so the secret scan cannot see it either: a path token is not 64 hex, not \"Bearer ...\" and not flop_... shaped.\n\nWhere the text goes:\n- run.ts:128, the first paid-network check: stderr, \"refused locally, nothing sent: eth_chainId error: ...\", exit 5.\n- run.ts:71 and 134, the owner check: stderr, \"link does not verify, refusing: owner check failed: eth_chainId error: ...\".\n- providers.ts:145, the recheck before the mint: PaidGateError, then run.ts:247 builds the ledger reason \"nothing sent: <message>\", the scan at run.ts:250 finds nothing, and bookFailure appends that reason to ledger.jsonl. It is also printed at run.ts:267.\n\nImpact: with an RPC endpoint or proxy that repeats the request URL in its error (an expired or rate limited key is the usual moment), the holder's RPC key lands in ledger.jsonl, a file the viewer reads, and on stderr. Low: it needs a server that echoes the URL.\n\nWhat I ran: the three functions against a stub fetch (below). What I only read: the path from PaidGateError through run.ts:247-256 into the ledger row.\n\nFix direction: pass every message that leaves postEthRpc's callers through the same replacement (rpcUrl, href, and the URL's userinfo and path), or do not quote server text at all.","line":261,"path":"imd-flop/src/gateway.ts","reproduction":"Offline, no keys. In imd-flop after npm ci && npm run build, save as repro-rpc.mjs and run: node repro-rpc.mjs\n\nconst g = await import(\"./dist/gateway.js\"); const n = await import(\"./dist/networks.js\"); const s = await import(\"./dist/secrets.js\");\nconst url = \"https://eth.example.invalid/v2/k3y-AbCdEf0123456789-path-token\";\n// A server that answers eth_chainId with a JSON-RPC error that repeats the request URL.\nconst f = async (input) => new Response(JSON.stringify({ jsonrpc: \"2.0\", id: 1, error: { code: -32000, message: `unauthorized for ${String(input)}` } }), { status: 200 });\ntry { await g.assertPaidNetwork(n.network(\"devnet\"), url, f); } catch (e) {\n  const reason = `nothing sent: ${e.message.replace(/0x[0-9a-fA-F]{64}/g, \"[hash]\")}`; // run.ts:247\n  console.log(e.constructor.name, e.message, \"| secret scan of the ledger reason:\", JSON.stringify(s.scanForSecrets({ reason })));\n}\n\nExpected: no message carries the URL (it is replaced by [ETH_RPC_URL], as for a transport error), or the scan refuses the reason.\nActual: \"PaidGateError eth_chainId error: unauthorized for https://eth.example.invalid/v2/k3y-AbCdEf0123456789-path-token | secret scan of the ledger reason: []\". readEthereumChainId and readVerifiedSeatOwner give the same text.","severity":"low","snippet":"  if (j.error) throw new Error(`eth_chainId error: ${j.error.message ?? \"rpc error\"}`);","title":"An Ethereum RPC error message is quoted unscrubbed: a server that repeats the request URL puts ETH_RPC_URL and its key on stderr and in the ledger reason"},{"citation":"resolved","description":"Look-for item 4. Outside the cap promise: the tool never writes nested values, so only an edited ledger holds such a line. It is inside the ledger text contract, which says a line that is not blank is one JSON value in JSON.parse's grammar (contract/README.md:87), for imd-flop and the viewer alike.\n\nimd-flop reads the line with JSON.parse (ledger-text.ts), which has no depth limit that matters: a line nested 2,000,000 deep is a row. The viewer's loads() turns Python's RecursionError into \"not valid JSON\" (build-lane.py:104-105). On Python 3.12.3 that starts at 9997 levels, and the number belongs to the Python build, not to the contract, so two viewers can differ too.\n\nVerdicts that differ for the same bytes: imd-flop counts the row (its cap_charge is in the day's total and `status` lists the request), while the viewer shows \"ledger line 1: not valid JSON\" as an unreadable row and the request is not in the lane. Neither side fails closed in the same way, and contract/fixtures/ledger-text has no fixture for nesting, so nothing pins either reading.\n\nApart from this one, I found no byte sequence the two readers read differently: 25,000 generated files (odd numbers, escapes and surrogates, invalid UTF-8, every blank character and its near misses, BOM, CR and CRLF, duplicate keys, request ids at the edges) gave the same line numbers, kinds, reasons, parsed values (compared as double bits and UTF-16 units) and group keys from dist/ledger-text.js and build-lane.py.\n\nFix direction: give the contract a nesting limit both readers enforce (a small one: the tool writes none), with a fixture on each side of it.","line":104,"path":"viewer/build-lane.py","reproduction":"Offline. Input: one ledger line, {\"request_id\":\"r1\",\"cap_charge\":1,\"x\":[[[ ... ]]]} with 9997 opening and 9997 closing brackets, then a newline.\n\nFrom the repository root (Python 3.12):\npython3 -B - <<'PY'\nimport importlib.util, sys\nsys.dont_write_bytecode = True\ns = importlib.util.spec_from_file_location(\"bl\", \"viewer/build-lane.py\"); bl = importlib.util.module_from_spec(s); s.loader.exec_module(bl)\nline = b'{\"request_id\":\"r1\",\"cap_charge\":1,\"x\":' + b\"[\" * 9997 + b\"]\" * 9997 + b\"}\\n\"\nprint(\"viewer:\", bl.read_ledger_text(line)[0])\nPY\nActual: viewer: (1, 'unreadable', 'not valid JSON')   (9996 levels still read as a row)\n\nFrom imd-flop after npm ci && npm run build (Node 24):\nnode -e 'import(\"./dist/ledger-text.js\").then((m) => { const l = \"{\\\"request_id\\\":\\\"r1\\\",\\\"cap_charge\\\":1,\\\"x\\\":\" + \"[\".repeat(9997) + \"]\".repeat(9997) + \"}\\n\"; const r = m.readLedgerText(l)[0]; console.log(\"imd-flop:\", r.n, r.kind, \"cap_charge\", r.row.cap_charge); })'\nActual: imd-flop: 1 row cap_charge 1   (the same at 2,000,000 levels)\n\nExpected: the same verdict from both readers.","severity":"low","snippet":"    except RecursionError as ex:\n        raise ValueError(\"nested too deep\") from ex","title":"Ledger text contract: a deeply nested line is a row to imd-flop and \"not valid JSON\" to the viewer"},{"citation":"resolved","description":"Look-for item 8. A gap in the tests, not a defect in the shipped rule, except that the first one is the guard the lock race in the other ledger.ts finding runs through.\n\nI changed one rule at a time in a copy, rebuilt, and ran the tests. 21 changes were tried. 15 were caught by test/decision-properties.test.ts and the stress blocks (every change to decideBooking, holdFloor, exactAfter, stateAfter, decideFailure, the carry of PAST_ADMITTED, the seed row's mark, the re-read under the lock, and reclaiming an ownerless lock). These six pass the four named files and the whole suite (742 passed, default STRESS sizes):\n\n1. ledger.ts:294, `if (reclaimable(readOwner(aside))) {` becomes `if (reclaimable(readOwner(aside)) || Date.now() > 0) {`. Wrong rule: a waiter that moved a lock aside deletes it without looking whose it is, so a live holder's lock is reclaimed. No test has a second waiter arrive at a dead holder's old lock.\n2. ledger.ts:359, the line `if (readOwner(path)?.token !== token) return;` removed. Wrong rule: a holder leaving removes whatever lock is at the path, another holder's included.\n3. ledger.ts:280, `if (!o || o.host !== hostname()) return false;` becomes `if (!o) return false;`. Wrong rule: a lock taken on another host (a shared home) is reclaimed when no process has that pid here.\n4. ledger.ts:142, `!(row.state === \"refused_local\" && row.cap_charge === 0)` becomes `!(row.state === \"refused_local\")`. Wrong rule: a refused_local row with no request id and a charge above 0 is left out of every total with no refusal (item 3: a charge dropped silently).\n5. run.ts:209, `const day = utcDay();` becomes `const day = base.day;`. Wrong rule: the day of the cap check and of the sent row is the one read before the estimate, outside the lock, so a call admitted just after midnight UTC is checked against the day before and booked on it (item 1: a UTC day change). The comment above the line states the rule; nothing tests it.\n6. settlement.ts:251, `charge > admitted` becomes `charge > admitted + 1e-6`. Wrong rule: a booking that raises a charge by up to 0.000001 FLOP says nothing, and each one can add 0.000001 to the day's total with no PAST_ADMITTED. The property \"a booking says it passed what the cap admitted exactly when it makes the day total grow\" only draws amounts further apart than that.\n\nImpact: any of these can come back in a later change and the 742 tests stay green. 1 to 3 are the lock's safety rules, and the stress blocks, which are the only tests with real contention, never exercise them.","line":294,"path":"imd-flop/src/ledger.ts","reproduction":"Offline. In a copy of the repository, in imd-flop after npm ci: make one of the six replacements given in the description (each pattern occurs once in its file), then run npm test.\nExpected: at least one failing test.\nActual, for each of the six: \"Tests  742 passed (742)\". With only the named files (npx vitest run test/decision-properties.test.ts test/review-fixes.test.ts test/review-hold.test.ts test/review-hold-2.test.ts after npx tsc -p tsconfig.json): \"Tests  104 passed (104)\".\nExample for the first: in src/ledger.ts replace `if (reclaimable(readOwner(aside))) {` with `if (reclaimable(readOwner(aside)) || Date.now() > 0) {`, then npm test.\nNot run: the stress blocks at the review's size (STRESS_PROCS=6 STRESS_OPS=60 STRESS_ROUNDS=20); the defaults were used.","severity":"low","snippet":"  if (reclaimable(readOwner(aside))) { try { unlinkSync(aside); } catch { /* gone */ } return true; }","title":"Six wrong rules pass all 742 imd-flop tests: three in the lock, one in the totals, the UTC day of admission, and the PAST_ADMITTED threshold"},{"citation":"resolved","description":"Look-for items 2 and 3. Inside the promise when the state comes from the tool's own crash (a torn write that lost only the final newline byte); outside it when the newline was lost by an edit. Either way the tool fails closed afterwards and names the line, so the cap is not passed: the defect is that the tool's own write turns a ledger it has just read as good into a damaged one, at the worst moment.\n\nThe ledger text contract reads a last line with no final newline as a line (contract/fixtures/ledger-text has that fixture), so every reader accepts such a ledger and the cap check passes. appendJsonlSafe (store.ts:21) then appends JSON + \"\\n\" without looking at how the file ends. The new row lands on the same line: `{row a}{row b}\\n`, which is not valid JSON.\n\nIn `run` this happens at run.ts:214, after the cap check and before the paid call: the sent row is written glued, the call goes out, and the booking step that follows cannot read the ledger (UnreadableLedgerLine, exit 1). From then on every command refuses with \"ledger line N cannot be read: not valid JSON; next call refused. Repair or remove that line.\" Line N now holds two rows that both carry a charge, one of them for a call that was really sent, and the message offers \"remove\" as a way back: a holder who takes it drops both charges from the totals.\n\nThe same missing check is why a torn line grows: run.ts:184 answers an unreadable last line by calling refuse(), which appends a refused_local row onto the torn fragment at every refused run, so the line the message names holds whole later rows as well.\n\nWhat I ran: the library calls below (dist/ledger.js appendLedger and spentToday). What I only read: the path through run.ts.\n\nFix direction: under the lock, before an append, check that the file is empty or ends with \"\\n\"; when it does not, write \"\\n\" first (the fragment then stays its own line and is named alone) or refuse.","line":21,"path":"imd-flop/src/store.ts","reproduction":"Offline, no keys. In imd-flop after npm ci && npm run build, save the script below as repro-glue.mjs there and run: node repro-glue.mjs\n\nState: ledger.jsonl holds one whole row (request mock-7-a, state sent, cap_charge 0.4) and no final newline.\nExpected: after the next append the ledger has two readable lines and the day's total is 0.8, or the append is refused.\nActual:\nbefore: 0.4\nafter: UnreadableLedgerLine ledger line 1 cannot be read: not valid JSON; next call refused. Repair or remove that line.\nline 1 holds 2 rows\n\nimport fs from \"node:fs\"; import os from \"node:os\"; import path from \"node:path\";\nconst { appendLedger, spentToday, utcDay } = await import(\"./dist/ledger.js\"); const { ledgerFile } = await import(\"./dist/paths.js\");\nconst h = fs.mkdtempSync(path.join(os.tmpdir(), \"glue-\")); const day = utcDay();\nconst row = (id) => ({ ts: new Date().toISOString(), day, seat: \"7\", did: \"did:key:zTest\", job: \"j1\", provider: \"mock\", network: \"devnet\", payer: \"holder\", model: \"m\", max_tokens: 1, prompt_sha256: \"00\", per_call_cap: 1, daily_cap: 1, job_budget: null, mock: true, request_id: id, quote_raw: 0.3, quote_padded: 0.4, spent_before: 0, job_spent_before: null, state: \"sent\", reason: null, spend: null, spend_raw: null, cost_reported: null, cap_charge: 0.4, http: null });\nappendLedger(h, row(\"mock-7-a\"));\nconst f = ledgerFile(h); fs.writeFileSync(f, fs.readFileSync(f, \"utf8\").replace(/\\n$/, \"\")); // the write of the row lost its last byte\nconsole.log(\"before:\", spentToday(h, \"7\", \"mock\", day)); // the row is read: a last line with no final newline is a line\nappendLedger(h, row(\"mock-7-b\")); // what run.ts:214 does after the cap check passed\ntry { console.log(\"after:\", spentToday(h, \"7\", \"mock\", day)); } catch (e) { console.log(\"after:\", e.constructor.name, e.message); }\nconsole.log(\"line 1 holds\", fs.readFileSync(f, \"utf8\").split(\"\\n\")[0].match(/\"request_id\"/g).length, \"rows\");\nfs.rmSync(h, { recursive: true, force: true });","severity":"low","snippet":"  appendFileSync(path, JSON.stringify(obj) + \"\\n\", { mode: 0o644 });","title":"An append never looks at the file's end: a last line with no final newline, which the contract reads as a row, gets the next row glued onto it and the ledger turns unreadable after the call was admitt"}],"hash":"2e14754fe7fdc6a4a34cddafdbca5ff586d031f760a2bab2e8d67c8a360c9325","nodeId":"ba389910-53dd-4b41-bddf-ae9bff41e253","outcome":"completed","summary":"The audit is finished: `.imd-findings.json` in the repository root holds five findings (one high, four low), each with its snippet checked against the tree. No other file in the repository was changed.\n\n## Findings\n\n| # | Severity | Location | Defect | Promise |\n|---|---|---|---|---|\n| A | high | `imd-flop/src/ledger.ts:293` | Lock reclaim reads the owner and then renames the lock aside as two separate steps, so it can move a live holder's fresh lock. Two holders run at once, and the cap is passed with no `PAST_ADMITTED` row. | Inside (a dead lock holder is a crash of the tool) |\n| B | low | `imd-flop/src/gateway.ts:261` | A JSON-RPC error message is quoted unscrubbed. If the server repeats the request URL, `ETH_RPC_URL` and its key reach stderr and the ledger reason, and the secret scan does not flag it. | Not a cap matter; breaks the secrets rule (items 6 and 7) |\n| C | low | `viewer/build-lane.py:104` | A deeply nested line is a row to imd-flop and \"not valid JSON\" to the viewer. | Outside the cap promise, inside the ledger text contract |\n| D | low | `imd-flop/src/ledger.ts:294` | Six wrong rules pass all 742 tests. | Test gap (item 8) |\n| E | low | `imd-flop/src/store.ts:21` | An append never checks how the file ends. A last line with no final newline is read as a row, then the next row is glued onto it and the ledger becomes unreadable. | Inside when the newline was lost by a torn write; outside when lost by an edit |\n\n- **A:** With a forced schedule, both rows had `spent_before=0` and the day total was 1.2 against a cap of 1. With nothing mocked, two holders occurred in 4 of 40 and 6 of 40 rounds (16 processes, 6 cores). It also leaves a lock naming a live pid after release, which locks that process out of its own next call, and a leftover `.stale` file.\n- **C:** On Python 3.12.3 the viewer reads up to 9,996 levels as a row and fails from 9,997; Node still reads 2,000,000 levels as a row.\n- **D:** The six surviving changes are at `ledger.ts:294`, `:359`, `:280`, `:142`, `run.ts:209` and `settlement.ts:251`. I tried 21 changes and 15 were caught.\n- **E:** The tool fails closed and names the line, so the cap is not passed. The harm is that the named line holds two charged rows and the message offers \"remove\" as a way back.\n\n## The 30 s window\n\nThe window can be made shorter, but not before finding A is fixed. For a lock whose owner is readable, on this host, and whose pid gives ESRCH, the age adds no proof, so that case could drop below the 5 s waiter timeout. Keep 30 s for locks with no readable owner, and make `readOwner` tell a missing file from \"no owner\".\n\n## Checks run and checks only read\n\nAll runs were offline, not as root, in temporary copies outside the repository.\n\n**Run:**\n- `npm ci` and `npm test` in imd-flop: 742 passed.\n- spend-guard unittest: 66 passed. Viewer unittest: 135 passed.\n- Lock race, both with a forced schedule and as a natural stress run.\n- Differential fuzz of the two ledger readers: 25,000 generated files, 0 differences.\n- Deep-nesting probes of both readers.\n- RPC error probe at function level (`readEthereumChainId`, `readVerifiedSeatOwner`, `assertPaidNetwork`).\n- The 21 mutation trials against the full suite.\n- The append repro for finding E, through the library calls.\n\n**Only read:**\n- `decideBooking` order independence, UTC day handling, and the seed row re-write.\n- The requester job budget path.\n- Amounts (`amount.ts`, `rounding.ts`, `policy.ts`).\n- The receipt redaction path.\n- The `run.ts` path for findings B and E.\n- The exit code table against `errors.ts`: names and numbers 0–11 agree, but I did not exercise each command's exit.\n\n**Not covered in depth:**\n- spend-guard sources beyond running their tests.\n- The contract test vectors and the `rules.json` quirks.\n- The stress blocks at raised sizes; they ran at default sizes only.","treeHash":null,"usage":{"cachedInputTokens":2532409,"cost":{"source":"runtime","usdMicros":5365484},"inputTokens":50,"model":"claude-fable-5-2","outputTokens":106567,"runtime":"claude","turns":49,"wallClockMs":2754041}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"d63ea36a2b809080","findings":[{"citation":"resolved","description":"Area 3 (rows that cannot be placed; refusals that name no line) and area 7 (exit code table). ledger.ts placed() builds its message with JSON.stringify of the unusable value (ledger.ts:222-223). run.ts spentSoFar passes that whole message to refuse(), which appends a refused_local row whose `reason` is the message (run.ts:171). appendJsonlSafe runs assertNoSecrets on the row first. When the unusable value is key-shaped (64 or more hex characters, a `Bearer ...` string, a `flop_ek_...` token, or any field value containing a loaded secret), the scan rejects the row and throws CliError exit 5 (`secret scan blocked writing ledger.jsonl: reason: 64-or-more-hex (key-shaped) value`) before the exit-3 CliError is reached. Actual: run exits 5, prints no line number, writes no refusal row. Expected per README 'Exit codes' row 3 and the spend rules ('refuses the next call: exit 3, nothing sent. The message names the ledger line'): exit 3 naming `ledger line <n>`. The same happens for `estimate failed (<message>)` if a provider error text is key-shaped (exit 5 instead of 7). Nothing is sent and no charge is lost, so the cap promise holds; this is outside the stated promise (an edited or damaged ledger) but breaks the README's 'names the line' claim and the exit code table. Fix: scan/redact the reason before appendLedger in refuse() (as the pre-send path already does with `provider error withheld by the secret scan`), or build the placement message without quoting the raw value (quote its type/length instead) so the line number always reaches the operator.","line":184,"path":"imd-flop/src/run.ts","reproduction":"Offline, mock provider, non-root. In imd-flop after `npm run build`:\n  export IMD_FLOP_HOME=$(mktemp -d); node dist/bin.js init --seat 7; node dist/bin.js cap set 1 --per-call 0.5\n  node dist/bin.js run --job j1 --prompt hi --no-link   # exit 0\n  HEX=$(printf 'a%.0s' $(seq 1 64))\n  printf '{\"request_id\":\"mock-7-00112233aabbccdd\",\"seat\":\"7\",\"provider\":\"mock\",\"day\":\"%s\",\"state\":\"sent\",\"cap_charge\":0.1,\"payer\":\"holder\",\"job\":\"j1\",\"quote_padded\":0.1}\\n' \"$HEX\" >> $IMD_FLOP_HOME/ledger.jsonl\n  node dist/bin.js run --job j1 --prompt hi --no-link; echo exit=$?\nActual (observed): stderr `imd-flop: secret scan blocked writing ledger.jsonl: reason: 64-or-more-hex (key-shaped) value`, exit=5, ledger still 3 lines (no refusal row).\nExpected: exit 3 with `ledger line 3 cannot be placed in the daily total ...` as `status` prints it (status exits 1 with that line, observed).","severity":"low","snippet":"      if (e instanceof UnusableLedgerRow) refuse(e.message, EXIT.REFUSED_CAP, quote, null);","title":"run refuses a damaged ledger row with exit 5 and names no line when the unusable value is key-shaped: the placement message quoting the value becomes the refusal reason and the secret scan blocks it"},{"citation":"resolved","description":"Area 2 (lock lockout with no way back that the message explains). reclaimable() only reclaims a lock older than 30 s when its owner is provably dead on this host (provablyDead, ledger.ts:280: `if (!o || o.host !== hostname()) return false;`) or when it has no readable owner. A lock left by a holder that died on a machine with a different hostname (a container that was removed, a laptop whose hostname changed between runs, a home on a shared or synced filesystem) is never reclaimed, so every `run` fails with exit 10 after a 5 s wait, indefinitely, and every reader that meets a line in flight fails with exit 1. The same lockout happens on this host when the dead holder's pid has been reused by an unrelated long-lived process (process.kill(pid, 0) succeeds, so the owner counts as alive). The message names only the lock path and its age; it does not print the recorded owner (pid, host) nor that the way back is to confirm the owner is gone and remove the file. README documents 'A lock owned by another host is never reclaimed' but gives no recovery step either. Inside the promise only in the sense that nothing is spent; it is an availability defect, not a cap break. Fix: include the owner's pid and host in LedgerLockError messages and, for a lock past the reclaim window that cannot be reclaimed, add one sentence naming the file to remove once the owner is known to be gone. On the 30 s question (task item 2): for a lock whose owner record is readable, on this host, and whose pid is gone (ESRCH), the window could be dropped to a few seconds safely, since createLock links the owner in atomically and a holder cannot be mid-write once its pid is gone; the window must stay for ownerless or foreign-host leftovers, and pid reuse remains the residual risk the window does not address anyway.","line":351,"path":"imd-flop/src/ledger.ts","reproduction":"Offline, mock provider. In imd-flop after `npm run build`:\n  export IMD_FLOP_HOME=$(mktemp -d); node dist/bin.js init --seat 8; node dist/bin.js cap set 1\n  echo '{\"pid\":1,\"host\":\"some-other-host\",\"token\":\"abc\"}' > $IMD_FLOP_HOME/ledger.jsonl.lock; touch -d '2 hours ago' $IMD_FLOP_HOME/ledger.jsonl.lock\n  node dist/bin.js run --job j1 --prompt hi --no-link; echo exit=$?\nActual (observed): after ~5 s, `imd-flop: refused locally, nothing sent: ledger lock timeout: <home>/ledger.jsonl.lock (7205 s old) is held`, exit=10. Repeating the command gives the same result at any age. Expected: either reclaim (not safe for a foreign host) or a message that names the recorded owner and the recovery step.","severity":"low","snippet":"      if (Date.now() - start > 5_000) throw new LedgerLockError(`ledger lock timeout: ${lockAge(path)} is held`);","title":"A stale ledger lock whose owner record names another host (or a dead pid that was reused) blocks run forever, and the timeout message does not say whose lock it is or how to recover"},{"citation":"resolved","description":"Area 1 (UTC day change between admission and booking), observational only. lockedCheck reads `day = utcDay()` inside the lock and uses it for the totals and for the `sent` row (run.ts:209-213), but a refusal from inside the lock goes through refuse(), whose row spreads `base` and so carries base.day, the day computed before the estimate (run.ts:151). Across 00:00 UTC the refused_local row says day D with spent_before taken from day D+1. Its cap_charge is 0 and its request_id is null, so no total is affected and the cap promise is not touched; only the viewer's placement of that refusal row and its 'daily cap left' line are off by a day. Fix: have refuse() take the day as a parameter and pass `day` from lockedCheck.","line":171,"path":"imd-flop/src/run.ts","reproduction":"Code reading; a timing reproduction needs a fake clock. With a fake clock: set the clock to 23:59:59.9Z, start `run` with a quote that will not fit (per-call cap below the padded quote), let the estimate complete after 00:00:00Z; the appended refused_local row has day = yesterday while spent_before equals spentToday(today). No charge is affected.","severity":"info","snippet":"    appendLedger(h, { ...base, request_id: null, quote_raw: q.raw, quote_padded: q.padded, ...recorded(spent), state: \"refused_local\", reason, spend: null, spend_raw: null, cost_reported: null, cap_charge: 0, http: null });","title":"A refusal written under the ledger lock records base.day (read before the estimate) while its spent_before was computed for the day read inside the lock"},{"citation":"resolved","description":"Mutation testing against the suite named in task item 8. Seven single-line mutations were applied one at a time, `npm run build && npx vitest run` executed, and the source restored: (M2) holdFloor requires a trusted rather than a known read to bring a released hold back: caught (decision-properties, review-hold); (M11) the seed row no longer reports pastAdmitted to the caller: caught (booking, review-hold-2); (M12) ledgerLines no longer re-reads a torn line under the lock: caught (audit-v3, audit-v6, review-fixes stress, review-hold-2 stress); (M13) an unusable cap_charge counts as 0 in sumCharges: caught (audit-v3, audit-8ddc5d2); (M15) rows without a usable request id are dropped from placed(): caught (contract vectors, review-hold, review-hold-2); (M16) 'nothing was sent' releases from any unsettled state, not only `sent`: caught (booking, decision-properties); (M8) lockedCheck uses `base.day` (computed before the estimate, run.ts:151) instead of `utcDay()` read inside the lock: NOT caught, 24 files / 742 tests pass. The suite never sets a fake clock (no setSystemTime/useFakeTimers in imd-flop/test), so the UTC day change between admission and booking that task item 1 asks about is untested. With M8 the mutated program is not a cap break (the call is checked against and booked on the earlier day, so neither day exceeds its cap), but the comment at run.ts:208 states a rule the tests do not hold, and the related refused_local row already carries the pre-lock day (see the separate info finding). Fix: add a test with a fake clock that starts `run` at 23:59:59.9Z with a slow estimate stub and asserts the `sent` row's `day` and `spent_before` are those of the new day; a second case asserts the same for a refusal written under the lock.","line":209,"path":"imd-flop/src/run.ts","reproduction":"In imd-flop: replace `    const day = utcDay();` (src/run.ts:209) with `    const day = base.day;`, run `npm test`. Observed: `Test Files 24 passed (24)`, `Tests 742 passed (742)`. Restore the line afterwards (`git checkout -- src/run.ts`). For comparison each of the six other mutations listed in the description fails at least two test files under the same command.","severity":"info","snippet":"    const day = utcDay();","title":"Test gap (area 8): the rule 'the UTC day is read once, inside the lock' is not pinned; a mutation that reads the day before the estimate passes all 742 tests, and no test crosses a UTC day boundary"}],"hash":"325cfe647afaa67bcfd17bd08b6f9dfdd34ba0e0b371c243f3097718a3db8d37","nodeId":"6b7583fb-4fc3-4003-b120-5dc7f8a13007","outcome":"completed","summary":"The review is complete. `.imd-findings.json` holds four findings, all verified against the tree, and the working tree has no tracked changes.\n\n## Outcome\n\nNo break of the cap promise was found. The hold floor, PAST_ADMITTED, the one-step booking and the ledger lock held up under code reading, concrete runs, and seven mutations against the suite. The findings are two lows and two infos, none inside the stated promise.\n\n**Checks run** (all in this workspace):\n\n| Suite | Command | Result |\n|---|---|---|\n| imd-flop | `npm ci && npm test` | 742 passed, 0 failed (24 files) |\n| spend-guard | `python3 -m unittest discover -s . -p 'test_*.py'` | 66 passed |\n| viewer | same | 135 passed |\n\nAlso run: three CLI experiments against the mock provider in a temp home, one viewer experiment through its test harness, and seven source mutations each followed by a full build and vitest run, with the source restored after each.\n\n## Findings (in `.imd-findings.json`)\n\n- **Low, run.ts:184.** A damaged row whose unusable value is key-shaped (64 hex, a bearer string) makes `run` exit 5 with \"secret scan blocked writing ledger.jsonl\" and no line number, because the placement message quoting the value is written as the refusal row's reason. README promises exit 3 naming the line. Observed offline. Outside the promise, nothing sent.\n- **Low, ledger.ts:351.** A stale lock whose owner record names another host, or whose dead pid was reused, is never reclaimed. Every `run` exits 10 after 5 s forever, and the message gives neither the recorded owner nor the way back. Observed offline with a 2-hour-old foreign lock. On the 30 s question: for a lock with a readable owner on this host whose pid is gone, the window could safely be a few seconds since the owner is linked in atomically. It must stay for ownerless or foreign-host leftovers.\n- **Info, run.ts:171.** A refusal written under the lock carries the day computed before the estimate while its `spent_before` was computed for the day read inside the lock. Charge 0, no total affected.\n- **Info, run.ts:209.** Test gap. Reading the UTC day before the lock instead of inside it passes all 742 tests. No test uses a fake clock, so the day-change case in item 1 is untested. The other six mutations (redacted read not restoring the hold, seed not warning, no re-read under the lock, unknown charge as 0, dropping id-less rows, release from any unsettled state) were each caught by at least two test files.\n\n## Covered by reading only\n\n- Rounding (R-g): ceil6 and floor6 move only the safe way above 8192 FLOP. No admission beyond the stated residual.\n- Amounts: knownAmount, exactAmount and the viewer twins agree, including -0, 2^53+1, 1e400 and 4300-digit integers.\n- Text contract: both readers split on LF only, use the same blank set, reject NaN, BOM, raw control characters and non-objects identically. The viewer handles deep nesting and lone surrogates without crashing.\n- Secrets: the RPC URL is masked in every transport error including undici's credentials message. Redacted receipts never settle and never lower a charge. Reconcile and the viewer's classify agree on every branch.\n- Exit codes match the README table except the exit-5 case above.\n\nNot done: no Foundry proof files, since there is no Solidity in scope. Stress blocks were run at their default sizes only.","treeHash":null,"usage":{"cachedInputTokens":3285432,"cost":{"source":"runtime","usdMicros":7208493},"inputTokens":738,"model":"claude-fable-5-1","outputTokens":57609,"runtime":"claude","turns":41,"wallClockMs":1335898}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"e0f64c10e30e33e2","findings":[{"citation":"resolved","description":"Inside the promise (a dead lock holder). reclaimIfDead reads the owner at line 291 and renames whatever is at the path at line 293, as two separate steps. If several waiters meet a dead holder's lock that is over 30 s old, waiter A can do the whole reclaim and create its own live lock (createLock) between waiter B's read and B's rename. B then moves A's live lock aside. Until B links it back at line 295 the path is empty: (a) a third waiter C creates the lock and runs while A is still inside. With two `run` processes that means two lockedCheck calls (run.ts:207-216) read the same spentToday and both append `sent` rows, so the day's total (or a job's total) goes over the limit and no row carries PAST_ADMITTED. B's link-back then fails with EEXIST: B throws `ledger lock contested` and leaves a `ledger.jsonl.lock.stale.*` file that nothing removes. (b) If A releases while its lock is aside, releaseLock finds no owner and removes nothing. B then links A's released lock back. It names a live pid, so it is never reclaimed while that process lives, and every command fails with `ledger lock timeout ... is held`. The re-check at line 294 is not tested: making it always true passes all 742 tests. On the 30 s question in item 2: createLock writes the owner atomically, so for an owner that is readable, on this host and gives ESRCH, age proves nothing more, and the window could be cut to below the 5 s waiter timeout. Do that only after this race is closed, because more reclaims widen the window. Keep 30 s for ownerless leftovers and never reclaim a lock from a foreign host. Fix: make 'read owner, judge dead, remove' one exclusive step (for example under a second lock file created by link, re-reading the owner inside it), or compare the inode/token of the moved file with the one judged and put it back if they differ. Also have each holder re-check that the path still holds its own token right before appending, and refuse if it does not.","line":293,"path":"imd-flop/src/ledger.ts","reproduction":"Offline, nothing mocked. In imd-flop after `npm ci && npm run build`, run the script below from imd-flop (`node lock.mjs 40`). Each round writes ledger.jsonl.lock as {pid:<pid of an exited process>, host:os.hostname(), token:'dead'} with mtime 60 s ago, then 16 processes call dist/ledger.js withLedgerLock at the same moment. Each body logs E on entry and X on exit. Expected: E/X strictly alternate, no error, no lock file left. Actual on this machine: `two holders: 2 of 40; contested: 2; left: 2`, meaning 2 rounds had two bodies inside the lock at once, with a `ledger lock contested` error and a leftover .stale file. Script: import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { spawn, spawnSync } from 'node:child_process'; import { fileURLToPath, pathToFileURL } from 'node:url'; const DIST = pathToFileURL(path.resolve('dist')).href; const { withLedgerLock } = await import(`${DIST}/ledger.js`); const { ledgerFile } = await import(`${DIST}/paths.js`); if (process.argv[2]==='racer'){ const [h,deadline]=process.argv.slice(3); const log=`${h}/race.log`; while(Date.now()<Number(deadline)){} try{ withLedgerLock(h,()=>{ fs.appendFileSync(log,'E\\n'); Atomics.wait(new Int32Array(new SharedArrayBuffer(4)),0,0,3); fs.appendFileSync(log,'X\\n'); }); }catch(e){ fs.appendFileSync(log,`ERR ${e.message.split(':')[0]}\\n`); } } else { let two=0; const N=+process.argv[2]||40; for(let r=0;r<N;r++){ const h=fs.mkdtempSync(path.join(os.tmpdir(),'lock-')); const lock=`${ledgerFile(h)}.lock`; fs.writeFileSync(lock, JSON.stringify({pid: spawnSync(process.execPath,['-e','']).pid, host: os.hostname(), token:'dead'})); fs.utimesSync(lock, Date.now()/1000-60, Date.now()/1000-60); const deadline=Date.now()+900; await Promise.all(Array.from({length:16},()=>new Promise(res=>spawn(process.execPath,[fileURLToPath(import.meta.url),'racer',h,String(deadline)],{stdio:'ignore'}).on('exit',res)))); let inside=0,ov=false; for(const l of fs.readFileSync(`${h}/race.log`,'utf8').split('\\n')){ if(l==='E'&&++inside>1) ov=true; if(l==='X') inside--; } if(ov) two++; } console.log(`two holders: ${two} of ${N}`); }. The count varies by run and machine; any value above 0 is the defect.","severity":"high","snippet":"  try { renameSync(path, aside); } catch { return false; } // someone else moved or released it","title":"Lock reclaim renames a lock it did not judge: a live holder's lock is moved aside, so two holders run at once (cap passed with no PAST_ADMITTED) or the seat is locked behind a released lock"},{"citation":"resolved","description":"The ledger text contract reads a last line with no final newline as a line, so a ledger whose last write lost only its newline (a torn write by the tool's own crash, inside the promise) passes the cap check. appendJsonlSafe then appends JSON+\"\\n\" right after it. The two rows share one line, which is not valid JSON. In `run` this append is the `sent` row (run.ts:214), written after the cap check and before the paid call. The paid call goes out, and the booking step and every later command then refuse with `ledger line N cannot be read ... Repair or remove that line.` Line N holds two charged rows, one of them for a call that was really sent. An operator who follows 'remove' drops both charges from the totals. The cap is not passed at the time (the tool fails closed), but the tool's own write damages a ledger it had just read as good, and the recovery the message suggests loses a real charge. Fix: under the lock, before appending, check that the file is empty or ends with \\n, and write a \\n first (or refuse) when it does not.","line":21,"path":"imd-flop/src/store.ts","reproduction":"Offline. In imd-flop after build: appendLedger(h, row('mock-7-a')) with a full sent row (cap_charge 0.4, day today, seat 7, provider mock). Strip the trailing \\n from ledger.jsonl. spentToday(h,'7','mock',day) -> 0.4 (the row is read). Then appendLedger(h, row('mock-7-b')). spentToday now throws `UnreadableLedgerLine ledger line 1 cannot be read: not valid JSON; next call refused. Repair or remove that line.` and the file is 2 lines (one row line plus empty), so line 1 holds both rows. Expected: two readable lines and total 0.8, or the append refused.","severity":"low","snippet":"  appendFileSync(path, JSON.stringify(obj) + \"\\n\", { mode: 0o644 });","title":"An append never checks the file's end: a last line with no final newline (read as a valid row) gets the next row glued onto it, and the ledger becomes unreadable right after a call is admitted"},{"citation":"resolved","description":"Item 6. The comment above postEthRpc says no message quotes the URL, because it often carries a key. A transport error is scrubbed, but the server's own JSON-RPC error message is quoted as received. The RPC URL is never registered as a secret, and a path token is not key-shaped, so scanForSecrets passes it. The message reaches stderr (run.ts first paid-network check and the owner check). When it comes from the pre-mint recheck as a PaidGateError, it also reaches the refused_local row's reason in ledger.jsonl via run.ts:247 and bookFailure, and stderr at run.ts:267. Outside the cap promise (keys stay local). It needs a server or proxy that echoes the URL, which is common for an expired or rate-limited key. Fix: pass every message built from server text through the same URL replacement (href, userinfo, path), or do not quote server text.","line":261,"path":"imd-flop/src/gateway.ts","reproduction":"Offline. In imd-flop after build: await (await import('./dist/gateway.js')).assertPaidNetwork((await import('./dist/networks.js')).network('devnet'), 'https://eth.example.invalid/v2/k3y-AbCdEf0123456789-path-token', async (input) => new Response(JSON.stringify({jsonrpc:'2.0',id:1,error:{code:-32000,message:`unauthorized for ${String(input)}`}}),{status:200})). Actual: throws `PaidGateError eth_chainId error: unauthorized for https://eth.example.invalid/v2/k3y-AbCdEf0123456789-path-token`, and scanForSecrets({reason: e.message}) returns []. Expected: the URL replaced by [ETH_RPC_URL], or the scan refusing it.","severity":"low","snippet":"  if (j.error) throw new Error(`eth_chainId error: ${j.error.message ?? \"rpc error\"}`);","title":"A JSON-RPC error text from the Ethereum RPC is quoted raw, so a server that echoes the request URL puts ETH_RPC_URL (and its key) on stderr and in the ledger reason"},{"citation":"resolved","description":"isSeatId accepts any 1 to 78 decimal digits, and every use (seat directory, config caps, the ledger seat filter) compares the text. The chain and the link signature use BigInt(tokenId), so '42', '042' and '0042' are one NFT seat on chain and separate seats to imd-flop. README line 70 ('One DID per seat. init refuses a second key for a seat') is not kept: `init --seat 042` after `init --seat 42` creates a second key and DID with no --rotate and no revocation record. The seat then has one daily cap and one daily total per spelling, so its spend per UTC day is N times its cap, and no row carries PAST_ADMITTED because the totals never meet. Values above 2^256-1 are accepted too. Inside the promise in the sense that only this tool writes the ledger, but the per-seat cap is split. Fix: canonicalise in isSeatId/validateSeat (no leading zero unless the id is '0', value at most 2^256-1), and treat a non-canonical seat in a ledger row as not placeable.","line":35,"path":"imd-flop/src/keys.ts","reproduction":"Offline, mock provider, fresh IMD_FLOP_HOME, from imd-flop after build (B='node dist/bin.js'): `$B init --seat 42` -> exit 0; `$B init --seat 042` -> exit 0 (expected 5), and seats/42 and seats/042 both exist. `$B cap set 0.2 --seat 42; $B cap set 0.2 --seat 042`. `$B run --job j1 --prompt hi --max-tokens 8 --seat 42 --no-link --json` -> exit 0 (charge 0.174); again -> exit 3 `padded quote 0.174 FLOP is over the remaining daily cap 0.026`; then `--seat 042` -> exit 0 with a new sent/estimated row, seat '042', cap_charge 0.174. So NFT seat 42 is charged 0.348 FLOP on a 0.2 FLOP/day cap, and grep -c PAST_ADMITTED ledger.jsonl = 0.","severity":"medium","snippet":"  return typeof seat === \"string\" && (seat === UNASSIGNED || /^[0-9]{1,78}$/.test(seat));","title":"Seat ids are compared as text, so 42 and 042 are two seats: a second key and DID, a second daily cap and a second daily total for one NFT seat"},{"citation":"resolved","description":"sumCharges adds cap charges as doubles and rounds once with ceil6. Caps and charges up to AMOUNT_MAX (2^53-1) are accepted, but a double stops resolving the 1e-6 grid at about 2^33 FLOP. When ulp(sum)/2 exceeds a charge, sum + charge === sum, so the charge counts as nothing on every later read of spentToday and jobSpent. README line 81 / rules.json R-g say that for grid charges and caps rounding never admits a call over the cap, with a residual of at most 1e-12 FLOP. Here every row is on the grid and written through appendLedger, and the overrun has no bound. Low because the amounts are far above devnet use, but `cap set` accepts them. Fix: sum in integer micro units (BigInt(Math.round(c*1e6))) and compare in the same units, or refuse limits and totals above about 2^33 until money stage 2.","line":177,"path":"imd-flop/src/ledger.ts","reproduction":"Offline, library calls from imd-flop after build: appendLedger(h, sent row seat 7 provider mock day today cap_charge 17179869184). Then loop 50 times: s = spentToday(h,'7','mock',day); if checkBudget({padded:0.000001, cap:{daily:17179869184.00001, perCall:1}, payer:'holder', spentBefore:s}) is non-null stop, else appendLedger(sent row with cap_charge 0.000001). Expected: 10 calls admitted, then refusal. Actual: all 50 admitted, and spentToday still returns 17179869184. The true total is 17179869184.00005 against a cap of 17179869184.00001.","severity":"low","snippet":"    sum += row.cap_charge;","title":"Day and job totals are float sums: at large totals small charges are absorbed and calls are admitted past the cap with no PAST_ADMITTED"},{"citation":"resolved","description":"Items 3 and 7. placed() builds its message with the unusable value quoted. refuse() appends a refused_local row whose reason is that message, and appendJsonlSafe scans it first. When the value is key-shaped (64+ hex, Bearer..., flop_...), the scan throws CliError exit 5 `secret scan blocked writing ledger.jsonl: reason: 64-or-more-hex (key-shaped) value` before the exit-3 refusal. The message names no line and no refusal row is written. The README exit table (code 3: '... a ledger line cannot be read or placed in a total') and 'the message names the line' are not kept. Nothing is sent, so the cap holds. Outside the promise (edited ledger). Fix: in refuse(), scan the reason and withhold the value on a hit, as the pre-send path does, or build the placement message from the value's type/length instead of the raw value.","line":184,"path":"imd-flop/src/run.ts","reproduction":"Offline, mock, from imd-flop after build: IMD_FLOP_HOME=$(mktemp -d); node dist/bin.js init --seat 7; node dist/bin.js cap set 1 --seat 7 --per-call 0.5; append the line {\"request_id\":\"mock-7-00112233aabbccdd\",\"seat\":\"7\",\"provider\":\"mock\",\"day\":\"<64 x 'a'>\",\"state\":\"sent\",\"cap_charge\":0.1,\"payer\":\"holder\",\"job\":\"j1\",\"quote_padded\":0.1} to ledger.jsonl; `node dist/bin.js run --job j1 --prompt hi --seat 7 --no-link`. Actual: exit 5 `imd-flop: secret scan blocked writing ledger.jsonl: reason: 64-or-more-hex (key-shaped) value`, and no row is appended. Control: with day \"zz\" the same run exits 3 with `ledger line 1 cannot be placed in the daily total ...`, and `status` names `ledger line 3` for the hex case.","severity":"low","snippet":"      if (e instanceof UnusableLedgerRow) refuse(e.message, EXIT.REFUSED_CAP, quote, null);","title":"run exits 5 and names no line when the value of a row that cannot be placed is key-shaped: the refusal reason quotes it and the secret scan blocks the refusal row"},{"citation":"resolved","description":"Item 2: a lockout with no explained way back. A dead lock holder is inside the promise. provablyDead requires the same hostname and ESRCH. A holder that died in a container that was then recreated with a new hostname, or whose pid now belongs to another process (common for a container entrypoint, where the next run can even get the same pid as the dead holder), is never reclaimed. Every `run` exits 10 after 5 s and every other locking command exits 1, at any age of the lock. The message calls the lock 'held' and prints only the path and age. Neither it nor README line 47 ('A lock owned by another host is never reclaimed') gives the owner (pid, host) or the recovery step (remove the file once no imd-flop process is using the home). It fails closed, so the cap holds. Fix: print the recorded owner in the message and, past the reclaim window, name the file to remove once the owner is known to be gone. Also treat owner.pid === process.pid with a foreign token as dead. On the 30 s window, see the lock-reclaim finding.","line":351,"path":"imd-flop/src/ledger.ts","reproduction":"Offline, mock, from imd-flop after build: IMD_FLOP_HOME=$(mktemp -d); node dist/bin.js init --seat 8; node dist/bin.js cap set 1 --seat 8; echo '{\"pid\":1,\"host\":\"some-other-host\",\"token\":\"abc\"}' > $IMD_FLOP_HOME/ledger.jsonl.lock; touch -d '2 hours ago' $IMD_FLOP_HOME/ledger.jsonl.lock; node dist/bin.js run --job j1 --prompt hi --seat 8 --no-link. Actual: after ~5 s `refused locally, nothing sent: ledger lock timeout: <home>/ledger.jsonl.lock (7205 s old) is held`, exit 10. It repeats at any age. Expected: a message naming the recorded owner and the recovery step.","severity":"low","snippet":"      if (Date.now() - start > 5_000) throw new LedgerLockError(`ledger lock timeout: ${lockAge(path)} is held`);","title":"A dead holder's lock that cannot be proven dead (other hostname, or its pid reused) locks the seat out for good, and the timeout message gives neither the owner nor a way back"},{"citation":"resolved","description":"Item 4. The contract says a non-blank line is one JSON value in JSON.parse's grammar, with no depth limit. Node's JSON.parse reads it. Python's json.loads raises RecursionError at about 10^4 levels (the limit depends on the build), and the viewer turns that into ValueError -> 'not valid JSON'. For the same bytes, imd-flop counts the row (its cap_charge is in the totals, and as the latest row of a request it sets that request's state), while the viewer shows an unreadable row and keeps the request's previous line. No fixture in contract/fixtures/ledger-text covers depth. The tool never writes nested values, so this is outside the promise (edited ledger), but it is a reader split. Fix: put a small nesting limit in LEDGER_TEXT, enforce it in both readers, and add fixtures on both sides of it.","line":104,"path":"viewer/build-lane.py","reproduction":"Offline. Line: {\"request_id\":\"r1\",\"cap_charge\":1,\"x\":<9997 '[' then 9997 ']'>}\\n. imd-flop after build: (await import('./dist/ledger-text.js')).readLedgerText(line)[0] -> n 1, kind 'row', cap_charge 1. Viewer (Python 3.12.3): importlib-load viewer/build-lane.py, read_ledger_text(line_bytes)[0] -> (1, 'unreadable', 'not valid JSON'). Expected: the same verdict from both readers.","severity":"low","snippet":"    except RecursionError as ex:","title":"Ledger text contract: a deeply nested line is a row to imd-flop and 'not valid JSON' to the viewer"},{"citation":"resolved","description":"imd-flop admits when padded <= floor6(cap - spent) (policy checkBudget/roomLeft). The viewer decides the same row as floor6(cap - spent - cap_charge) < 0. The extra subtraction leaves float noise near 0 larger than the 1e-12 tolerance once amounts are above about 8192 FLOP, so floor6 moves it a whole step down to -0.000001. For one tool-written ledger, imd-flop admits the call and `status` shows the day exactly at its cap with over_cap false, while the lane marks the row 'over the daily cap'. Same ledger, two verdicts (item 4). Outside the cap promise. Fix: use imd-flop's form in the viewer (left = floor6(cap - spent); over when cap_charge > left), or integer micro units in both, and add this triple to contract/vectors/cap-compare.json.","line":218,"path":"viewer/build-lane.py","reproduction":"Offline. imd-flop after build: (await import('./dist/policy.js')).checkBudget({padded:4027.974074, cap:{daily:8197.132493, perCall:8197.132493}, payer:'holder', spentBefore:4169.158419}) -> null (admitted). Viewer: importlib-load viewer/build-lane.py; bl.floor6(8197.132493 - 4169.158419 - 4027.974074) -> -1e-06, the value limit_flags compares (line 218), giving 'over the daily cap'. Expected: daily cap left 0 and no flag.","severity":"low","snippet":"        applied.append((lim, floor6(cap - spent - cap_charge) if None not in (cap, spent, cap_charge) else None))","title":"The viewer flags a row that imd-flop admitted exactly at the daily cap as 'over the daily cap' (different float rounding)"},{"citation":"resolved","description":"Item 6. run.ts:247 replaces every 0x<64 hex> in err.message with [hash] and runs the secret scan on that stripped text (line 250). When the stripped text passes, line 267 prints the unstripped err.message. So a value the tool's own scan calls key-shaped (HEX64) is kept out of the ledger row but reaches stderr. The strip exists for public genesis hashes in PaidGateError, but it applies to every PreSendError, including mint failures whose text is whatever the transport or gateway echoed. Outside the cap promise. Fix: print `reason` (already stripped and scanned), or scan err.message itself and withhold on a hit.","line":267,"path":"imd-flop/src/run.ts","reproduction":"Offline, from imd-flop after build: init --seat 7 and cap set 1 --seat 7 in a fresh IMD_FLOP_HOME, then call dist/run.js runJob(h, stubProvider, {job:'j1', prompt:'hi', maxTokens:8, seat:'7', noLink:true, timeoutMs:5000}, {warn(){}}), where stubProvider is named 'mock', estimate returns {0.066,3,8} and complete throws new PreSendError('child key mint failed (TypeError: 0x' + 'ab'.repeat(32) + ')'). Actual: CliError exit 11 with message `nothing sent: child key mint failed (TypeError: 0xabab...ab)` (the full 64 hex, which the CLI prints on stderr), while the ledger's last row is refused_local with reason `nothing sent: child key mint failed (TypeError: [hash])`. Expected: stderr carries the stripped reason or a withheld line.","severity":"low","snippet":"    if (preSend) throw new CliError(`${preSendWithheld ? reason : `nothing sent: ${err.message}`}${kept ? \".\" + also : \"\"}`, e instanceof PaidGateError ? EXIT.REFUSED_RULE : EXIT.NOT_SENT);","title":"A 0x-prefixed 64-hex value in a pre-send error is stripped for the ledger reason but printed raw on stderr"},{"citation":"resolved","description":"Item 8. Each change was applied alone in a copy of imd-flop, then rebuilt with tsc and run with the full vitest suite (default STRESS sizes). Each left `Tests 742 passed (742)`. (1) ledger.ts:294 `reclaimable(readOwner(aside))` -> `reclaimable(readOwner(aside)) || Date.now() > 0`: a waiter deletes whatever lock it moved aside, including a live holder's. No test has a second waiter at a dead holder's old lock, which is the race in the lock-reclaim finding. (2) run.ts:209 `const day = utcDay();` -> `const day = base.day;`: the cap check and the sent row use the day read before the estimate, against the comment above the line. No test uses a fake clock or crosses 00:00 UTC (item 1's day change). (3) settlement.ts:251 `charge > admitted` -> `charge > admitted + 1e-6`: a booking that raises a charge by up to 1e-6 FLOP prints no PAST_ADMITTED. The decision-properties grid never draws amounts that close. (4) settlement.ts:211 `BigInt(incoming) > BigInt(known)` -> `incoming > known`: exact amounts compared as text ('10' < '9'). The grid never pairs two exact amounts of different length. As a control, the same harness with ledger.ts:177 `sum += row.cap_charge` -> `sum += 0` fails 99 tests. Fix: add a two-waiter reclaim test, a fake-clock test that crosses midnight inside estimate(), grid amounts within 1e-6 of the admitted charge, and a vector with booked spend_raw '9' and read '10'.","line":294,"path":"imd-flop/src/ledger.ts","reproduction":"cp -r imd-flop to a scratch copy (with contract/ beside it), apply one replacement, `npx tsc -p tsconfig.json && npx vitest run`. Observed for each of (1) to (4): `Tests  742 passed (742)`. Control (`sum += 0;` at ledger.ts:177): `Tests  99 failed | 643 passed (742)`.","severity":"low","snippet":"  if (reclaimable(readOwner(aside))) { try { unlinkSync(aside); } catch { /* gone */ } return true; }","title":"Test gaps: four wrong rules pass all 742 imd-flop tests (lock reclaim re-check, UTC day read before the lock, PAST_ADMITTED threshold, exact amounts compared as text)"},{"citation":"resolved","description":"Item 1 (UTC day change). lockedCheck reads the day inside the lock (run.ts:209) and computes the totals for it, but refuse() spreads `base`, so the refused_local row keeps base.day and base.ts from before the estimate. Across 00:00 UTC the row is dated day D with spent_before from day D+1. cap_charge is 0 and request_id null, so no total moves and the cap promise holds. Only the record contradicts itself (the viewer's 'daily cap left' for that row). Fix: pass the locked day into refuse().","line":171,"path":"imd-flop/src/run.ts","reproduction":"Offline, from imd-flop after build: fresh home, init --seat 7, cap set 1 --seat 7 --per-call 0.5. Call runJob with a stub 'mock' provider whose estimate() replaces globalThis.Date with a class shifted +24 h and returns {estimated_cost_flop:0.3, estimated_input_tokens:3, estimated_output_tokens:8}. Actual: exit 3 `padded quote 0.845455 FLOP is over the per-call cap 0.5`, and the refused_local row has day 2026-11-11 (the pre-estimate day), while the locked check ran on 2026-11-12. Expected: the row's day is the day the check used.","severity":"info","snippet":"    appendLedger(h, { ...base, request_id: null, quote_raw: q.raw, quote_padded: q.padded, ...recorded(spent), state: \"refused_local\", reason, spend: null, spend_raw: null, cost_reported: null, cap_charge: 0, http: null });","title":"A refusal written inside the locked cap check carries the day read before the estimate, while its spent_before is for the day read inside the lock"},{"citation":"resolved","description":"Item 5 (an unknown amount counted as 0). README line 86 says an unknown amount 'shows blank, never 0', and line 20 presents gateway_spent_today_utc as what the gateway reported spent today. reportedSpendToday adds 0 for every row whose spend is unknown, so a day with an unknown spend cannot be told apart from one where that call settled at 0. The cap is unaffected (spentToday uses cap_charge). Fix: return null when any row of the day has an unknown spend, or report the count of such rows beside the sum.","line":254,"path":"imd-flop/src/ledger.ts","reproduction":"Offline, from imd-flop after build: in a fresh home, appendLedger two settled rows (seat 7, provider mock, today), the first with spend 0.05 and the second with spend null. reportedSpendToday(h,'7','mock',day) -> 0.05. The same with the second spend 0 -> 0.05. Expected: unknown (null) or a marker in the first case.","severity":"info","snippet":"  for (const { row } of seatDay(h, seat, provider, day)) sum += knownAmount(row.spend) ?? 0;","title":"status gateway_spent_today_utc counts a row with unknown settled spend as 0, with no marker"}],"hash":"78ea3bd692b8a0d9e14e3fb66797c0706ebe583f7f9a75697620ce312d387f82","nodeId":"5f4307ba-6d4d-4fcb-a6f7-7559ab04d21d","outcome":"completed","summary":"I've written 13 findings to `.imd-findings.json`. Each was reproduced offline against the code at this commit, as a non-root user (uid 1000), and its file, line and snippet were checked against the tree. No repository files were changed.\n\n**Checks run:** `npm ci` and `npm test` in imd-flop pass 742/742. spend-guard passes 66/66 and viewer passes 135/135. Every reproduction below was run, as Node scripts against `dist/`, CLI runs with the mock provider, or Python against `viewer/build-lane.py`. Mutation tests ran in a scratch copy of imd-flop.\n\n**Findings, merged across the specialists:**\n- **High – lock reclaim race** (`ledger.ts:293`). After a holder dies, a waiter checks the old lock's owner and then renames whatever is at the path. In between, another waiter may already have taken the lock, so a live holder's lock gets moved aside. With 16 real processes and nothing mocked, 2 of 40 rounds had two holders inside the lock at once, each with a \"ledger lock contested\" error and a leftover `.stale` file. The consequence for the cap I worked out from the code but did not run: two `run`s would read the same day total and both book a charge, so the cap is passed with no PAST_ADMITTED row. The three specialists' duplicate findings are merged here, including the 30 s question in item 2. That window can be shortened only for a lock whose owner is readable, on this host and provably gone, and only after this race is fixed. It must stay 30 s for locks with no readable owner, and a lock from another host must never be reclaimed.\n- **Medium – seat ids compared as text** (`keys.ts:35`). Seats `42` and `042` are the same NFT on chain but two seats to the tool: a second key and DID, and a second daily cap. I reproduced 0.348 FLOP charged against a 0.2 FLOP daily cap with no PAST_ADMITTED row.\n- **Low:**\n  - A new row is glued onto a last line that has no final newline, so the ledger becomes unreadable right after a call is admitted.\n  - An Ethereum RPC error that echoes the request URL is quoted raw, so the RPC URL and its key reach stderr and the ledger reason.\n  - Totals are float sums: above about 2^34 FLOP, 50 calls were admitted against room for 10.\n  - A key-shaped value in a damaged row makes `run` exit 5 without naming the line, where the README promises exit 3 and the line number.\n  - A lock left by another host, or by a pid now reused, locks the seat out for good, and the message gives no owner and no way back.\n  - The two readers disagree on a deeply nested line: imd-flop reads it as a row, the viewer as \"not valid JSON\".\n  - At caps above about 8192 FLOP the viewer flags a row \"over the daily cap\" that imd-flop admitted exactly at the cap.\n  - A 64-hex value in a pre-send error is stripped from the ledger reason but printed raw on stderr.\n  - Test gaps: four wrong rules each still pass all 742 tests. A control mutation (`sum += 0` in `ledger.ts`) fails 99 tests.\n- **Info:**\n  - A refusal written inside the lock keeps the day read before the estimate; reproduced by shifting the clock 24 h during the estimate.\n  - `gateway_spent_today_utc` in `status` counts an unknown spend as 0.\n\n**Dropped:** nothing reproduced was dropped; the specialists' duplicates were merged into the findings above. The other wrong rules they reported as surviving the tests I did not re-run, so they are left out. I attached no Foundry proofs because the code under review is TypeScript and Python, not Solidity.\n\n**Only read, not run:** the path from a PaidGateError into the ledger row in `run.ts`. The stress blocks ran only at their default size, not at `STRESS_PROCS`/`STRESS_OPS`/`STRESS_ROUNDS` set larger.","treeHash":null,"usage":{"cachedInputTokens":1503816,"cost":{"source":"runtime","usdMicros":1401546},"inputTokens":44,"model":"claude-opus-5-5","outputTokens":24998,"runtime":"claude","turns":23,"wallClockMs":788396}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"2d027bc56749d95c","findings":[{"citation":"resolved","description":"isSeatId accepts any 1 to 78 decimal digits and every user of a seat id (seat directory, config.caps[seat], ledger.ts onSeat filter `v !== f.want`, the link statement) compares the text. A tokenId is a number: link.ts signs EIP-712 with `tokenId: BigInt(s.tokenId)` (type uint256) and ownerOf is read with the same number, so \"42\", \"042\" and \"0042\" are one NFT seat for the wallet and the chain and three seats for imd-flop. README.md line 4 (\"It gives one imd seat one FLOP identity\"), line 14 (\"Refuses a second key for a seat unless `--rotate`, which writes a signed revocation record\") and line 70 (\"One DID per seat. `init` refuses a second key for a seat.\") are not kept: `init --seat 042` after `init --seat 42` makes a second key and did:key, writes no revocation record, and the seat then has one daily cap and one daily total per spelling, so the seat's spend per UTC day is N times the cap with no PAST_ADMITTED row (the totals never meet). Values above 2^256-1 (78 nines) are accepted as seats too. Inside the stated promise in the sense that only this tool writes the ledger; the cap promise is per seat and the seat is split. Fix: canonicalise in validateSeat/isSeatId (reject a leading zero unless the id is \"0\", and reject values above 2^256-1), and have ledger.ts placed() treat a non canonical seat as not usable.","line":35,"path":"imd-flop/src/keys.ts","reproduction":"Offline, mock provider, fresh IMD_FLOP_HOME (ran as a vitest file beside the suite, using test/helpers.ts cli): `init --seat 42 --json` -> exit 0, did A. `init --seat 042 --json` -> expected exit 5 (second key for a seat); actual exit 0 with a different did B, and seats/42 and seats/042 both exist. `init --seat 42` again -> exit 5 as documented. Then for each of 42 and 042: `link --token <s> --agent 7`, `cap set 0.2 --seat <s>`. `run --job j1 --prompt hi --max-tokens 8 --seat 42 --json` -> exit 0 (charge 0.174). Same command again -> exit 3 `padded quote 0.174 FLOP is over the remaining daily cap 0.026`. `run ... --seat 042` -> expected exit 3 (seat 42 has 0.026 left today); actual exit 0, ledger row seat \"042\" spent_before 0, cap_charge 0.174. Seat 42 was charged 0.348 FLOP on a 0.2 FLOP/day cap and no row carries PAST_ADMITTED.","severity":"medium","snippet":"  return typeof seat === \"string\" && (seat === UNASSIGNED || /^[0-9]{1,78}$/.test(seat));","title":"Seat ids are compared as text, so 42 and 042 are two seats: a second key, DID, cap and daily total for one NFT seat, with no --rotate and no revocation"},{"citation":"resolved","description":"sumCharges adds cap charges as doubles (`sum += row.cap_charge`) and rounds once at the end (ceil6). knownAmount/usableLimit accept limits and charges up to AMOUNT_MAX = 2^53-1, but a double only resolves the 1e-6 grid up to about 2^33 FLOP. Once the running sum S is large enough that ulp(S)/2 exceeds a charge, `S + charge === S`: the charge is counted as nothing, on every later read, for the daily total (spentToday) and the job total (jobSpent) alike. A 0.000001 charge vanishes from S >= 2^34 (1.7e10 FLOP), a 0.001 charge from about 1.8e13, a 0.186 charge from 2^51. Smaller drift is also possible below that: each addition can lose up to ulp(S)/2, e.g. base 65536 plus 300000 charges of 0.000003 totals 65536.899999 against a true 65536.9. From 2^33 FLOP (8.6e9) a double no longer resolves neighbouring grid amounts, so roomLeft/checkBudget admit a padded quote that is 0.000001 over the room (random search over grid triples with cap = spent + padded - 0.000001: 0 of 300000 admitted with spent below 2^33, 65463 of 300000 with spent between 2^33 and 2^34, e.g. cap 14541661521.487915, spent 14541661520.477258, padded 1.010658). README.md line 81 and rules.json R-g say \"For charges and caps on the 6 decimal grid, rounding never admits a call over the cap\" with a residual of at most 1e-12 FLOP; here every row is on the grid, the ledger is written only by this tool, and the overrun is unbounded. Magnitudes are far above devnet use, which is why this is low, but `cap set` explicitly accepts them. Fix: add in integer micro units (BigInt(Math.round(charge * 1e6)), exact for grid charges) and compare in the same units, or refuse a limit or total above the largest value the grid is exact for, until money stage 2.","line":177,"path":"imd-flop/src/ledger.ts","reproduction":"Offline, tool-written ledger only (ran as a vitest file using test/helpers.ts cli and readySeat with a stub Provider named mock whose estimate returns the quotes below, complete returns usage without cost_flop, readReceipt returns state estimated). `cap set 4503599627370507`. Run 1: estimate {estimated_cost_flop: 4503599627370496, estimated_input_tokens: 4503599627370496, estimated_output_tokens: 4503599627370496} -> padded and charged 4503599627370506, so 1 FLOP of the day is left. Runs 2 to 9: estimate {estimated_cost_flop: 0.066, estimated_input_tokens: 3, estimated_output_tokens: 8} -> padded 0.186 each. Expected: runs 2 to 6 admitted (0.93 FLOP), run 7 refused with exit 3. Actual: all 9 runs exit 0 and 9 paid calls are made; every sent row from run 2 on records spent_before 4503599627370506; `status --json` shows today_utc.mock 4503599627370506, cap.daily 4503599627370507, over_cap_today_utc.mock false; no ledger row carries PAST_ADMITTED. The latest rows charge 4503599627370506 + 8 x 0.186 = ...507.488 FLOP against a cap of ...507, and every further 0.186 call is admitted the same way. The same happens with `cap set 17179869185`, a first charge of 17179869184 and any number of 0.000001 charges (python: 17179869184.0 + 1e-06 == 17179869184.0).","severity":"low","snippet":"    sum += row.cap_charge;","title":"The day total and the job total are float sums: above about 2^34 FLOP a charge on the 6 decimal grid is absorbed, so calls are admitted past the cap with no PAST_ADMITTED (inside the promise; breaks t"},{"citation":"resolved","description":"imd-flop admits on `padded > floor6(cap - spent)` (policy.ts roomLeft, checkBudget) and status reports over only on `spent > cap.daily`. The viewer decides the same row with one more float subtraction, floor6(cap - spent - cap_charge) < 0. Both use the 1e-12 tolerance, but they apply it to different quantities: imd-flop to (cap - spent) * 1e6, a number near the charge in micro units whose own ulp is about 1e-6 once the charge is above about 4000 FLOP, the viewer to a number near 0. For a cap of 8192 FLOP or more the float noise of cap - spent is above 1e-12, so for a row that fills the cap exactly the viewer gets about -1.4e-12, takes it for off the grid and floors it to -0.000001, while imd-flop's product lands within the tolerance and the call is admitted. The row is then marked bad with the spend flag 'over the daily cap' (and with a settled receipt its kind is over_limit, an amber chip), for a ledger only this tool wrote and a day that `status` shows as exactly at its cap with over_cap false. README.md line 81 says the tolerance means 'float noise does not move an amount a whole step'; it does here, as rounding.ts says for amounts above 8192 FLOP, but only in one of the two readers. Random search over grid triples with cap = spent + charge: 0 of 200000 differ with amounts up to 1000 FLOP, the first cases have a cap just above 8192 FLOP (36 of 200000 with spent and charge up to 5000 each, 284 up to 8192 each), and about 1 percent differ from 20000 FLOP up. Outside the cap promise (it is a viewer verdict), same ledger, two verdicts. Fix: have the viewer use imd-flop's form (left = floor6(cap - spent), over when cap_charge > left), or do both in integer micro units, and add the triple below to contract/vectors/cap-compare.json for both parts.","line":218,"path":"viewer/build-lane.py","reproduction":"Offline. Stub mock provider through the CLI (test/helpers.ts): `cap set 8197.132493`; run 1 estimate {estimated_cost_flop: 4169.158418, estimated_input_tokens: 4503599627370496, estimated_output_tokens: 4503599627370496} -> padded 4169.158419; run 2 estimate {estimated_cost_flop: 4027.974073, same token counts} -> padded 4027.974074. imd-flop: both runs exit 0; row 2 records daily_cap 8197.132493, spent_before 4169.158419, cap_charge 4027.974074, reason null; policy.checkBudget({padded: 4027.974074, cap: {daily: 8197.132493, perCall: 8197.132493}, payer: 'holder', spentBefore: 4169.158419}) returns null; `status --json`: today_utc.mock 8197.132493, over_cap_today_utc.mock false. Viewer on that ledger.jsonl (importlib load of viewer/build-lane.py, read_ledger, spend_limit, limit_flags, build_row): row 2 daily_left = -1e-06, limit_flags = ['over the daily cap'], build_row(...)['spend_flags'] = ['over the daily cap']. Expected: daily cap left 0 and no flag, as imd-flop decided. Python one-liner for the number: 8197.132493 - 4169.158419 - 4027.974074 is negative by more than 1e-12, and floor6 of it is -1e-06.","severity":"low","snippet":"        applied.append((lim, floor6(cap - spent - cap_charge) if None not in (cap, spent, cap_charge) else None))","title":"Viewer and imd-flop round the same at-cap row differently: a call imd-flop admitted exactly at the daily cap is flagged 'over the daily cap' in the lane"},{"citation":"resolved","description":"The ledger text contract says a non blank line is one JSON value in JSON.parse's grammar. Node's JSON.parse reads 100000 (and 2000000) nested arrays without error; Python's json.loads raises RecursionError long before that (it read 5000 levels and failed at 100000 on Python 3.12.3), and loads() turns that into ValueError, so read_ledger_text reports the line as unreadable 'not valid JSON'. The grammar has no depth limit, so the two readers cut the same bytes into different kinds, and since the latest line of a request wins, they disagree on the request's state and charge: imd-flop counts the deep line, the viewer shows the line before it plus a red unreadable row. No fixture in contract/fixtures/ledger-text/ covers depth. The tool never writes such a line, so this is outside the cap promise (an edited ledger), but it is exactly the reader split item 4 asks about, and imd-flop does not fail closed on it. Fix: state a depth limit in LEDGER_TEXT (for example 64) and enforce it in both readers (ledger-text.ts: reject after parse by walking the value; viewer: same walk, and keep the RecursionError branch), with a fixture on each side of the limit.","line":104,"path":"viewer/build-lane.py","reproduction":"Offline. Take a ledger.jsonl with two requests whose latest rows charge 4169.158419 and 4027.974074 (the ledger of the finding above), and append one line: the latest row of request 2 with state 'refused_local', cap_charge 0, reason 'edited' and an extra field \"x\" holding 100000 nested empty arrays ('[' x 100000 + ']' x 100000). imd-flop: readLedgerText gives lines 1 to 5 all kind 'row', and spentToday(h, seat, 'mock', day) = 4169.158419 (request 2 now counts 0). Viewer: read_ledger_text gives line 5 ('unreadable', 'not valid JSON'); read_ledger returns latest rows [('estimated', 4169.158419), ('estimated', 4027.974074)] plus a bad row 'ledger.jsonl line 5: not valid JSON'. Expected: both readers give the same kind for line 5. Minimal check without the repo: python3 -c \"import json; json.loads('['*100000+']'*100000)\" raises RecursionError; node -e \"JSON.parse('['.repeat(100000)+']'.repeat(100000))\" returns.","severity":"low","snippet":"    except RecursionError as ex:","title":"A ledger line with deep JSON nesting is a row for imd-flop and 'not valid JSON' for the viewer, so the two take different latest rows for the same request"},{"citation":"resolved","description":"refuse() builds its row from `base`, whose `day` and `ts` were taken before the estimate (run.ts line 151). lockedCheck reads the day again inside the lock (line 209, 'The UTC day is read once, inside the lock, and used for both the cap check and the rows') and computes the totals for that day, but when it refuses it calls refuse(), so the refused_local row carries the old day beside spent_before (and a reason) worked out for the new day. Across 00:00 UTC the ledger then holds a row dated day D whose spent_before is day D+1's total, and the viewer computes that row's 'daily cap left' from the two. The charge is 0, so no total moves: this is a record that contradicts itself and the comment, not a cap break. Related test gap (item 8): replacing `const day = utcDay();` in lockedCheck by `const day = base.day;` (the check and the sent row use the day from before the estimate, the opposite of the stated rule) passes all of npm test: the same 4 root-only failures as the unmodified tree, nothing else. So nothing in the suite, the stress blocks included, exercises a UTC day change between the start of `run` and its locked check. Fix: pass the locked day to refuse() (`{ ...base, day, ... }`), and add a test with a faked clock that crosses midnight inside estimate().","line":171,"path":"imd-flop/src/run.ts","reproduction":"Offline, vitest with vi.useFakeTimers({toFake:['Date']}) and a stub mock provider. Clock 2026-11-11T23:50:00Z: readySeat cap 1, then `cap set 1 --per-call 0.5`; run with estimate {0.066, 3, 8} -> exit 0, charge 0.186 on day 2026-11-11. Clock 2026-11-11T23:59:59Z: run with a provider whose estimate() first sets the clock to 2026-11-12T00:00:01Z and returns {estimated_cost_flop: 0.3, estimated_input_tokens: 3, estimated_output_tokens: 8} (pads to 0.845455) -> exit 3 'padded quote 0.845455 FLOP is over the per-call cap 0.5'. The row it appends: {ts: '2026-11-11T23:59:59.000Z', day: '2026-11-11', state: 'refused_local', quote_padded: 0.845455, cap_charge: 0, spent_before: 0}. Expected: day '2026-11-12' (the day the check ran against, whose total is 0), or spent_before 0.186 (the total of 2026-11-11). Actual: day 2026-11-11 with spent_before 0.","severity":"low","snippet":"    appendLedger(h, { ...base, request_id: null, quote_raw: q.raw, quote_padded: q.padded, ...recorded(spent), state: \"refused_local\", reason, spend: null, spend_raw: null, cost_reported: null, cap_charge: 0, http: null });","title":"A refusal written from the locked cap check is filed under the UTC day read before the estimate, not the day the check used; no test pins which day the locked check reads"},{"citation":"resolved","description":"settlement.ts exactAfter keeps the higher of two exact amounts with `BigInt(incoming) > BigInt(known)`. Replacing that by the string comparison `incoming > known` (so \"10\" is below \"9\") changes no test result: npm test gives the same 4 failures as the unmodified tree (the 4 that need a non root user) and nothing else. The grid builds the raw values of a row and of a read from the same table (RAW_OF[spend] and the same with two leading zeros), so whenever the float spends are equal the exact amounts are equal too, and the 'a higher one replaces a lower one' branch is only ever asked with equal values or an unknown one. contract/vectors/settlement-run.json and settlement-refresh.json do not cover it either. In all 47 single line wrong rules were run against the full suite; 37 were caught and 10 survived. Caught, among others ( the pastAdmitted comparisons, the hold floor max, receipt_seen, the carry, the seed mark, the exact pair rules, ceil6/floor6 swaps and tolerances, AMOUNT_MAX and 2^128 bounds, MIN_CAP, the job total's payer filter, the booking day, the lock window, the release token check, and the cap check or the booking step run without the lock, which the stress blocks catch). Survivors that matter are this one, the UTC day read before the lock (run.ts finding) and the three in the ledger.ts:260 finding. Five more survive but change nothing on a ledger only this tool writes or stay inside the 1e-12 tolerance: lowersSettlement without its SETTLES.has(prev.state) test, holdFloor returning the quote without Math.max(booked, quote) on a released row, sumCharges skipping refused_local rows, padQuote without its `padded < raw` test, and the tolerance test with < in place of <=. A mutant counted as caught only when a test other than the 4 root only ones failed; under parallel load decision-properties.test.ts and one lock test in audit-v6.test.ts each failed once for unrelated mutants (timeouts), so every verdict that rested on one such failure was run again alone. Six caught verdicts with several failing tests each were not run again (receipt_seen, the two raise rules, the released hold rule, decideFailure's unknown charge test, the booking step without the lock). Fix: add to readRaws/prevRaws a second exact amount for the same float spend with a different digit count (for example RAW_OF[x] and that value times 10 minus 1), and a vector with booked \"9\" and a read of \"10\".","line":533,"path":"imd-flop/test/decision-properties.test.ts","reproduction":"Mutant: in imd-flop/src/settlement.ts line 211 replace `BigInt(incoming) > BigInt(known)` by `incoming > known`, then npm test: 738 passed, 4 failed, the same 4 as without the change when run as root (booking.test.ts:709, review-hold-2.test.ts:192 and the other two read only file tests). Concrete input the suite never asks: decideBooking({state: 'settled', spend: 0.05, cap_charge: 0.186, quote_padded: 0.186, spend_raw: '9'}, {fresh: true, identityOk: true, redacted: false, state: 'settled', settled: 0.05, settledRaw: '10'}, null). Correct code books spend_raw '10' (the exact spend only rises); the mutant keeps '9'.","severity":"low","snippet":"  const prevRaws = (prev: Row): unknown[] => [ABSENT, null, 5e16, ...(settles(prev.state) ? (known(prev.spend) ? [RAW_OF[prev.spend!], \"00\" + RAW_OF[prev.spend!]] : [RAW_ONLY]) : [])];\n  const readRaws = (read: ReadOutcome): unknown[] => [ABSENT, null, \"0x10\", ...(known(read.settled) ? [RAW_OF[read.settled as number], \"00\" + RAW_OF[read.settled as number]] : [RAW_ONLY])];","title":"Test gap: comparing exact amounts as text instead of as integers passes all 742 tests (decision-properties grid has no two exact amounts of different length beside one float spend)"},{"citation":"resolved","description":"reclaimIfDead reclaims an old lock only when provablyDead: same host name and process.kill(pid, 0) gives ESRCH. Two ordinary crash cases never satisfy that, and a dead lock holder is inside the promise. (1) The pid is in use again: after a reboot or in a container pids are low and repeat, and when the CLI is the container's entrypoint the next `run` has the same pid as the dead holder, so it probes itself, finds the pid alive and waits for its own ghost. A lock that records this process's own pid with another token cannot have a live owner in this pid namespace, yet it is treated as live. (2) The host name changed (a container started again gets a new one): README.md line 47 says such a lock 'is never reclaimed' and stops there. In both cases every `run` exits 10 after 5 s and every command that needs the lock fails, for ever, with `ledger lock timeout: <path> (<n> s old) is held`: the message calls a dead holder's lock held, and neither it nor the README says the way back (remove the lock file once no imd-flop process is running on that home). It fails closed, so the cap holds; the defect is the lock out with no stated recovery. On the 30 s window (item 2): for a lock with a readable owner on this host in the same pid namespace, ESRCH is proof at any age, since pid reuse can only make a dead holder look alive and never a live one dead; the window adds safety only where that proof is unsound (two pid namespaces sharing one host name and one home, where a live holder's pid is invisible) and for ownerless leftovers of older versions, and there 30 s is not a proof either, only longer than any synchronous hold. So the window for a lock with a readable, provably dead owner can be cut to about the waiter's own 5 s timeout (the hold is one synchronous read and append) without new risk in the single namespace case, and the ownerless case should keep 30 s. Lowering it to 0 is caught by two tests in review-hold-2.test.ts (the killed writer stress block and the young empty lock test). Fix: treat `owner.pid === process.pid && owner.token !== mine` as dead, record the process start time (or boot id) beside the pid and compare it, and add the recovery sentence to the timeout message and the README.","line":351,"path":"imd-flop/src/ledger.ts","reproduction":"Offline, mock provider (vitest file using test/helpers.ts). Case 1: readySeat, then write <home>/ledger.jsonl.lock as {\"pid\": <the test process's own pid>, \"host\": os.hostname(), \"token\": \"dead\"} and set its mtime one hour back (utimes). `run --job j1 --prompt hi --seat 9001 --json` -> exit 10 after 5.0 s: `refused locally, nothing sent: ledger lock timeout: <home>/ledger.jsonl.lock (3605 s old) is held`; the lock file is still there afterwards and every later run does the same. Expected: the lock is reclaimed (no live process can own it), or the message says how to recover. Case 2: same with {\"pid\": 999999, \"host\": \"old-container-7f3a\"} -> the same exit 10 and message, lock still there. Control: {\"pid\": 999999, \"host\": os.hostname()} one hour old -> reclaimed, run exits 0 in 18 ms.","severity":"low","snippet":"      if (Date.now() - start > 5_000) throw new LedgerLockError(`ledger lock timeout: ${lockAge(path)} is held`);","title":"A lock whose recorded owner can never be proven dead locks the seat out for good, and the message only says the lock 'is held'"},{"citation":"resolved","description":"Three wrong rules, each a one line change, leave npm test with the same 4 root only failures as the unmodified tree. (1) jobSpent with a day filter added (`[onJob(job), onProvider(provider), onPayer(\"requester\"), onDay(utcDay())]`): the job budget then counts today's rows only, so a requester job gets its whole budget again after 00:00 UTC. README.md line 81 and the comment on jobSpent ('on any day') say otherwise, and item 1 asks for a job budget beside a UTC day change, but no test or stress block books a requester job on two days. (2) isUtcDay without its round trip (`utcDay(new Date(...)) === v`): V8 parses 2026-02-30T00:00:00Z as 1772409600000 (2 March), so the day '2026-02-30' becomes usable, differs from today, and its row is left out of the daily total without a word instead of refusing the next call; the viewer (strptime) still flags it, so the two parts would differ. contract/vectors/ledger-placement.json has no day that matches the pattern and is not a calendar date. (3) overDailyCap with >= (policy.ts line 204): `status` then prints OVER CAP for a day that is exactly at its cap, the case where the viewer already differs from imd-flop (build-lane.py:218 finding); every over_cap assertion in the suite is either clearly over or clearly under. Fix: a test with a faked clock that charges one requester job on day D and expects the refusal on day D+1; ledger-placement vectors for 2026-02-30, 2026-04-31 and 2025-02-29 (cannot be placed, in both parts); a status test with the total equal to the cap expecting over false, and the same row in the viewer vectors.","line":260,"path":"imd-flop/src/ledger.ts","reproduction":"Each mutant, then npm test in imd-flop (as root: 738 passed, 4 failed, the 4 read only file tests, identical to the unmodified tree; each was run alone, not under load). (1) imd-flop/src/ledger.ts line 260: add `, onDay(utcDay())` to the filter list. Input the suite lacks: payer requester, `--job-budget 0.2`, job j1 charged 0.186 on 2026-11-11; on 2026-11-12 `run --job j1 --job-budget 0.2` with a 0.186 quote must exit 3 (0.014 left); the mutant admits it. (2) ledger.ts line 184: delete ` && utcDay(new Date(`${v}T00:00:00Z`)) === v`. Input the suite lacks: a latest row of this seat and provider with day '2026-02-30' and cap_charge 0.186: correct code refuses the next run with exit 3 naming the line (day is not a usable value), the mutant reads it as another day and admits. node -e \"console.log(Date.parse('2026-02-30T00:00:00Z'))\" prints 1772409600000. (3) policy.ts line 204: `spent >= cap.daily`. Input the suite lacks: `cap set 0.186`, one mock call charged 0.186, `status --json` must show over_cap_today_utc.mock false; the mutant shows true.","severity":"low","snippet":"  return sumCharges(placed(h, [onJob(job), onProvider(provider), onPayer(\"requester\")], `the requester total of job ${job} (${provider})`));","title":"Test gap: a job budget that resets every UTC day, an impossible calendar day read as usable, and 'over' at exactly the cap all pass the whole suite"}],"hash":"a76d07cf875e5da535b283298616dd0ec85628b1efb48916efb92dcaedef9c3a","nodeId":"15ddcf36-8b2e-4b2b-abe6-951a0fe22f9e","outcome":"completed","summary":"The audit is written to `.imd-findings.json`: 8 findings, one medium and seven low, each with file, line, an exact snippet and an offline reproduction. No tracked file was changed; everything ran in a temporary copy that I removed afterwards.\n\nThe cap promise itself held in every dynamic check I ran. The one inside-the-promise break (the float totals) needs a daily total above about 1.7e10 FLOP.\n\n## Findings\n\n| # | Severity | Where | What | Promise |\n|---|---|---|---|---|\n| 1 | medium | `imd-flop/src/keys.ts:35` | Seat ids are compared as text, so `42` and `042` are two seats. `init --seat 042` after `init --seat 42` exits 0 with a second DID and no revocation; each spelling gets its own cap and daily total. Seat 42 was charged 0.348 on a 0.2 cap with no PAST_ADMITTED row. | Inside (tool-written ledger; the seat is split) |\n| 2 | low | `imd-flop/src/ledger.ts:177` | The day and job totals are float sums. With a cap of 4503599627370507 and a first charge of …506, nine runs all exit 0, `spent_before` never moves, `over_cap` stays false, and no row carries PAST_ADMITTED. A 0.000001 charge vanishes from totals of 2^34 FLOP. | Inside; breaks the R-g sentence |\n| 3 | low | `viewer/build-lane.py:218` | A call imd-flop admits exactly at the cap is flagged \"over the daily cap\" by the viewer. Cap 8197.132493, spent 4169.158419, charge 4027.974074: imd-flop admits and `status` says not over; the viewer computes −0.000001 left. First cases appear with caps just above 8192 FLOP. | Outside (viewer verdict) |\n| 4 | low | `viewer/build-lane.py:104` | A ledger line holding 100000 nested arrays is a row for imd-flop and \"not valid JSON\" for the viewer, so they take different latest rows for the same request. | Outside (edited ledger) |\n| 5 | low | `imd-flop/src/run.ts:171` | A refusal written from the locked cap check carries the UTC day read before the estimate, beside a `spent_before` for the new day. The charge is 0, so no total moves. | Inside; record only |\n| 6 | low | `imd-flop/src/ledger.ts:351` | A lock whose owner can never be proven dead locks the seat out for good: a reused pid (including the waiter's own) or a changed host name. Every `run` exits 10 with \"… is held\"; neither the message nor the README says to remove the file. Fails closed. | Inside (dead lock holder) |\n| 7 | low | `imd-flop/test/decision-properties.test.ts:533` | Test gap: comparing exact amounts as text (`\"10\"` below `\"9\"`) passes all 742 tests. | — |\n| 8 | low | `imd-flop/src/ledger.ts:260` | Test gap: three wrong rules pass the whole suite: a job budget that resets each UTC day, day `2026-02-30` read as usable, and \"over\" at exactly the cap. | — |\n\n## Your questions\n\n- **Hold floor and PAST_ADMITTED (item 1):** I found no sequence that breaks it. A seeded random walk through the real CLI (22,400 operations over 1,700 homes) found no total over its limit without the mark, no charge lowered other than the one release, and no lost mark. It mixed runs, refreshes, timeouts, gateway errors, pre-send failures, refreshes during a call, payer switches, a job budget and UTC day changes. It did not cover a moved ledger (seed row) or crash injection.\n- **The 30 s window (item 2):** it can be cut to about the waiter's own 5 s for a lock with a readable owner on this host whose pid is gone. That proof does not depend on age, because pid reuse can only make a dead holder look alive. Keep 30 s for ownerless locks. The window only adds safety where two pid namespaces share a host name and a home, and there 30 s is no proof either. Lowering it to 0 is caught by two tests.\n- **Tests (item 8):** of 47 one-line wrong rules, 37 were caught and 10 survived. Five survivors matter (findings 5, 7 and 8); the other five change nothing on a tool-written ledger or stay inside the 1e-12 tolerance. Removing the lock around the cap check or the booking step is caught by the stress blocks. Six \"caught\" verdicts came from loaded parallel runs and were not re-run alone; each had several failing te","treeHash":null,"usage":{"cachedInputTokens":5320533,"cost":{"source":"runtime","usdMicros":10210570},"inputTokens":55,"model":"claude-fable-5-2","outputTokens":117306,"runtime":"claude","turns":45,"wallClockMs":2707372}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"ce319efac2b76da0","findings":[{"citation":"resolved","description":"Inside the promise (a dead lock holder). takeLock (ledger.ts:337-355) lets every waiter on a lock older than 30 s call reclaimIfDead. reclaimIfDead reads the owner (line 291), judges it dead, and only then renames the path (line 293). The read and the rename are two steps. Between them a second waiter W2 can complete the same reclaim and create its own live lock at the path (createLock, line 342). W1's rename then moves W2's LIVE lock aside. From that moment the path is empty while W2 is inside withLedgerLock. Two outcomes, both reproduced in imd-flop/test/scratch/lock-reclaim-race.test.ts (it forces the interleaving by wrapping fs.renameSync for W1's one call; everything else is the real code):\n(A) A third process W3 creates its lock at the empty path and enters: W2 and W3 run their critical sections at once. For two `run`s this is two cap checks (run.ts lockedCheck) against the same spentToday, so both are admitted into the same room and both append `sent` rows: the day's total ends above the cap and no row carries PAST_ADMITTED. W1 then fails its link-back with EEXIST and throws `ledger lock contested`, but W2 and W3 have already proceeded.\n(B) W2 finishes while the path is empty: releaseLock (line 358) reads the path, finds no owner, and does nothing. W1 then links W2's lock back into place (line 295). The path now holds a lock whose owner already left it, with a live pid. provablyDead is false while that process lives (a long lived worker or refresher, or the stress harness), so reclaim never happens; every later command on the seat exits 10 (`run`) or 1 with `ledger lock timeout: <path> (<n> s old) is held`, which names a holder that does not exist. The message explains no way back. A process that exits frees it only 30 s after its creation mtime.\nFix: never move a lock you have not verified to be the one you judged. Open the path, read the owner and fstat its inode from the same fd, then rename and compare the inode of the moved file with the one judged (or compare tokens before and after the rename); if they differ, link it back and retry. Closing the window fully needs an atomic conditional operation: either reclaim with an exchange rename (renameat2 RENAME_EXCHANGE via a tiny native helper), or make every holder re-check that the path still holds its own token (readOwner(path)?.token === token) immediately before its append and refuse (LedgerLockError) when it does not, so a holder whose lock was moved never writes. Both together are the safe form.","line":293,"path":"imd-flop/src/ledger.ts","reproduction":"Offline. From imd-flop: `npx vitest run test/scratch/lock-reclaim-race.test.ts`. The test seeds a lock file `{pid:<dead pid>, host:<this host>, token:'dead-token'}` with mtime 120 s ago, and for W1's one renameSync call replaces the path with a live lock `{pid: process.pid, host, token:'w2-live-token'}` just before the rename (W2 reclaimed and created in between) and asserts, just after it, that the lock path does not exist while W2 is still 'inside' (variant A's window). The test then lets the real code continue: readOwner(aside) is alive, linkSync puts W2's lock back, W1 throws LedgerLockError. Expected: a lock that a live holder created and released is gone, and the next withLedgerLock succeeds. Actual (test passes = defect present): the path holds `{token:'w2-live-token', pid:<live>}`; after setting its mtime 120 s back, `withLedgerLock(h, () => 1)` still throws LedgerLockError whose message matches /is held/ though no process is inside the lock. Variant A follows from the same window: any createLock in it succeeds (the path is absent) while W2 holds the lock.","severity":"high","snippet":"  try { renameSync(path, aside); } catch { return false; } // someone else moved or released it","title":"reclaimIfDead renames whatever is at the lock path after judging a stale owner, so a lock another waiter reclaimed and re-created is moved aside while live: two holders at once, or a seat locked out w"},{"citation":"resolved","description":"Outside the cap promise (secrets, item 6). run.ts:247 builds the ledger `reason` from err.message with every `0x<64 hex>` replaced by `[hash]`, and line 250 runs the secret scan on that stripped text. When the stripped text passes, line 267 prints the UNstripped err.message on stderr. So a value the tool's own rule calls key-shaped (secrets.ts:52 HEX64: a 0x-prefixed run of 64 or more hex digits) is refused for the ledger row yet reaches stderr. The strip exists for the genesis hashes in PaidGateError messages, which are public, but it is applied to every PreSendError, including `child key mint failed (<name>: <message>)` (gateway.ts:66), whose message is whatever the transport or the gateway echoed. Expected: what stderr prints is what the scan passed (the stripped `reason`), or the message is withheld as line 251 does for other hits. Actual: stderr shows the raw value. Fix: print `reason` (already stripped and scanned) instead of err.message on line 267, or scan err.message itself and withhold on a hit.","line":267,"path":"imd-flop/src/run.ts","reproduction":"Offline, with a provider stub in a vitest test (as test/presend-abort.test.ts builds one): make complete() throw `new PreSendError('child key mint failed (TypeError: 0x' + 'ab'.repeat(32) + ')')` and run `runJob` through the CLI with stderr captured. Expected: stderr carries `[hash]` or a withheld line. Actual: stderr carries `nothing sent: child key mint failed (TypeError: 0xabab...ab)` with the full 64-hex value, while the appended refused_local row's reason reads `nothing sent: child key mint failed (TypeError: [hash])`; the scan, applied to the printed text, would have flagged it as `64-or-more-hex (key-shaped) value`.","severity":"low","snippet":"    if (preSend) throw new CliError(`${preSendWithheld ? reason : `nothing sent: ${err.message}`}${kept ? \".\" + also : \"\"}`, e instanceof PaidGateError ? EXIT.REFUSED_RULE : EXIT.NOT_SENT);","title":"A 0x-prefixed 64-hex value in a pre-send provider error is stripped before the ledger scan but printed raw on stderr"},{"citation":"resolved","description":"Outside the cap promise (item 5: an unknown amount counted as 0). reportedSpendToday adds 0 for every placed row whose `spend` is not a known amount (null, missing, negative, non-finite, above AMOUNT_MAX). The README (line 20) presents `gateway_spent_today_utc` as 'what the gateway reported as spent today', and the README's own amount rule (line 86) says an unknown amount 'shows blank, never 0'. The figure cannot tell a day where the gateway reported 0 for a row from a day where it reported nothing, and it shows nothing beside it (no count of rows with unknown spend, no null). The cap is unaffected: spentToday uses cap_charge and refuses on an unusable one. Fix: return null (unknown) when any placed row of the day has an unknown spend, or report the number of such rows beside the sum, as the README's blank-never-0 rule for amounts does elsewhere.","line":254,"path":"imd-flop/src/ledger.ts","reproduction":"Offline. In a fresh IMD_FLOP_HOME with a seat, link bypass and a cap, run two mock calls (`run --job j1 ... --mock-scenario settled` books spend 2.x) then append through the normal path a second request whose receipt read is a settled state with `settled_spend: null` (the mock `settled` scenario with a receipt stub returning null, or `receipt --refresh` with a stub). `status --json` then prints `gateway_spent_today_utc` equal to the first row's spend alone: the same value as a day where the second call settled at exactly 0. Expected per the README's amount rule: an unknown figure, or a marker that one row's spend is unknown. Actual: a plain number that understates the day.","severity":"low","snippet":"  for (const { row } of seatDay(h, seat, provider, day)) sum += knownAmount(row.spend) ?? 0;","title":"status's gateway_spent_today_utc counts a row whose settled spend is unknown as 0, and nothing in the figure says a spend was unknown"},{"citation":"resolved","description":"Answer to item 2's question. A holder that dies inside the lock blocks every command on the seat for up to 30 s: each waiter gives up after 5 s (line 351) and exits 10 (`run`) or 1, and only a waiter arriving after the lock is 30 s old reclaims it. Since review fixes 4 the lock is created with its owner already in it (createLock, lines 327-335), so a lock this tool wrote is never empty for an instant; and provablyDead (lines 279-283) is conclusive on the same host and pid namespace (ESRCH), with pid reuse only making a dead holder look alive, which is the safe direction. So for an owner that is readable, on this host and ESRCH, the age test adds nothing and the reclaim could run at once. The age test must stay for a lock with no readable owner (a leftover from an older version or damage) and for a foreign host, which is never reclaimed. Two cautions: (1) reclaiming sooner means more concurrent reclaim attempts, which widens the window of the rename race reported above, so close that first; (2) two containers that share the ledger volume and the same hostname but not a pid namespace would see each other's live pids as ESRCH, and neither the age nor the pid test protects there; a lock token re-check before every append (see the fix above) is the guard that does.","line":349,"path":"imd-flop/src/ledger.ts","reproduction":"Offline, timing only. Create `<IMD_FLOP_HOME>/ledger.jsonl.lock` holding `{\"pid\":<a pid that does not exist>,\"host\":\"<hostname>\",\"token\":\"x\"}` with a fresh mtime. `imd-flop status` exits 1 after 5 s with `ledger lock timeout: ... (5 s old) is held`; repeated every few seconds it keeps failing until the file is 30 s old, then succeeds. The owner was provably dead from the first second.","severity":"info","snippet":"      try { old = Date.now() - statSync(path).mtimeMs > 30_000; } catch { continue; } // the holder removed it","title":"The 30 s reclaim window after a dead holder can be dropped for a provably dead same-host owner, but only once the reclaim race above is closed"}],"hash":"fe149ef1b8f16ac76fedd09598191e1c9231af023c360e5f7ed81a1f6678a182","nodeId":"d4154ada-cc12-418a-bae9-5bceda067580","outcome":"completed","summary":"The review is complete and the findings file holds four substantiated findings.\n\n**Checks run**\n- imd-flop: `npm ci` then the full suite, 742 of 742 pass when run with `dac_override` dropped via `setpriv` and a longer test timeout. As plain root, 4 tests fail on the read-only-file assumption and 1 property test times out at 20 s. The machine would not switch uid.\n- spend-guard 66 of 66 and viewer 135 of 135 pass.\n- Two settlement-rule mutants in a scratch copy, one dropping the `receipt_seen` guard in decideFailure and one making the hold come back only on an unredacted read, were both killed by the property and stress tests. Sources were not touched.\n- One new reproduction, `imd-flop/test/scratch/lock-reclaim-race.test.ts`, forces the lock interleaving below and confirms the defect.\n\n**Findings written to .imd-findings.json**\n- **High, inside the promise.** `reclaimIfDead` in ledger.ts judges a stale owner and then renames whatever is at the lock path. A second waiter can reclaim and create its own live lock in between, so the first waiter moves a live lock aside. In that window a third process enters too, giving two concurrent cap checks with no PAST_ADMITTED row. If the live holder releases inside the window, its lock is linked back with a live pid and the seat stays locked out with a \"is held\" message that names no real holder.\n- **Low.** A pre-send provider error prints its raw message on stderr while the ledger row only gets the 0x-64-hex-stripped, scanned version.\n- **Low.** The gateway-reported daily figure in status counts an unknown settled spend as 0 with no marker.\n- **Info.** The 30 s reclaim window can be dropped for a provably dead same-host owner, since locks are never created empty, but only after the race above is closed, and it stays for ownerless or foreign-host locks.\n\n**Read only, no defect found**\n- Hold floor, PAST_ADMITTED carry, seed rewrite, UTC day change, job budget beside the daily cap, the single release path, and rounding tolerance: no sequence found that raises a total without the mark or lowers a charge other than the release.\n- Placement and damaged-line refusals name the right line in every path traced.\n- Ledger text contract: the two readers agree on splitting, blanks, JSON constants, big integers and grouping. I did not do an exhaustive verdict-by-verdict comparison of the viewer against imd-flop beyond the shared vectors, so item 4 is covered in part.\n- Exit codes match the README table in every path I traced, including the documented exit 1 known gap.","treeHash":null,"usage":{"cachedInputTokens":1081435,"cost":{"source":"runtime","usdMicros":5242355},"inputTokens":418,"model":"claude-fable-5-1","outputTokens":41048,"runtime":"claude","turns":31,"wallClockMs":876443}}],"verification":[]}