# Audit report

> Audit the TypeScript program under imd-flop at commit 288a03eda34957e23fd6ab19c2581ce984ba2a1d. This is a re-audit after fixes for prior audit f31116ef. Focus on src/run.ts, src/receipts.ts, src/ledger.ts, src/match.ts, src/cli.ts, src/secrets.ts and their tests. Check receipt identity (request_id must exactly match), ledger fail-closed totals, the file lock from cap check through append, receipt --refresh ledger writes, --timeout-ms validation, and that no paid response is lost on a scan hit. Require a file/line, impact and runnable reproduction for every finding. Read-only review: no fixes, deployments, credentials or live payments. This is TypeScript, not Solidity. The Python spend-guard and viewer folders are outside this audit.

| | |
|---|---|
| Repository | https://github.com/uxp-labs/flop-devnet-lab.git |
| Commit | `288a03eda34957e23fd6ab19c2581ce984ba2a1d` |
| Job | `e99a2cf1-0405-41c3-84ea-131bf85d4f98` |
| Judged | 2026-11-10 06:06 UTC |
| Findings | 4 medium · 7 low · 3 info |

Four agents audited the code as it is at `288a03e`, 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: receipt --refresh checks identity on the classified request_id, which falls back to the queried id: a receipt with no/null request_id settles and releases the reservation, and any non-200 refresh turn

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

```
            const rawId = (saved as { request_id?: unknown } | null)?.request_id;
```

Merged from audit_flow, audit_math, audit_permissions and audit_economics (same root cause). run.ts:262-268 decides identity on the raw gateway receipt (`rr.receipt.request_id === requestId`). The refresh branch instead reads request_id from `saved`, which is `merged` built at cli.ts:412 from classifyReceipt(), and receipts.ts:55 sets `request_id: r?.request_id ?? ctx.requestId ?? ""` with ctx.requestId = rid (the id the user typed). (a) HTTP 200 receipt with request_id absent or null: request_id becomes rid, identityOk at cli.ts:418 is true, and with state settled / settled_spend 0 the appended ledger row has spend 0, cap_charge 0, releasing the 0.186 FLOP reservation, so the daily cap admits one more padded quote per such refresh; the receipt is also saved with request_id = rid and shows match 'matches'. (b) Any non-200 refresh (404, http 0, 401 api_key_expired): merged takes c.request_id = rid, overwriting a stored '' or foreign request_id that run.ts recorded as an identity mismatch, so `receipt <id>` and `status` report 'matches' permanently without the gateway ever confirming the id. Both contradict the rule that request_id must match exactly, which run.ts already enforces. For the live FlopProvider, case (a) depends on what the @flop-labs/client response validation allows (not verified here); case (b) only needs a non-200 answer. Fix: derive identity from `rr.receipt?.request_id` exactly as run.ts does; when identity fails keep settled null, chain_final false and the stored/raw request_id; on a non-200 refresh keep saved.request_id; never let classifyReceipt's ctx fallback feed identity or settlement.

**Reproduction**

Reproduced on 288a03e with the attached vitest file (3 cases fail). Seat 9001, cap 5, mock provider via io.provider. Run job j1 with a same-id 'estimated' receipt; ledger latest {state estimated, spend null, cap_charge 0.186}. Then `receipt <id> --refresh --json --seat 9001` where readReceipt returns HTTP 200 {state:'settled', settled_spend:0} with request_id missing (or null). Expected: cap_charge 0.186, spend null, match 'mismatch', status today_utc.mock 0.186. Actual: cap_charge 0, spend 0, match 'matches', today_utc.mock 0. Case (b): run with a receipt lacking request_id (run prints match 'mismatch', file stores request_id ''), then refresh with a provider answering 404. Expected: still 'mismatch'. Actual: refresh prints 'matches' and a later `receipt <id> --json` also says 'matches'.

**Proof**: a Foundry test that fails on this code and passes once it is fixed.

