# Audit report

> TypeScript CLI
>
> Re-audit the `imd-flop` folder only, at commit 716819c. It is a follow-up to audit job e99a2cf1, so check whether each of those findings is fixed. Cover the ledger lock (exit 10 before the paid call, exit 1 after it), the spend cap (exit 3), refusal of async lock bodies, request_id attempts, and stdout withholding. Treat every other folder as out of scope. Work offline and read-only. Give each finding a file and line, its impact, and steps to reproduce it.

| | |
|---|---|
| Repository | https://github.com/uxp-labs/flop-devnet-lab.git |
| Commit | `716819c41f8c655d8305db2a382a6b4e356d9e80` |
| Job | `8a0a3abd-3706-4176-8ef5-7fc4f291e6e7` |
| Judged | 2026-11-10 15:20 UTC |
| Findings | 3 medium · 8 low · 4 info |

Four agents audited the code as it is at `716819c`, 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. Medium: config.json payer outside holder\|requester disables the daily cap and the job budget (fail-open) while status reports 0 spent

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

```
      const left = Math.round((o.jobBudget! - spentBefore) * 1e6) / 1e6;
```

loadConfig (imd-flop/src/config.ts:20) spreads config.json over DEFAULT_CONFIG without checking that payer is exactly "holder" or "requester". runJob only demands --job-budget when payer === "requester" (run.ts:159), and the cap branch at run.ts:214-221 treats every non-"holder" value as the requester case. With payer "sponsor" and no --job-budget, o.jobBudget is undefined, so left = NaN at run.ts:219 and `q.padded > NaN` is false: no refusal ever fires. Every ledger row then carries payer "sponsor", which spentToday (ledger.ts:74 filters payer === "holder") and jobSpent (ledger.ts:78 filters payer === "requester") both skip, so nothing counts against any cap and `status`/`cap show` print today's spend as 0 while calls keep going out. The sibling field caps[seat] fails closed for malformed input (run.ts:154-157, prior finding TS-L10); payer does not. Precondition: a writer of config.json other than `config set payer` (hand edit, a future writer, or the agent operating the CLI). Fix: validate payer in loadConfig or next to the cap check and refuse with EXIT.REFUSED_CAP when it is not exactly holder|requester; make the run.ts:218 branch `else if (cfg.payer === "requester")` with a final fail-closed refuse.

**Reproduction**

1. IMD_FLOP_HOME=$(mktemp -d); imd-flop init --seat 9001; imd-flop link --token 9001 --agent 7; imd-flop cap set 0.2. 2. Edit $IMD_FLOP_HOME/config.json: "payer": "sponsor". 3. Run three times with --job p1, p2, p3: imd-flop run --seat 9001 --job p1 --prompt 'Say hi' --max-tokens 8 --provider mock --json (each padded quote is 0.186 FLOP against a 0.2 FLOP/day cap). Expected: at most one run exits 0, the rest exit 3 'refused locally, nothing sent' (or all three exit 3 for a malformed payer). Actual at 716819c (vitest, specialist proof section A, re-run by the judge): exit codes [0, 0, 0], provider.complete called 3 times, all 6 ledger rows have payer "sponsor", `status --json` today_utc = {flop: 0, mock: 0}.

### 2. Medium: Pre-mint refusal whose error text trips the secret scan leaves the pre-call `sent` reservation as the latest ledger line: daily cap consumed with nothing sent, exit 5 instead of 1

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

```
    appendLedger(h, { ...sent, ts: new Date().toISOString(), state: timedOut ? "timeout_unknown" : preSend ? "refused_local" : "error", reason, spend: null, cap_charge: preSend ? 0 : sent.cap_charge, http });
```

