# Audit report

> 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.
> Scope: 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.
> Work 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.
> Run 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).
> The 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.
> Look for:
> 1. 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.
> 2. 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.
> 3. 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.
> 4. 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.
> 5. 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.
> 6. 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.
> 7. 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.
> 8. 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).
> For 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.

| | |
|---|---|
| Repository | https://github.com/uxp-labs/flop-devnet-lab.git |
| Commit | `33f3b9f147f46a305fe189951289c5d58590a314` |
| Job | `b4e8e103-53a5-4c65-a67f-f10a5ea50542` |
| Judged | 2026-11-11 16:19 UTC |
| Findings | 1 high · 1 medium · 9 low · 2 info |

Four agents audited the code as it is at `33f3b9f`, each in one area (math, permissions, economics, control flow),
and a judge reproduced, merged and ranked what they found, then read the code once more itself. Nothing in the repository was changed or deployed.

## Findings

### 1. High: 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

`imd-flop/src/ledger.ts:293`

```
  try { renameSync(path, aside); } catch { return false; } // someone else moved or released it
```

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.

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

### 4. Medium: 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

`imd-flop/src/keys.ts:35`

```
  return typeof seat === "string" && (seat === UNASSIGNED || /^[0-9]{1,78}$/.test(seat));
```

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.

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

### 2. Low: 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

`imd-flop/src/store.ts:21`

```
  appendFileSync(path, JSON.stringify(obj) + "\n", { mode: 0o644 });
```

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.

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

### 3. Low: 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

`imd-flop/src/gateway.ts:261`

```
  if (j.error) throw new Error(`eth_chainId error: ${j.error.message ?? "rpc error"}`);
```

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.

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

### 5. Low: 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

`imd-flop/src/ledger.ts:177`

```
    sum += row.cap_charge;
```

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.

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

### 6. Low: 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

`imd-flop/src/run.ts:184`

```
      if (e instanceof UnusableLedgerRow) refuse(e.message, EXIT.REFUSED_CAP, quote, null);
```

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.

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

### 7. Low: 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

`imd-flop/src/ledger.ts:351`

```
      if (Date.now() - start > 5_000) throw new LedgerLockError(`ledger lock timeout: ${lockAge(path)} is held`);
```

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.

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

### 8. Low: Ledger text contract: a deeply nested line is a row to imd-flop and 'not valid JSON' to the viewer

`viewer/build-lane.py:104`

```
    except RecursionError as ex:
```

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.

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

### 9. Low: The viewer flags a row that imd-flop admitted exactly at the daily cap as 'over the daily cap' (different float rounding)

`viewer/build-lane.py:218`

```
        applied.append((lim, floor6(cap - spent - cap_charge) if None not in (cap, spent, cap_charge) else None))
```

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.

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

### 10. Low: A 0x-prefixed 64-hex value in a pre-send error is stripped for the ledger reason but printed raw on stderr

`imd-flop/src/run.ts:267`

```
    if (preSend) throw new CliError(`${preSendWithheld ? reason : `nothing sent: ${err.message}`}${kept ? "." + also : ""}`, e instanceof PaidGateError ? EXIT.REFUSED_RULE : EXIT.NOT_SENT);
```

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.

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

### 11. Low: 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)

`imd-flop/src/ledger.ts:294`

```
  if (reclaimable(readOwner(aside))) { try { unlinkSync(aside); } catch { /* gone */ } return true; }
```

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

**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)`.

### 12. Info: 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

`imd-flop/src/run.ts:171`

```
    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 });
```

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

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

### 13. Info: status gateway_spent_today_utc counts a row with unknown settled spend as 0, with no marker

`imd-flop/src/ledger.ts:254`

```
  for (const { row } of seatDay(h, seat, provider, day)) sum += knownAmount(row.spend) ?? 0;
```

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.

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

---

Judge's submission `78ea3bd692b8a0d9e14e3fb66797c0706ebe583f7f9a75697620ce312d387f82`, accepted on the IdentityMD network. Acceptance means the report met the job's checks;
it is not a guarantee that the code has no other defects.