```solidity
/**
 * Vitest reproduction (imd-flop/test/scratch/). Asserts the SAFE outcome, so it fails on the current tree.
 * Run: cd imd-flop && npx vitest run test/scratch/<this file>
 */
import { writeFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import type { CompleteReq, CompleteRes, Provider } from "../../src/providers.js";
import { utcDay } from "../../src/ledger.js";
import { cli, cliP, readLines, readySeat, tmpHome } from "../helpers.js";

const SEAT = "9001";
const quote = { estimated_cost_flop: 0.066, estimated_input_tokens: 3, estimated_output_tokens: 8 };
class P implements Provider {
  readonly name = "mock" as const;
  calls = 0;
  constructor(private readonly o: { state?: "estimated" | "settled"; settled?: unknown; idMode?: "same" | "missing" | "null"; http?: number } = {}) {}
  async estimate() { return quote; }
  async complete(req: CompleteReq): Promise<CompleteRes> {
    this.calls += 1;
    return { request_id: req.requestId, text: "BENIGN", usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 } };
  }
  async readReceipt(requestId: string) {
    if (this.o.http && this.o.http !== 200) return { http: this.o.http, receipt: null, error: "not_found" };
    const mode = this.o.idMode ?? "same";
    const receipt: Record<string, unknown> = { correlation_id: "imd-job-j1", model: "audit", reserved_spend: 0.186, settled_spend: this.o.settled === undefined ? null : this.o.settled,
      settled_spend_raw: null, terminal_event: null, channel_id: null, created_at: 0, state: this.o.state ?? "estimated", response_status: 200 };
    if (mode === "same") receipt.request_id = requestId;
    if (mode === "null") receipt.request_id = null;
    return { http: 200, error: null, receipt: receipt as never };
  }
}
const RUN = ["run", "--seat", SEAT, "--job", "j1", "--prompt", "Say hi", "--max-tokens", "8", "--provider", "mock", "--json"];
const rows = (home: string, id: string) => (readLines(join(home, "ledger.jsonl")) as { request_id: string | null; state: string; spend: number | null; cap_charge: number }[]).filter((r) => r.request_id === id);

describe("receipt --refresh receipt identity", () => {
  for (const idMode of ["missing", "null"] as const) {
    it(`refresh with request_id ${idMode}, state settled, settled_spend 0 keeps the 0.186 reservation and never says matches`, async () => {
      const home = tmpHome();
      await readySeat(home, SEAT, "5");
      const opened = await cliP(home, RUN, new P());
      expect(opened.code, opened.err).toBe(0);
      const id = opened.json().request_id as string;
      expect(rows(home, id).at(-1)).toMatchObject({ state: "estimated", spend: null, cap_charge: 0.186 });
      const refreshed = await cli(home, ["receipt", id, "--refresh", "--json", "--seat", SEAT], { provider: new P({ state: "settled", settled: 0, idMode }) });
      expect(refreshed.code, refreshed.err).toBe(0);
      const latest = rows(home, id).at(-1)!;
      const today = (await cli(home, ["status", "--seat", SEAT, "--json"])).json().today_utc.mock;
      expect({ cap: latest.cap_charge, spend: latest.spend, match: refreshed.json().match.match, today }).toEqual({ cap: 0.186, spend: null, match: "mismatch", today: 0.186 });
    });
  }
  it("a non-200 refresh (404 here; 401 api_key_expired behaves the same) must not flip an identity-mismatch receipt to matches", async () => {
    const home = tmpHome();
    await readySeat(home, SEAT, "5");
    const opened = await cliP(home, RUN, new P({ idMode: "missing" }));
    expect(opened.code, opened.err).toBe(0);
    const id = opened.json().request_id as string;
    expect(opened.json().receipt.match.match).toBe("mismatch");
    const refreshed = await cli(home, ["receipt", id, "--refresh", "--json", "--seat", SEAT], { provider: new P({ http: 404 }) });
    expect(refreshed.code, refreshed.err).toBe(0);
    expect(refreshed.json().match.match).toBe("mismatch");
    expect((await cli(home, ["receipt", id, "--json", "--seat", SEAT])).json().match.match).toBe("mismatch");
  });
});
```

### 2. Medium: receipt --refresh rewrites an unusable ledger cap_charge (missing, null, non-numeric) as 0, undoing the fail-closed day total

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

```
            const reservation = typeof prev.cap_charge === "number" && Number.isFinite(prev.cap_charge) ? prev.cap_charge : 0;
```