When provider.complete() rejects with PreSendError (FlopProvider.complete wraps every assertPaidNetwork failure this way, providers.ts:139-143, and mintChildKey failures too), the catch block builds reason = `nothing sent: ${err.message}` with only `0x`+64-hex stripped (run.ts:249) and appends the refused_local row with cap_charge 0 (run.ts:250). appendLedger runs assertNoSecrets on the row. Any other 64-or-more hex run (HEX64 at secrets.ts:52 matches bare hex and decimal digit runs), a `Bearer ...` or `flop_ek_...` shaped substring in the message is not stripped. Those strings come from the RPC/faucet the recheck talks to (gateway.ts: `eth_chainId error: ${j.error.message}`, `rpc error: ...`, `Ethereum chain id ${chainId} ...`; a chain id of 0xff..ff prints as a 78-digit decimal). When the scan hits, appendLedger throws CliError(REFUSED_RULE) from inside the catch: the refused_local line is never written, the pre-call `sent` row (cap_charge = padded quote) stays the latest line for that request_id, and the CLI exits 5 'secret scan blocked writing ledger.jsonl' instead of 1 'nothing sent'. spentToday counts the phantom reservation for the rest of the UTC day, so the next run is refused with 'daily cap reached' although nothing was minted or sent. `receipt <id> --refresh` cannot clear it (no gateway receipt exists for an unminted request; cli.ts:442 keeps prev.cap_charge) and `status` lists it as pending. This breaks the README invariant for this path ('the latest ledger line is refused_local with cap charge 0'), which TS-L11 tests only with a clean message. Fix: build the ledger reason from a fixed vocabulary (e.g. `nothing sent: pre-send refusal`) or pass it through redactFlagged before the append, and if the append still throws, still write a minimal refused_local row and throw the EXIT.ERROR 'nothing sent' CliError; never let a scan failure keep the sent reservation live.

**Reproduction**

1. init --seat 9001; link --token 9001 --agent 7; cap set 0.186 (cap equals one padded quote). 2. Run with a provider whose complete() rejects with new PreSendError('eth_chainId error: ' + 'ee'.repeat(32)) (what FlopProvider.complete throws when ETH_RPC_URL answers eth_chainId with {"error":{"message":"<64 hex>"}} on the recheck; a chain id result 0x followed by 64 'f' gives the same via its 78-digit decimal): imd-flop run --seat 9001 --job m1 --prompt 'Say hi' --max-tokens 8 --provider mock --json. Expected: exit 1 'nothing sent', latest ledger row state refused_local cap_charge 0, and a following run --job m2 exits 0. Actual at 716819c (specialist proof section B and B2, re-run by the judge): exit 5 'imd-flop: secret scan blocked writing ledger.jsonl: reason: 64-or-more-hex (key-shaped) value'; ledger rows [['sent', 0.186]]; the following run exits 3 'refused locally, nothing sent: daily cap reached (0.186/0.186 FLOP today, UTC)' with complete() never called. Both the 64-hex and the 78-digit decimal variants reproduce.

### 3. Medium: receipt --refresh on a ledger-only attempt (after a timeout) writes a receipt without seat/did, so match is 'mismatch' forever while the settlement is still booked

`imd-flop/src/cli.ts:418`

```
            ? { ...saved, ...c, request_id: typeof rawId === "string" ? rawId : "", ...(identityOk ? {} : { settled: null, chain_final: false }) }
```

A timed-out attempt has a ledger row but no receipt file; the README says to check it later with `receipt <id> --refresh`. In that state `saved` is null, so the merged receipt at cli.ts:418 is built only from classifyReceipt, which carries no seat, did, ss58 or network. The file written at cli.ts:422 therefore lacks seat and did. matchReceipt (src/match.ts:30-31, 43-44) then reports fields seat, did, seat(link), did(link), and the attempt shows match=mismatch in `receipt`, `receipt --refresh` and `status`. Every later refresh spreads the same `saved` (still without seat/did), so the mismatch is permanent. Meanwhile the ledger booking at cli.ts:435-447 keys off identityOk alone, so the settled amount IS booked against a receipt whose match says mismatch, contradicting the documented rule that `matches` means the receipt agrees with its records. Operators are told to treat mismatch as a revoked/rotated-DID signal; a false alarm after every timeout either sends them chasing a non-issue or teaches them to ignore mismatches. The non-refresh path (cli.ts:458) does copy seat/did from the ledger row, so only the refresh path is wrong. Fix: when `saved` is null, seed seat, did, ss58 and network from ledgerRow (and mock_scenario for mock) before writing, as run.ts:297 does.

**Reproduction**