Merged from audit_flow, audit_permissions and audit_economics. ledger.ts sumCharges throws UnusableLedgerCharge when a latest row's cap_charge is not a finite number >= 0, so run refuses with exit 3 ('ledger cap_charge unusable; next call refused'). When the refreshed receipt state differs from the ledger row's state (e.g. 'sent' -> 'estimated', or null after a 404), the refresh branch appends a new latest row for that request_id whose cap_charge is prev.cap_charge if finite, else literally 0 (line 421, written at line 425 since settledKnown is null). The unknown charge now counts as a known 0, the day total drops, and the next run is admitted. Rows without cap_charge are what an older ledger contains, so an upgrade plus a routine refresh silently clears the refusal. Fix: if usableCapCharge(prev.cap_charge) is false and no exact-identity settlement is known, do not append (report the existing 'receipt and ledger disagree' status) or charge prev.quote_padded / per_call_cap; never substitute 0.

**Reproduction**

Reproduced on 288a03e with the attached vitest file (3 cases: cap_charge missing, null, 'nope'). State: seat 9001 cap 5; ledger.jsonl holds one row {request_id:'seed-bad', seat:'9001', provider:'mock', payer:'holder', day: today, state:'sent', cap_charge: <bad>}. Step 1: `run --seat 9001 --job j1 --prompt 'Say hi' --max-tokens 8 --provider mock` -> exit 3, provider.calls 0 (correct). Step 2: `receipt seed-bad --refresh --json --seat 9001` with a receipt in state 'estimated' -> exit 0, appends {state:'estimated', spend:null, cap_charge:0}. Step 3: the same run. Expected: exit 3, calls 0. Actual: exit 0, calls 1.

**Proof**: a Foundry test that fails on this code and passes once it is fixed.

```solidity
/**
 * Vitest reproduction (imd-flop/test/scratch/). Asserts the SAFE outcome, so it fails on the current tree.
 * Run: cd imd-flop && npx vitest run test/scratch/<this file>
 */
import { writeFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import type { CompleteReq, CompleteRes, Provider } from "../../src/providers.js";
import { utcDay } from "../../src/ledger.js";
import { cli, cliP, readLines, readySeat, tmpHome } from "../helpers.js";

const SEAT = "9001";
const quote = { estimated_cost_flop: 0.066, estimated_input_tokens: 3, estimated_output_tokens: 8 };
class P implements Provider {
  readonly name = "mock" as const;
  calls = 0;
  constructor(private readonly o: { state?: "estimated" | "settled"; settled?: unknown; idMode?: "same" | "missing" | "null"; http?: number } = {}) {}
  async estimate() { return quote; }
  async complete(req: CompleteReq): Promise<CompleteRes> {
    this.calls += 1;
    return { request_id: req.requestId, text: "BENIGN", usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 } };
  }
  async readReceipt(requestId: string) {
    if (this.o.http && this.o.http !== 200) return { http: this.o.http, receipt: null, error: "not_found" };
    const mode = this.o.idMode ?? "same";
    const receipt: Record<string, unknown> = { correlation_id: "imd-job-j1", model: "audit", reserved_spend: 0.186, settled_spend: this.o.settled === undefined ? null : this.o.settled,
      settled_spend_raw: null, terminal_event: null, channel_id: null, created_at: 0, state: this.o.state ?? "estimated", response_status: 200 };
    if (mode === "same") receipt.request_id = requestId;
    if (mode === "null") receipt.request_id = null;
    return { http: 200, error: null, receipt: receipt as never };
  }
}
const RUN = ["run", "--seat", SEAT, "--job", "j1", "--prompt", "Say hi", "--max-tokens", "8", "--provider", "mock", "--json"];
const rows = (home: string, id: string) => (readLines(join(home, "ledger.jsonl")) as { request_id: string | null; state: string; spend: number | null; cap_charge: number }[]).filter((r) => r.request_id === id);

function seedRow(home: string, id: string, capCharge: unknown) {
  const row: Record<string, unknown> = { ts: new Date().toISOString(), day: utcDay(), request_id: id, seat: SEAT, did: "did:key:zseed", job: "seed", provider: "mock",
    network: "devnet", payer: "holder", model: "m", max_tokens: 8, prompt_sha256: "ab".repeat(32), quote_raw: 0.066, quote_padded: 0.186, per_call_cap: 5, daily_cap: 5,
    job_budget: null, spent_before: 0, state: "sent", reason: null, spend: null, cost_reported: null, http: null, mock: true };
  if (capCharge !== "missing") row.cap_charge = capCharge;
  writeFileSync(join(home, "ledger.jsonl"), JSON.stringify(row) + "\n");
}
describe("receipt --refresh must not rewrite an unusable cap_charge as 0", () => {
  for (const bad of ["missing", null, "nope"] as const) {
    it(`cap_charge ${String(bad)}: run refuses before and must still refuse after a refresh`, async () => {
      const home = tmpHome();
      await readySeat(home, SEAT, "5");
      seedRow(home, "seed-bad", bad);
      const first = new P();
      expect((await cliP(home, RUN, first)).code).toBe(3);
      expect(first.calls).toBe(0);
      const refreshed = await cli(home, ["receipt", "seed-bad", "--refresh", "--json", "--seat", SEAT], { provider: new P({ state: "estimated" }) });
      expect(refreshed.code, refreshed.err).toBe(0);
      const second = new P();
      const r2 = await cliP(home, RUN, second);
      expect({ code: r2.code, calls: second.calls, latestCapIsZero: rows(home, "seed-bad").at(-1)!.cap_charge === 0 }).toEqual({ code: 3, calls: 0, latestCapIsZero: false });
    });
  }
});
```

### 3. Medium: A secret-scan key containing '.' cannot be redacted: the second response write throws and the paid response, receipt and final ledger row are all lost

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

```
    writeJsonSafe(responseFile, storedResponse, "response");
```

Merged from audit_flow, audit_math and audit_permissions. scanForSecrets reports a hit as a dotted path string ('usage.a.b'); setNull (secrets.ts:91-104) re-parses it by splitting on '.' and '[n]', so a gateway-supplied key that itself contains '.' or '[' resolves to usage -> a -> b, finds undefined and nulls nothing. redactFlagged returns a copy still holding the hit, and the second writeJsonSafe at run.ts:257 throws a CliError that nothing catches. runJob aborts after the paid completion: no response file (the completion text, which was clean, is gone), readReceipt never runs, no receipt file, no final ledger row (latest stays 'sent' with the 0.186 reservation, the safe direction), exit 5. This breaks the requirement that no paid response is lost on a scan hit. The usage object is stored verbatim from the gateway (run.ts:245). The same unguarded second write exists for the receipt at run.ts:278. Fix: return finding paths as key arrays and have setNull consume them (no re-parsing), assert scanForSecrets(copy) is empty after redaction, and make the fallback write fail safe (e.g. drop usage and still store text/withheld) so the run proceeds to the receipt and the final ledger row.

**Reproduction**

Reproduced on 288a03e (attached vitest file). Seat 9001 cap 5, mock provider whose complete() returns text 'BENIGN-CONTROL-output' and usage {prompt_tokens:1, completion_tokens:1, total_tokens:2, 'a.b': 'ee' x32}. Expected: exit 0, responses/<id>.json written with the hit nulled, receipt written, final ledger row 'estimated'. Actual: exit 5, stderr 'imd-flop: secret scan blocked writing response: usage.a.b: 64-or-more-hex (key-shaped) value', provider.calls 1, responses/ and receipts/ empty, ledger states ['sent']. Unit: redactFlagged({usage:{'a.b':HEX}}).copy.usage['a.b'] is still HEX. Control: key 'ab' (no dot) exits 0.

**Proof**: a Foundry test that fails on this code and passes once it is fixed.