In a temp IMD_FLOP_HOME: imd-flop init --seat 9001; imd-flop link --token 9001 --agent 7; imd-flop cap set 5; imd-flop run --seat 9001 --job j1 --prompt 'Say hi' --max-tokens 8 --mock-scenario timeout --timeout-ms 50 (exit 6; stderr names the request_id; receipts/<id>.json does not exist). `imd-flop receipt <id> --json` prints match pending with seat 9001. Then `imd-flop receipt <id> --refresh --json --seat 9001` with a provider whose receipt is {request_id: <id>, state: settled, settled_spend: 0.05}. Expected: receipts/<id>.json carries seat 9001 and the seat's did and match is {matches, []}. Actual at 716819c (judge probe H): exit 0, file.seat undefined, file.did undefined, printed match {"match":"mismatch","fields":["seat","did","seat(link)","did(link)"]}, ledger latest row for <id> is state settled, spend 0.05, cap_charge 0.05 (booked despite the mismatch). A second --refresh and `status --json` show the same mismatch.

### 4. Low: Secret scan never inspects object keys, so a key-shaped JSON property name from the gateway is written to the response file unredacted

`imd-flop/src/secrets.ts:84`

```
      for (const [k, x] of Object.entries(v as Record<string, unknown>)) visit(x, path ? `${path}.${k}` : k, [...keys, k], k);
```

scanForSecrets visits only values. For an object it recurses into each value and passes the key as the field name, but the key string itself is never matched against the registry (loaded secrets), HEX64, FLOP_TOKEN, BEARER or PEM. A completion whose usage object (stored verbatim in the response record, run.ts:261) carries a 64-hex string, a flop_ek_ token, a Bearer string or a loaded secret as a property name with a harmless value passes writeJsonSafe and lands in responses/<id>.json (mode 0644); redactFlagged cannot clear it either because setNull only nulls values. The README's rule 1 says 'any 64-hex value' and 'flop_ek_... tokens' are rejected before anything touches disk, and the gateway (which knows the child token it minted) is the party whose output this scan exists to filter. stdout is unaffected (run --json does not print usage). Fix: in visit(), also run the string checks on each key k (treat it as a string value under field name k), and in redactFlagged delete or rename a flagged key. Merged from three specialist reports of the same defect.

**Reproduction**

Ready a seat (init --seat 9001; link --token 9001 --agent 7; cap set 5). Use a provider whose complete() returns usage { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2, ['ee'.repeat(32)]: 1 } (or the key 'flop_ek_SYNTHETICNOTAKEY0123456789'). Run: imd-flop run --seat 9001 --job k1 --prompt 'Say hi' --max-tokens 8 --provider mock --json. Expected: the response file does not contain the 64-hex / flop_ek_ string (withheld or redacted) and `withheld` is set. Actual at 716819c (specialist proof sections C and C2, re-run by the judge): exit 0, `withheld` undefined, readFileSync(response_file).includes('ee'.repeat(32)) === true; same for the flop_ek_ key. Unit form: scanForSecrets({ usage: { ['ab'.repeat(32)]: 1 } }) returns [].

### 5. Low: Lock file is created and its owner written in two steps; a crash between them leaves a zero-byte lock that is never reclaimed, so every later run exits 10 until it is deleted by hand

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

```
      writeSync(fd, JSON.stringify(owner));
```

takeLock creates ledger.jsonl.lock with openSync(path, 'wx') (ledger.ts:122) and only then writes the owner record {pid, host, token} (ledger.ts:123), with no fsync. A SIGKILL, OOM kill, power loss, or a writeSync failure (ENOSPC/EIO, which is not EEXIST so line 126 rethrows without unlinking the just-created file) leaves an existing lock with no readable owner. readOwner returns null, provablyDead(null) is false by design ('unreadable or malformed: never provably stale', ledger.ts:92), so reclaimIfDead never fires regardless of age, and every subsequent withLedgerLock waits 5 s and throws LedgerLockError. Every `run` on that home exits 10 'refused locally, nothing sent: ledger lock timeout' forever, `receipt --refresh` prints the misleading 'receipt and ledger disagree', and no message names the lock file or a recovery step. The same permanent state arises when the recorded host no longer equals hostname(). Fail-closed, so no spend is at risk; the cost is an outage of the paid path that the CLI cannot recover from. Fix: make the owner record and the lock one atomic step (write the JSON to a private temp file in the same directory, then linkSync(temp, lockPath), which fails EEXIST exactly like 'wx'; unlink the temp), unlink the path before rethrowing a write failure, and/or treat a zero-byte lock older than the 30 s threshold as reclaimable; print the lock path in the exit-10 message. Merged from three specialist reports.

**Reproduction**

1. Ready a seat: init --seat 9001; link --token 9001 --agent 7; cap set 5. 2. Create the crash artefact: : > $IMD_FLOP_HOME/ledger.jsonl.lock; touch -d '10 minutes ago' $IMD_FLOP_HOME/ledger.jsonl.lock. 3. imd-flop run --seat 9001 --job g1 --prompt hi --max-tokens 8 --provider mock. Expected: a lock older than 30 s with no live owner is reclaimed and the run exits 0 (or the refusal names ledger.jsonl.lock). Actual at 716819c (judge probe G): the run blocks 5050 ms and exits 10 'imd-flop: refused locally, nothing sent: ledger lock timeout'; the empty lock file is still present afterwards and every further run behaves the same. Control: writing {"pid":2147483000,"host":"<hostname>","token":"x"} into the same aged lock makes the next run exit 0 (reclaimed).

### 6. Low: Daily cap or job budget already exhausted: the prompt still leaves the machine for a gateway estimate before the locally decidable exit-3 refusal, and an estimate failure then exits 7 instead of 3

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

```
    q = padQuote(await provider.estimate({ model, prompt: estPrompt, max_tokens: o.maxTokens }));
```

runJob requests provider.estimate() (which carries the full system+user prompt) before it reads the ledger. The 'daily cap reached' check (run.ts:216) and the requester 'remaining budget' check (run.ts:220) run only inside lockedCheck after the estimate returned. When spentToday >= cap.daily, or jobSpent >= jobBudget, or a ledger cap_charge is unusable, the outcome is decidable from local state alone, yet the prompt is posted to /models/estimate (on --provider flop) and only then is the run refused with 'refused locally, nothing sent' and exit 3. When that estimate fails the user sees exit 7 ESTIMATE_FAILED and a refused_local row with reason 'estimate failed', not the cap refusal. The sibling spend-guard tool was fixed for exactly this in commit 5c5b881 (audit 355a3703 I11: 'the prompt does not leave the machine for a request that was always going to be refused'); imd-flop was not. No FLOP is spent (the estimate is free); the prompt is disclosed to the gateway for a call that can never be sent and the exit code is wrong. Fix: before provider.estimate, compute spentBefore and refuse with REFUSED_CAP (same reason strings, same refused_local row with quote_raw/quote_padded null) when left <= 0 or the ledger charge is unusable; keep the authoritative in-lock check for the per-call cap and the race. Merged from two specialist reports.

**Reproduction**

Ready a seat with cap set 0.186 and complete one mock run (padded quote 0.186, so the day is full). Second run with a provider whose estimate() throws 'estimate HTTP 503': imd-flop run --seat 9001 --job d2 --prompt 'Say hi' --max-tokens 8 --provider mock --json. Expected: exit 3 'daily cap reached' with estimate() never called. Actual at 716819c (specialist proof section D, re-run by the judge): estimate() is called once, exit 7 'refused locally, nothing sent: estimate failed (estimate HTTP 503)'. With a working estimate the same state exits 3 'daily cap reached (0.186/0.186 FLOP today, UTC)' but still with estimate count 1. Same shape with config set payer requester and --job-budget 0.2 after one 0.186 run on the same job.

### 7. Low: A truncated or corrupt ledger line makes run, status, cap show and receipt all exit 1 with a raw JSON SyntaxError instead of a local refusal naming the file

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

```
    .map((l) => JSON.parse(l) as T);
```

readJsonl parses every non-blank line with no error handling. appendJsonlSafe uses appendFileSync with no fsync, so a crash or disk-full event mid-append can leave a partial last line. After that, latestAttempts throws SyntaxError on every call: `run` fails inside lockedCheck (fail-closed, nothing sent, but exit 1 rather than the documented exit 3 for an unusable ledger), and `status`, `cap show` and `receipt` all exit 1 with the bare parser message, which names neither the ledger file nor the line. The README's exit-code table reserves 3 for 'a ledger cap_charge is unusable' and 1 for 'any unexpected error', so a script branching on 3 to alert on ledger problems never fires, and the operator has no diagnostic command left that works. Fix: catch parse failures per line in readJsonl, raise a typed error naming the file and line number, map it to UnusableLedgerCharge/REFUSED_CAP in run, and let status and cap show report the corrupt line instead of dying.