```solidity
/** Vitest reproduction for imd-flop/test/scratch/. Asserts the safe outcome, so it fails on 288a03e. Run: cd imd-flop && npx vitest run test/scratch/<file> */
import { existsSync, readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { expect, it } from "vitest";
import type { CompleteReq, CompleteRes, Provider } from "../../src/providers.js";
import { cliP, readLines, readySeat, tmpHome } from "../helpers.js";
const SEAT = "9001";
const HEX = "ee".repeat(32);
class P implements Provider {
  readonly name = "mock" as const;
  calls = 0;
  constructor(private usage: Record<string, unknown>, private text = "BENIGN-CONTROL-output") {}
  async estimate() { return { estimated_cost_flop: 0.066, estimated_input_tokens: 3, estimated_output_tokens: 8 }; }
  async complete(req: CompleteReq): Promise<CompleteRes> { this.calls++; return { request_id: req.requestId, text: this.text, usage: this.usage as never }; }
  async readReceipt(id: string) { return { http: 200, error: null, receipt: { request_id: id, correlation_id: "imd-job-j", model: "m", reserved_spend: 0.186, settled_spend: null, state: "estimated" } as never }; }
}
const RUN = ["run", "--seat", SEAT, "--job", "j", "--prompt", "Say hi", "--max-tokens", "8", "--provider", "mock", "--json"];
it("a usage key containing '.' with a 64-hex value must not lose the paid response, receipt and final ledger row", async () => {
  const h = tmpHome(); await readySeat(h, SEAT, "5");
  const p = new P({ prompt_tokens: 1, completion_tokens: 1, total_tokens: 2, "a.b": HEX });
  const r = await cliP(h, RUN, p);
  const resp = existsSync(join(h, "responses")) ? readdirSync(join(h, "responses")) : [];
  const rec = existsSync(join(h, "receipts")) ? readdirSync(join(h, "receipts")) : [];
  const states = (readLines(join(h, "ledger.jsonl")) as { state: string }[]).map((x) => x.state);
  expect({ calls: p.calls, code: r.code, resp: resp.length, rec: rec.length, last: states.at(-1) }).toEqual({ calls: 1, code: 0, resp: 1, rec: 1, last: "estimated" });
});
```

### 4. Medium: A secret-scan hit anywhere in the response record discards the clean paid completion text

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

```
    const redacted = redactFlagged({ ...responseRecord, text: null, withheld });
```

From audit_math. The fallback unconditionally sets text to null before calling redactFlagged, although redactFlagged already nulls exactly the flagged paths. A hit in a gateway-controlled field such as usage.note (the case test/audit-v3.test.ts 'redacts a hex hit in usage' exercises without asserting on text) therefore withholds a completion that contains no secret shape: the paid text is written nowhere and `run --json` prints text null. That contradicts the audit's requirement that no paid response is lost on a scan hit. Fix: pass responseRecord unchanged to redactFlagged and null text (setting withheld) only when a finding path is 'text'; keep withheld naming the fields actually removed.

**Reproduction**

Reproduced on 288a03e (attached vitest file). Seat 9001 cap 5, mock provider: complete() returns text 'BENIGN-CONTROL-output', usage {prompt_tokens:1, completion_tokens:1, total_tokens:2, note:'ee' x32}. Expected: response file text 'BENIGN-CONTROL-output', usage.note null, `--json` text the same. Actual: exit 0, printed text null, withheld 'secret scan blocked writing response: usage.note: 64-or-more-hex (key-shaped) value', response file text null and usage.note null.

**Proof**: a Foundry test that fails on this code and passes once it is fixed.

```solidity
/** Vitest reproduction for imd-flop/test/scratch/. Asserts the safe outcome, so it fails on 288a03e. Run: cd imd-flop && npx vitest run test/scratch/<file> */
import { existsSync, readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { expect, it } from "vitest";
import type { CompleteReq, CompleteRes, Provider } from "../../src/providers.js";
import { cliP, readLines, readySeat, tmpHome } from "../helpers.js";
const SEAT = "9001";
const HEX = "ee".repeat(32);
class P implements Provider {
  readonly name = "mock" as const;
  calls = 0;
  constructor(private usage: Record<string, unknown>, private text = "BENIGN-CONTROL-output") {}
  async estimate() { return { estimated_cost_flop: 0.066, estimated_input_tokens: 3, estimated_output_tokens: 8 }; }
  async complete(req: CompleteReq): Promise<CompleteRes> { this.calls++; return { request_id: req.requestId, text: this.text, usage: this.usage as never }; }
  async readReceipt(id: string) { return { http: 200, error: null, receipt: { request_id: id, correlation_id: "imd-job-j", model: "m", reserved_spend: 0.186, settled_spend: null, state: "estimated" } as never }; }
}
const RUN = ["run", "--seat", SEAT, "--job", "j", "--prompt", "Say hi", "--max-tokens", "8", "--provider", "mock", "--json"];
it("a scan hit only in usage must not discard the clean paid completion text", async () => {
  const h = tmpHome(); await readySeat(h, SEAT, "5");
  const r = await cliP(h, RUN, new P({ prompt_tokens: 1, completion_tokens: 1, total_tokens: 2, note: HEX }));
  expect(r.code).toBe(0);
  const file = JSON.parse(readFileSync(r.json().response_file, "utf8"));
  expect(file.usage.note).toBeNull();
  expect(file.text).toBe("BENIGN-CONTROL-output");
  expect(r.json().text).toBe("BENIGN-CONTROL-output");
});
```

### 5. Low: receipt --refresh writes the ledger only when the state string changes, so a settlement learned later under the same state is never booked

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

```
          if (prev && (saved?.state ?? null) !== prev.state) {
```

Merged from audit_math, audit_permissions and audit_economics. run.ts:294 records state = classified.state even when the settlement was rejected (unusable settled_spend such as NaN, identity mismatch, or a redacted receipt), so the ledger can already say state 'settled' with spend null and cap_charge 0.186. A later refresh that returns the exact id, state 'settled' and settled_spend 0.05 updates the receipt file but appends nothing, because the state string is unchanged; no 'disagree' status is printed. The day keeps 0.186 reserved instead of 0.05 (lost headroom, fail-closed direction), and receipt and ledger disagree permanently. The same gate would also skip a corrected higher amount on an already-settled row. Fix: append when the derived (state, identityOk, settledKnown/cap_charge) differs from prev, not only on a state change.

**Reproduction**

Reproduced on 288a03e (vitest). Seat 9001 cap 5. `run --job j` with readReceipt {request_id:<ours>, state:'settled', settled_spend:NaN} -> ledger latest {state:'settled', spend:null, cap_charge:0.186}. Then `receipt <id> --refresh --json --seat 9001` with {request_id:<ours>, state:'settled', settled_spend:0.05}. Expected: a row with spend 0.05, cap_charge 0.05. Actual: refresh prints settled 0.05 and ledger_spend null; ledger still 2 rows, latest cap_charge 0.186.

### 6. Low: receipt --refresh has no redaction fallback: a key-shaped value in the fresh receipt aborts before the ledger write on every retry

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

```
          writeJsonSafe(path, merged, "receipt");
```

From audit_math. run.ts:272-282 falls back to redactFlagged when the receipt trips the secret scan, so a 64-hex settled_spend_raw still yields a receipt and a final ledger row. The refresh path calls writeJsonSafe unguarded: assertNoSecrets throws, main exits 5, the saved receipt stays stale and the ledger append at line 425 is never reached. Since the gateway keeps returning the same settled_spend_raw, every later refresh fails the same way, so the known settlement can never be recorded and the reservation is held indefinitely (fail-closed, but an accounting dead end). Fix: apply the same redaction fallback as run.ts, then continue to the ledger logic with identity derived from the raw receipt.

**Reproduction**

Reproduced on 288a03e (vitest). Seat 9001 cap 5; completed run, ledger latest {state:'estimated', cap_charge:0.186}. `receipt <id> --refresh --json --seat 9001` twice with readReceipt {request_id:<ours>, state:'settled', settled_spend:0.05, settled_spend_raw:'ee' x32}. Expected: exit 0, settled_raw null in the file, ledger {state:'settled', spend:0.05, cap_charge:0.05}. Actual: both exit 5 'imd-flop: secret scan blocked writing receipt: settled_raw: 64-or-more-hex (key-shaped) value', ledger latest still {state:'estimated', cap_charge:0.186}.

### 7. Low: Final ledger row uses the unredacted receipt state: a key-shaped state aborts a paid run with exit 5 and no output

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

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

From audit_flow. When the receipt write trips the scan, lines 276-281 redact receiptBody but copy only error and settled_raw back; the ledger append at line 294 still uses the gateway-controlled classified.state. If state is 64+ hex, bearer-shaped or a flop_ token, assertNoSecrets rejects the ledger row and the CliError escapes runJob after the paid call: exit 5, empty stdout (no request_id or response path printed even with --json), latest ledger row stays 'sent'. The response file exists, and the reservation is held (safe direction), but the holder is told the run failed. Fix: whitelist state to the known enum (reserved/estimated/settled/released) before writing, or take it from the redacted receipt and fall back to 'sent'.

**Reproduction**

Reproduced on 288a03e (vitest). Seat 9001 cap 5, mock provider whose readReceipt returns {request_id:<ours>, state:'cd' x32, settled_spend:null}. `run --seat 9001 --job j --prompt 'Say hi' --max-tokens 8 --provider mock --json`. Expected: exit 0 with JSON output and a final ledger row. Actual: exit 5, stderr 'imd-flop: secret scan blocked writing ledger.jsonl: state: 64-or-more-hex (key-shaped) value', stdout empty, provider.calls 1, responses/<id>.json present, ledger rows [['sent',0.186]].