**Reproduction**

In a ready home run one successful mock call, then: printf '{"ts":"2026-11-10T00:00:00Z","request_id":"trunc"' >> $IMD_FLOP_HOME/ledger.jsonl (no closing brace). Then: imd-flop run --seat 9001 --job i1 --prompt 'Say hi' --provider mock; imd-flop status --seat 9001; imd-flop cap show --seat 9001; imd-flop receipt mock-9001-x --seat 9001. Expected: run refuses with exit 3 naming the ledger problem; status and cap show still render and name the bad line. Actual at 716819c (judge probe I): all four exit 1 with "imd-flop: Expected ',' or '}' after property value in JSON at position 49 (line 1 column 50)"; provider.complete not called.

### 8. Low: UnusableLedgerCharge is not a CliError: status and cap show exit 1 whenever run is refusing with exit 3, so the operator cannot inspect the ledger state that caused the refusal

`imd-flop/src/cli.ts:375`

```
          today_utc: { flop: spentToday(h, seat, "flop"), mock: spentToday(h, seat, "mock") },
```

sumCharges (ledger.ts:64-71) throws UnusableLedgerCharge for any latest row whose cap_charge is missing, null, non-numeric, negative or non-finite. runJob maps that to REFUSED_CAP (exit 3) at run.ts:210, which is correct. But `status` (cli.ts:375) and `cap show` (cli.ts:263) call spentToday unguarded, so the same ledger state makes both diagnostic commands exit 1 with 'ledger cap_charge unusable; next call refused' and no row identifier. The request_id that needs repair is never shown anywhere, and `receipt <id> --refresh`, the only command the README offers to repair a ledger row (cli.ts:437-440), requires the operator to already know the request_id. Fix: let UnusableLedgerCharge carry the request_id(s), have status and cap show print the offending request_id(s) with today's known-good total instead of throwing, and keep run on exit 3.

**Reproduction**

In a ready home run one mock call, then append a copy of its latest ledger row with request_id 'mock-9001-bad' and "cap_charge":"nope" to $IMD_FLOP_HOME/ledger.jsonl. imd-flop run --seat 9001 --job j1 --prompt 'Say hi' --provider mock exits 3 'refused locally, nothing sent: ledger cap_charge unusable; next call refused' (correct). imd-flop status --seat 9001 and imd-flop cap show --seat 9001: expected exit 0 naming the request_id whose cap_charge is unusable. Actual at 716819c (judge probe J): both exit 1 with 'imd-flop: ledger cap_charge unusable; next call refused'.

### 9. Low: receipt --refresh swallows a ledger-lock failure as 'receipt and ledger disagree' and exits 0, leaving the receipt file settled while the ledger still holds the reservation

`imd-flop/src/cli.ts:448`

```
                } catch {
```

After the refreshed receipt has already been written to receipts/<id>.json (cli.ts:422), the ledger append at cli.ts:447 runs under its own withLedgerLock. If another process holds the lock (or the lock is stale and unreclaimable, see the zero-byte-lock finding), appendLedger throws LedgerLockError, the bare catch at cli.ts:448 prints the fixed string 'receipt and ledger disagree; reservation held, chain-final green blocked' and the command returns EXIT.OK. The message is false (the ledger could not be written; nothing disagrees), the exit code tells a script the refresh succeeded, and the two stores are now inconsistent: the receipt file says state settled / settled 0.05 while the ledger's latest row is still state estimated, spend null, cap_charge 0.186. Only a later refresh that happens to get the lock repairs it. The outcome is safe for spend (the reservation is kept), but the operator is told the wrong cause and a script cannot tell 'ledger updated' from 'not updated'. Fix: catch LedgerLockError separately, report 'ledger lock timeout; settlement not booked, re-run --refresh', and exit non-zero (10 fits the documented meaning: lock not taken, nothing written); or write the receipt file only after the ledger append succeeds. Merged from two specialist reports.

**Reproduction**