### 8. Low: run prints receipt fields on stdout (request_id, correlation_id) that the receipt-file secret scan redacted

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

```
  const receipt0 = { ...classified, seat, did: signer.identity.did };
```

From audit_permissions. receipt0 is built from the unredacted receipt; after a scan hit only error and settled_raw are copied back from the redacted body (lines 279-281). `run --json` prints receipt0 and plain output prints receiptLine(receipt0), whose first field is request_id. A foreign 64-hex request_id (kept verbatim by the identity check at line 267) or a bearer-shaped correlation_id is nulled in receipts/<id>.json but emitted to stdout in both modes, so stdout and file disagree and key-shaped values reach terminals and CI logs, the opposite of the stdout-withhold rule applied to response text. Fix: build receipt0 from the redacted receiptBody (all fields) after the write.

**Reproduction**

Reproduced on 288a03e (vitest). Seat 9001 cap 5. (A) readReceipt request_id = 'ee' x32: receipt file does not contain the hex; `run --json` stdout contains it; plain `run` stdout contains it too. (B) correlation_id 'Bearer SYNTHETICNOTAKEY0123456789': receipt file has no bearer string; `run --json` stdout contains it. Expected: neither value on stdout.

### 9. Low: UTC day is fixed before the estimate while the cap check reads spend for the day at lock time, so a run across 00:00 UTC is checked against the new day and booked to the old one

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

```
    day: utcDay(),
```

Merged from audit_math and audit_economics. base.day is computed before `await provider.estimate()` (network call, up to 15 s live) and before the lock; spentToday() at line 200 evaluates utcDay() again inside the lock. If midnight passes in between, the check sums the new day's rows (0) while the sent and final rows carry the old day, so the old day's booked total exceeds its cap. Over-spend is bounded by one per-call cap per day boundary. Fix: compute the day once inside withLedgerLock and use it for both spentToday and the rows.

**Reproduction**

Reproduced on 288a03e (vitest, vi.useFakeTimers({toFake:['Date']})). Seat 9001 cap 0.2, padded quote 0.186. At 2026-11-10T23:59:59.000Z run d0 (exit 0, day total 0.186). At 23:59:59.900Z run d1 whose estimate() advances the clock to 2026-11-11T00:00:00.100Z. Expected: refused with exit 3 (2026-11-10 at cap) or booked to 2026-11-11. Actual: exit 0, d1 rows have day '2026-11-10' and spent_before 0, spentToday(2026-11-10) = 0.372 against a 0.2 cap, spentToday(2026-11-11) = 0.

### 10. Low: A cap object without numeric daily/perCall passes the 'cap required' guard and disables both cap checks

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

```
  if (!cap) throw new CliError(`no daily cap for seat ${seat}: FLOP stays off. Set one with \`imd-flop cap set <FLOP/day>\``, EXIT.REFUSED_CAP);