In a ready home run one mock call (exit 0, note request_id R). Create $IMD_FLOP_HOME/ledger.jsonl.lock containing {"pid":<a live pid>,"host":"<hostname>","token":"other"}. Run imd-flop receipt R --refresh --json --seat 9001 with a provider whose receipt is {request_id: R, state: settled, settled_spend: 0.05}. Expected: a non-zero exit naming the lock failure, or the receipt file not updated ahead of the ledger. Actual at 716819c (judge probe K): after the 5 s lock wait, exit 0, stderr 'receipt and ledger disagree; reservation held, chain-final green blocked', receipts/R.json now says state settled / settled 0.05, the ledger's latest row for R is still {state: estimated, spend: null, cap_charge: 0.186}, and the printed ledger_spend is null.

### 10. Low: A ledger-lock failure after the paid call exits 1 with only 'ledger lock timeout': request_id, response path and a higher reported cost are dropped, and the timeout branch exits 1 instead of 6

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

```
  appendLedger(h, { ...sent, ts: new Date().toISOString(), state: classified.state ?? "sent", reason: classified.flag, spend: settledKnown, cost_reported: costReported, cap_charge: capCharge, http: rr.http });
```

The README accepts exit 1 for a post-call lock failure, and the pre-call/post-call split (10 vs 1) is implemented as documented (TS-I14 passes). But the failure mode is harsher than described. appendLedger at run.ts:322 (success) and run.ts:250 (timeout/error branch) is not wrapped, so the raw LedgerLockError escapes runJob and cli.ts:475 prints only 'imd-flop: ledger lock timeout'; nothing is written to stdout even with --json. The operator gets neither the request_id, the response file path nor the receipt line, although the paid call completed and both files were written; the only way to find the attempt is `status`. On the timeout branch the same failure exits 1 instead of 6, the ledger's latest row stays `sent` (not timeout_unknown), and the 'Spend is UNKNOWN ... Check later with imd-flop receipt <id>' guidance is lost. On the success branch a cost_reported above the padded quote (0.25 vs 0.186) is never recorded, so the exposure raise the ledger-reservation tests guarantee is silently dropped and spentToday under-counts until a later refresh. Fix: catch LedgerLockError around both post-call appends, print the request_id, response path and 'ledger row not written: <state> pending' to stderr (and the normal JSON to stdout), then exit with the branch's own code (6 on timeout) or 1 as documented.

**Reproduction**

In a ready home run with a mock provider whose complete() first creates $IMD_FLOP_HOME/ledger.jsonl.lock with {"pid":<live pid>,"host":"<hostname>","token":"other"} and then returns usage cost_flop 0.25 (padded quote 0.186): imd-flop run --seat 9001 --job l1 --prompt 'Say hi' --max-tokens 8 --provider mock --json. Actual at 716819c (judge probe L1): exit 1, stdout empty, stderr exactly 'imd-flop: ledger lock timeout', provider called once, responses/ and receipts/ written, ledger rows for the id: [sent, 0.186, cost_reported null] only. Timeout variant (provider creates the lock then never answers; --timeout-ms 50; judge probe L2): exit 1 instead of 6, stderr 'imd-flop: ledger lock timeout', ledger latest row state sent, no request_id shown. Expected: the request_id and outcome on the terminal, and exit 6 on the timeout branch.

### 11. Low: Stale-lock reclaim renames whatever is at the lock path after a separate owner read, so a live holder's fresh lock can be moved aside and a third process enters the ledger critical section concurrentl

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

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

reclaimIfDead decides a lock is stale from one read of `path` (ledger.ts:109) and then renames whatever is at `path` (ledger.ts:111) without re-checking. Between those two steps another waiter can finish the same reclaim and create its own live lock at `path`. The second reclaimer then moves that live lock aside. The code anticipates this and links it back (ledger.ts:113), but between the rename and the link-back `path` does not exist, so any other waiter's openSync(path, 'wx') succeeds and it runs lockedCheck() while the first holder is still inside its body. The link-back then fails EEXIST and that reclaimer exits 10 'contested', and the live holder's file is left stranded as <lock>.stale.<pid>.<hex> (its releaseLock is a no-op). Result: two cap checks read the same spent_before, both pass, both append `sent`, so the daily cap can be exceeded by one padded quote. Preconditions: a holder that crashed leaving a lock older than 30 s with a dead pid on this host, plus three or more runs started together (e.g. automation retrying after a crash); the windows are microseconds, so this is an atomicity defect rather than an easy exploit. The comment at ledger.ts:104-106 claims two waiters cannot both act on one stale file; that no longer holds once the moved file is a live lock. Fix: re-read `path` immediately before the rename and refuse to move it unless its owner is still the dead one (compare pid and token), or reclaim via open + fstat and only unlink if the inode still matches what was read; on EEXIST at link-back unlink the aside file only if its token is the mover's own. Merged from two specialist reports.