```

From audit_math. Only truthiness of cfg.caps[seat] is checked, and loadConfig does no validation. With caps['9001'] = {} (hand-edited or partially written config.json), `q.padded > cap.perCall` is false, `left` is NaN, and `left <= 0` / `q.padded > left` are false, so every call is admitted, while the ledger side fails closed on unusable values. Fix: require cap.daily and cap.perCall to be finite positive numbers (perCall <= daily) at run.ts:150-151 or in loadConfig, refusing with EXIT.REFUSED_CAP otherwise.

**Reproduction**

Reproduced on 288a03e (vitest). Seat 9001, `cap set 0.1`, then config.json edited so caps['9001'] = {}. Three mock runs f1, f2, f3 (padded 0.186 each). Expected: exit codes [3,3,3]. Actual: [0,0,0], provider.complete called 3 times.

### 11. Low: A transport failure while minting the child key (nothing sent) is booked as timeout_unknown and holds the padded quote against the daily cap

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

```
  const res = await uaFetch(f)(`${net.gateway}/keys/ephemeral`, {
```

From audit_math. mintChildKey wraps HTTP-status and token-shape failures in PreSendError, but a rejected fetch (DNS, connection refused, its own 15 s AbortSignal.timeout) or a res.json() failure propagates raw. run.ts:229 treats any error that is neither FlopHTTPError nor PreSendError as a timeout, so a call that could not have spent anything is recorded as timeout_unknown with cap_charge = padded quote, the user is told spend is UNKNOWN, and the reservation blocks the cap until a refresh (which will not find a receipt). Fix: wrap the mint fetch and JSON parse in PreSendError, as FlopProvider.complete already does for assertPaidNetwork.

**Reproduction**

Reproduced on 288a03e (vitest). Unit: mintChildKey(network('devnet'), 'parent', {model:'m', maxSpend:0.186}, async () => { throw new TypeError('fetch failed') }) rejects with TypeError, not PreSendError. End to end: seat 9001 cap 0.186, provider whose complete() calls that mintChildKey. Expected: ledger latest refused_local cap_charge 0, next run allowed. Actual: exit 6 'no answer within 60000 ms. Spend is UNKNOWN', ledger latest timeout_unknown cap_charge 0.186, next run exits 3 'daily cap reached (0.186/0.186 FLOP today, UTC)'.

### 12. Info: withLedgerLock releases the lock as soon as an async callback returns its promise; the audit-v3 lock test passes without exercising the lock

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

```
  try { return fn(); }
```

Merged from audit_flow and audit_economics. withLedgerLock is synchronous; with an async fn the finally block releases the lock before any awaited work runs. run.ts:197 passes a synchronous closure, so production is not affected today, but the API gives no protection and test/audit-v3.test.ts:277 passes only because its append runs before the first await. Fix: throw if fn() returns a thenable (or add an awaiting async variant) and rewrite the test so the append happens after the await, or use two processes.

**Reproduction**

Reproduced on 288a03e (vitest). first = withLedgerLock(home, async () => { await hold; appendLedger(home, sentRow /* cap_charge 0.186, seat 9001, mock, holder, today */) }); ledger.jsonl.lock exists right after: false. second = withLedgerLock(home, async () => { spent = spentToday(home,'9001','mock') }); release; await both. Expected with a real critical section: spent 0.186. Actual: spent 0.

### 13. Info: A 64-hex job id passes --job validation but every ledger write rejects it, exiting 5 with a misleading secret-scan error

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

```
  if (!/^[\x21-\x7e]{1,120}$/.test(job)) throw new CliError("--job must be 1-120 printable characters", EXIT.USAGE);
```

Merged from audit_flow and audit_math. `job` is not in PUBLIC_HEX_FIELDS, so a sha256-shaped job id trips the 64-hex rule on the pre-call 'sent' append inside the lock (and on the refused_local append). Nothing is sent or recorded; fail-closed, but the job cannot be served and the message suggests a key leak. Fix: reject such ids at line 119 with a usage error, or allowlist `job` if IMD job ids may be hex digests.

**Reproduction**

Reproduced on 288a03e (vitest). Seat 9001 cap 5: `run --seat 9001 --job ab x32 --prompt 'Say hi' --max-tokens 8 --provider mock --json`. Expected: exit 0 or a usage error (2). Actual: exit 5, 'imd-flop: secret scan blocked writing ledger.jsonl: job: 64-or-more-hex (key-shaped) value', provider.calls 0, ledger.jsonl not created.

### 14. Info: Ledger lock timeout exits with the generic code 1 instead of a 'refused locally, nothing sent' code

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

```
      if (Date.now() - start > 5_000) throw new Error("ledger lock timeout");
```

From audit_economics. takeLock throws a plain Error, so main maps it to EXIT.ERROR (1), the code also used after a gateway HTTP error on a paid call; errors.ts says the codes are stable for scripts. Nothing is sent, so a wrapper that reads 1 as 'spend may have happened' holds the attempt needlessly. The same applies to 'ledger lock contested' (line 110). Fix: throw CliError('refused locally, nothing sent: ledger lock timeout', EXIT.REFUSED_CAP).

**Reproduction**

Reproduced on 288a03e (vitest). Seat 9001 cap 5; write ledger.jsonl.lock = {pid: <a live pid on this host>, host: hostname(), token:'other'}; `run --seat 9001 --job o1 --prompt 'Say hi' --max-tokens 8 --provider mock`. Expected: exit 3 with 'refused locally, nothing sent'. Actual after ~5 s: exit 1, stderr 'imd-flop: ledger lock timeout', provider.calls 0.

---

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