**Reproduction**

Emulated interleaving at 716819c (judge probe Q, using the unmodified src/ledger.ts): (1) child process A runs withLedgerLock(home, body) where body prints HOLDING, sleeps 3 s via Atomics.wait, prints DONE; A's own live pid is in the lock file. (2) After HOLDING, perform the step reclaimIfDead takes after a stale owner read, exactly ledger.ts:111: renameSync(home/ledger.jsonl.lock, home/ledger.jsonl.lock.stale.emulated). (3) Call withLedgerLock(home, () => 'entered') in another process. Expected: the call waits until A releases (about 3 s) or exits 10. Actual: it returns 'entered' after 0 ms while A is still inside its body (DONE not yet printed): entered=true, holderStillRunning=true. Step 4 in the real sequence, linkSync(aside, path) at ledger.ts:113, then fails EEXIST and throws 'ledger lock contested'.

### 12. Info: quote --provider flop prints the gateway's estimate JSON to stdout verbatim, without the redaction every other gateway-derived stdout path applies

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

```
        print({ provider: pname, estimate: est, padded: padQuote(est), note: "padded = raw + 20 tokens for the Qwen chat template" });
```

The `quote` command prints `est`, the parsed body of POST /models/estimate, as-is. Every other place gateway JSON reaches stdout (run --json receipt, receipt, status) goes through writeJsonSafe/redactFlagged first so a bearer-, flop_ek_- or key-shaped string from the gateway is withheld (TS-L8, stdout-withhold tests). `quote` has no such step, so any extra field in the estimate response is echoed. No local secret is loaded on this path (no gateway key is read), so the exposure is gateway-supplied content only, in a command the README lists as 'live, free, opt-in'. Consistency gap rather than a leak of local material. Fix: print only the fields padQuote consumes (model, estimated_input_tokens, estimated_output_tokens, estimated_cost_flop), or pass est through redactFlagged before print.

**Reproduction**

Supply a fetch whose /models/estimate response is {"estimated_cost_flop":0.066,"estimated_input_tokens":3,"estimated_output_tokens":8,"debug":"Bearer SYNTHETICNOTAKEY0123456789"} and run: imd-flop quote --prompt hi --provider flop. Expected: the Bearer-shaped string is withheld from stdout. Actual at 716819c (specialist proof section F, re-run by the judge): exit 0 and stdout contains 'Bearer SYNTHETICNOTAKEY0123456789'.

### 13. Info: Numeric CLI arguments are parsed with Number(): hex, exponent and padded strings are accepted for caps and budgets, and quote --max-tokens is not validated at all

`imd-flop/src/cli.ts:71`

```
  const n = Number(v);
```

num() is used for `cap set <FLOP/day>`, --per-call and --job-budget. Number() accepts hexadecimal (0x10 is 16), binary/octal prefixes, exponent notation (1e-7) and surrounding whitespace, so `cap set 0x10` silently sets a 16 FLOP/day cap and `--per-call 1e-7` sets a per-call cap below the 1e-6 quote granularity that ceil6 produces, after which every run is refused with exit 3 and the message 'padded quote 0.186 FLOP is over the per-call cap 1e-7'. run --max-tokens goes through Number() (cli.ts:327) but is then integer-range checked in runJob; quote --max-tokens (cli.ts:289) has no check, so `quote --max-tokens abc` exits 0 and prints estimated_output_tokens null / estimated_cost_flop null / padded null, and with --provider flop would send max_tokens: null to the gateway. Nothing here spends FLOP (quote is free, caps only ever refuse): a hardening note. Fix: validate FLOP amounts with a decimal regex (^\d+(\.\d{1,6})?$) and reuse the run path's integer check for quote.

**Reproduction**

In a ready home: imd-flop cap set 0x10 --seat 9001 --json exits 0 with cap {daily: 16, perCall: 16}; imd-flop cap set ' 5 ' --per-call 1e-7 --seat 9001 --json exits 0 with perCall 1e-7, after which imd-flop run --seat 9001 --job m1 --prompt 'Say hi' --provider mock exits 3 'padded quote 0.186 FLOP is over the per-call cap 1e-7'; imd-flop quote --prompt hi --max-tokens abc exits 0 and prints estimated_output_tokens null, padded null. Expected: exit 2 (USAGE) for each malformed number. Verified at 716819c (judge probe M).

### 14. Info: Estimate failure while another process holds the ledger lock exits 1 and drops the ESTIMATE_FAILED refusal (documented residual of e99a2cf1 I14)

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

```
    appendLedger(h, { ...base, request_id: null, quote_raw: q.raw, quote_padded: q.padded, spent_before: spentBefore, state: "refused_local", reason, spend: null, cost_reported: null, cap_charge: 0, http: null });
```

Re-audit of e99a2cf1 I14: the lock taken at run.ts:228 now maps LedgerLockError to exit 10 and nothing is sent (TS-I14 passes). One pre-call path still does not: when the estimate fails or returns no usable cost (run.ts:198, 200), refuse() appends a refused_local row through appendLedger (run.ts:189), which takes the lock itself. A LedgerLockError there is not a CliError, so main exits 1 with 'imd-flop: ledger lock timeout', without the 'refused locally' prefix, without naming the estimate failure, and without the refused_local row. README.md:48 records this as a known gap, two lines after README.md:46 states the unconditional rule, so it is an accepted residual rather than a regression; listed so the re-audit of I14 is complete. Nothing is sent and no FLOP is at risk. Fix: in refuse(), catch LedgerLockError around appendLedger and rethrow CliError('refused locally, nothing sent: ...', EXIT.REFUSED_LOCK), or let the original refusal code win since the row is informational. Merged from two specialist reports.

**Reproduction**

State: seat 9001 ready with cap 5; $IMD_FLOP_HOME/ledger.jsonl.lock contains {"pid":<a live pid on this host>,"host":"<hostname>","token":"other"}. Command: imd-flop run --seat 9001 --job n1 --prompt hi --provider mock --mock-scenario estimate_error. Expected per README.md:46: exit 10 (or at least 7) with 'refused locally, nothing sent'. Actual at 716819c (judge probe N): after the 5 s lock wait, exit 1, stderr 'imd-flop: ledger lock timeout', no ledger row written, complete() never called. The same command with the lock free exits 7.

### 15. Info: Async-body refusal in withLedgerLock is runtime-only: the body's pre-await part runs under the lock and its post-await part still runs outside it, and tsc accepts the call

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

```
export function withLedgerLock<T>(h: string, fn: () => T): T {
```

Re-audit of e99a2cf1 I12: fixed as far as a runtime check can go (TS-I12 passes; a promise-returning body throws TypeError and leaves no lock behind). syncResult (ledger.ts:146-152) only sees the promise after fn() has already run to its first await, so an async body's pre-await part executes under the lock, the TypeError is thrown to the caller, the lock is released, and the body's post-await part still runs unlocked (test/audit-v7d.test.ts itself asserts afterAwait === true). The signature `fn: () => T` at ledger.ts:155 still accepts async functions at compile time, so a future caller gets the error only when the code path executes. Production callers (lockedCheck, appendLedger) are synchronous, so no current spend path is affected. Fix: narrow the type so a promise-returning body fails tsc, e.g. `fn: () => T extends PromiseLike<unknown> ? never : T`, keeping the runtime check as a backstop.

**Reproduction**

withLedgerLock(home, async () => { appendLedger(home, rowA); await Promise.resolve(); appendLedger(home, rowB); }) in a ready home. Expected: neither append happens, or both happen under the lock. Actual at 716819c (judge probe O): a TypeError 'withLedgerLock body must be synchronous...' is thrown to the caller, rowA (request_id A) was appended under the lock, and rowB (request_id B) is appended on the next microtask after the lock file was removed (ledger contains both A and B; lock file absent). tsc -p tsconfig.json --noEmit accepts the call.

---

Judge's submission `ac2e9bdeb5d59d873bbf4ff78da40544481c8378f7c568f39c56dae23a601c3b`, 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.
