# Audit report

> Audit the FLOP spend path of uxp-labs/flop-devnet-lab at this commit.
> Scope: imd-flop/ (TypeScript CLI that lets an IMD worker pay FLOP for inference), spend-guard/ (Python), viewer/ (Python) and contract/ (shared rules and test vectors). Leave observatory/ and tclk-rehearsal/ out.
> Work offline and read only: no network, no faucet, no keys, no paid calls. Do not run as root: one imd-flop test needs a read only file.
> Run first: in imd-flop, npm ci then npm test (295 tests). In spend-guard and in viewer, python3 -m unittest discover -s . -p 'test_*.py' (66 and 59 tests).
> Look for:
> 1. Any path in imd-flop/src/run.ts, providers.ts or gateway.ts that mints a child key or sends a paid call after a local refusal, or after the run deadline.
> 2. Any receipt or refresh that lowers a booked charge or releases a reservation without a proven settlement (settlement.ts, ledger.ts, receipts.ts, contract/vectors).
> 3. 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 (secrets.ts, store.ts, gateway.ts).
> 4. Any case where imd-flop, spend-guard and the viewer disagree on a contract vector, or on how unknown spend is shown.
> 5. Whether findings 1 to 7 of the earlier audit are closed at this commit (its report: https://api.imd.fun/jobs/ba164ffa-1c3e-4183-93bd-4a11d24b76bd/report.md), and whether the refactor that moved the rules into shared modules (settlement.ts, policy.ts, receipt-store.ts) changed any refusal or booking decision.
> For every finding give file and line, severity, impact and a reproduction that runs offline. Say which checks you ran and which you only read.

| | |
|---|---|
| Repository | https://github.com/uxp-labs/flop-devnet-lab.git |
| Commit | `8ddc5d2ba2e523e3230a86b6f259dd6125f8aa57` |
| Job | `769d5aeb-83f2-4d1b-b7d1-2549abca21c1` |
| Judged | 2026-11-11 01:49 UTC |
| Findings | 1 medium · 9 low · 3 info |

Four agents audited the code as it is at `8ddc5d2`, 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 books a settlement from a receipt body the secret scan had to redact; run holds the reservation for the same read

`imd-flop/src/receipt-store.ts:84`

```
  const next = refreshBooking(prev, { fresh, identityOk, state: saved?.state ?? null, settled: saved?.settled });
```

run.ts:265-267 states the rule 'a receipt body that had to be redacted never proves a settlement' and passes identityOk && !receiptRedacted into nextBooking, so a fresh identity-matched settling receipt whose body carried a key-shaped or bearer-shaped string keeps the padded reservation (state sent, spend null, cap_charge 0.186). refreshReceipt writes the same receipt through writeReceipt (which redacts and returns redacted: true, line 80) but drops the flag and hands refreshBooking identityOk unchanged. The shared settlement module therefore receives two different ReadOutcomes for one and the same gateway read depending on which command performed it, and the laxer path is the unattended one (receipt --refresh against a sent or timeout_unknown row). The booked charge drops from 0.186 to 0.05 and 0.136 of daily cap is freed on exactly the read run declined. The receipt file is identical after both operations (settled 0.05, settled_raw null), so a reader cannot tell which rule applied. Test TS-L6 (test/audit-v7.test.ts:171) pins the refresh side and the run.ts:266 comment pins the run side; the refactor that moved the rule into settlement.ts did not carry run's redaction qualifier into receipt-store.ts. Merged from audit_flow, audit_economics and audit_math (same mechanism, same line). Fix: return writeReceipt's redacted flag into refreshReceipt and pass identityOk: identityOk && !redacted to refreshBooking, as run.ts does (or, if refresh is meant to settle, drop the qualifier in run.ts and pin one rule with a shared vector).

**Reproduction**

Offline, vitest with a stub Provider against src/ (imd-flop/test/scratch/judge.test.ts case A, 12/12 passed). Seat 9001, cap set 1, run --seat 9001 --job j1 --prompt 'Say hi' --provider mock --max-tokens 8 --json with readReceipt returning http 200 {request_id: <own rid>, correlation_id: 'imd-job-j1', model: 'm', reserved_spend: 0.186, settled_spend: 0.05, settled_spend_raw: 'ab'x32, state: 'settled'}. Observed after run: latest ledger row {state: 'sent', spend: null, cap_charge: 0.186}; receipt file {state: 'settled', settled: 0.05, settled_raw: null}. Then receipt <rid> --refresh --json with the identical read. Expected (run's rule): no row appended, reservation 0.186 held. Actual: exit 0, stderr empty (no DISAGREE), one row appended, latest {state: 'settled', spend: 0.05, cap_charge: 0.05}.

### 2. Low: A later 'released' read with settled_spend 0 overwrites an already booked settlement with 0, and released/0 silently wins over a higher cost reported by the completion

`imd-flop/src/settlement.ts:61`

```
  const regresses = SETTLES.has(prev.state) && !(o.state !== null && SETTLES.has(o.state));
```

nextBooking blocks a move from a settling state back to a non-settling one, but settled -> released is allowed because both are in SETTLES, and settledSpend accepts an explicit 0 in either state. (1) A row booked settled/0.05 is rewritten to released/spend 0/cap_charge 0 by one later fresh read, freeing 0.05 of daily cap on the word of a receipt that contradicts the previous settlement, with no DISAGREE. (2) In run, a receipt in state released with settled_spend 0 books cap_charge 0 even when the completion's own usage.cost_flop said 0.15; the ledger records cost_reported 0.15 next to spend 0 and neither run --json (match 'matches'), receipt <id>, nor the viewer (compares ledger spend to receipt settled, both 0) surfaces the contradiction. Impact is bounded because the gateway is the payee, but the local ledger then under-reports spend relative to what the completion reported and the daily cap admits more. Brief item 2. Fix: treat settled -> released as a regression (keep the booking, DISAGREE), and flag a settlement below cost_reported.

**Reproduction**

Offline, pure functions and stub provider (imd-flop/test/scratch/judge.test.ts case G). nextBooking({state:'settled', spend:0.05}, {fresh:true, identityOk:true, state:'released', settled:0}, 0.05) -> {state:'released', spend:0, cap_charge:0}; refreshBooking({state:'settled', spend:0.05, cap_charge:0.05}, same outcome) -> {kind:'append', booking:{state:'released', spend:0, cap_charge:0}}. End to end: seat 9001 cap 1, run with complete() returning usage {cost_flop:0.15} and readReceipt http 200 {request_id:<rid>, state:'released', settled_spend:0, reserved_spend:0.2}: exit 0, run --json receipt.match {match:'matches'}, latest ledger row {state:'released', spend:0, cap_charge:0, cost_reported:0.15}. Second home: run with a receipt settled 0.05, then receipt <rid> --refresh with a read released/0: exit 0, no DISAGREE, latest row {state:'released', spend:0, cap_charge:0}. Expected: booking kept (or DISAGREE) in both cases.

### 3. Low: Daily cap is checked against a 6-decimal rounded spend total, so the exact sum of booked charges can exceed the cap

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

```
  return round6(sum);
```

sumCharges adds the raw cap_charge of every latest row and returns Math.round(sum*1e6)/1e6. Settled spends are booked unrounded (settlement.ts:63 cap_charge: settled ?? hold), so stored charges may carry more than 6 decimals while checkBudget (policy.ts:103-105) compares the padded quote with daily - round6(sum). Up to 5e-7 FLOP of real charge disappears from each check, so a call is admitted when the exact total already leaves less than its padded quote, and the ledger's own rows then sum above the cap. Dust-sized and bounded by one padded quote, but it breaks the invariant 'sum of today's cap_charge <= daily cap' that spend-guard enforces with an exact Fraction compare (guard.py:150-155), and the viewer's daily_left (r6 on a decimal string) agrees with neither. Fix: compare with the unrounded sum, or round spentBefore up (ceil6) rather than to nearest.

**Reproduction**

Offline, stub provider (imd-flop/test/scratch/judge.test.ts case I). Seat 9001, cap set 1, payer holder. Runs 1 and 2: estimate {0.066, 3, 8} (padded 0.186), readReceipt settles 0.4999996 each: both exit 0, booked cap_charge 0.4999996 each (exact total 0.9999992). Run 3: estimate {estimated_cost_flop:1e-8, estimated_input_tokens:3, estimated_output_tokens:8} (padded 1e-6), receipt settles 1e-6. Expected: exit 3 since 0.9999992 + 1e-6 > 1. Actual: exit 0; the run-3 row has spent_before 0.999999 and cap_charge 0.000001; checkBudget({padded:1e-6, cap:{daily:1, perCall:1}, payer:'holder', spentBefore:0.999999}) returns null; the exact sum of the three latest cap_charge values is 1.0000002. Only run 4 is refused ('daily cap reached (1/1 FLOP today, UTC)').

### 4. Low: The prompt is posted to the public estimate endpoint before the 'daily cap reached' refusal; spend-guard refuses before the estimate

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

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

When the seat's holder-paid spend for the UTC day already equals or exceeds the daily cap, no quote can be accepted: checkBudget (policy.ts:103-104) refuses with 'daily cap reached' regardless of the padded amount. imd-flop nevertheless calls provider.estimate() first, which for --provider flop is an unauthenticated POST of the full system+user prompt to <gateway>/models/estimate (gateway.ts postEstimate), and only then takes the ledger lock and refuses (run.ts:173-174). spend-guard applies the opposite rule for every refusal that does not depend on the quote (guard.py:108-110: 'Refuse before the estimate so the prompt does not leave the machine for nothing'). Nothing is minted or paid, but IMD job material leaves the holder's machine on a day the holder's policy has already switched FLOP off. requireCap and validatePayer already run before the estimate (run.ts:120-121); the 'left <= 0' case can run there too (spentToday >= cap.daily, re-checked under the lock as today). Per-call and 'over the remaining cap' refusals need the quote and are unchanged.

**Reproduction**

Offline, stub provider that counts estimate() calls (imd-flop/test/scratch/judge.test.ts case L). init --seat 9001, link --token 9001 --agent 7, cap set 0.186 (stub quote 0.066/3/8 pads to 0.186, so one call uses the whole day). Run 1: run --job j1 --prompt 'Say hi' --max-tokens 8 -> exit 0, row estimated cap_charge 0.186. Run 2 with a fresh counting provider: run --job j2 ... -> exit 3, stderr 'refused locally, nothing sent: daily cap reached (0.186/0.186 FLOP today, UTC)'. Expected: estimate() called 0 times (decidable from the ledger alone, as spend-guard does). Actual: estimate() called 1 time with the prompt before the refusal.

### 5. Low: Viewer applies the seat's daily cap to requester-paid rows using the job-budget accumulator, so it flags allowed calls and never shows a job-budget breach

`viewer/build-lane.py:106`

```
    return r6(daily - spent_before - cap_charge) if None not in (daily, spent_before, cap_charge) else None
```

imd-flop writes daily_cap on every row but counts only holder-paid rows against it (ledger.ts:78 spentToday filters payer === 'holder'); for a requester row spent_before is this job's requester spend (run.ts:168 jobSpent), not today's holder spend, and checkBudget (policy.ts:99-113) never applies the daily cap to a requester row. build-lane.py daily_left() ignores payer and job_budget and computes daily_cap - spent_before - cap_charge for every row; over_limit() (line 115) then raises the amber 'over the daily cap' chip and build_row (line 238) the 'over the daily cap' spend flag on calls imd-flop admitted correctly. Scale mismatch: a job-budget accumulator is subtracted from a daily cap. A real job-budget breach (spent_before + settled > job_budget) is never shown. Brief item 4; the vectors and viewer tests only contain payer holder rows, so nothing pins this. Merged from audit_economics and audit_math. Fix: for payer requester show job budget left (job_budget - spent_before - cap_charge) and no daily-cap flag, matching imd-flop, and add a requester vector.

**Reproduction**

Offline, viewer/lane_harness.py (imd-flop/test/scratch/judge_viewer.py cases H). LED with payer='requester', job_budget=5, daily_cap=1, spent_before=0.93, cap_charge=0.186, state='sent' and REC state 'estimated', http 200 -> row daily_left -0.116, spend_flags ['over the daily cap'] on a pending row imd-flop accepted. Control: payer requester, job_budget=5, daily_cap=100, spent_before=4.9, cap_charge=0.2, settled 0.2 (job spend 5.1 > budget 5) -> chips [['amber','over the reservation']], daily_left 94.9, no budget flag. imd-flop side (judge.test.ts case M): cap set 0.2, config set payer requester, five runs --job-budget 1 all exit 0 and cap show --json reports spent_today_mock 0, so imd-flop never applies the daily cap to these rows. Expected: no daily-cap flag or chip on requester rows.

### 6. Low: Viewer labels a settled receipt with unknown amount 'receipt and ledger disagree' while imd-flop books it as settled and reports 'matches'

`viewer/build-lane.py:124`

```
        d = st not in SETTLES or settled is None or amount(e.get("spend")) != settled
```

Contract vector run-settled-null-spend pins imd-flop: a fresh identity-matched receipt in state settled with settled_spend null books {state:'settled', spend:null, cap_charge:0.186} (nextBooking moves the state, keeps the hold); match.ts then reports 'matches' and receipt <id> prints 'ledger: state settled, spend' (blank). The viewer, given the row and receipt file imd-flop wrote, folds settled is None into the disagree test and renders an amber DISAGREE chip and row text 'receipt and ledger disagree; reservation held, chain-final green blocked'. Receipt and ledger agree (both say settled, amount unknown); the hold and no-green outcome is right, but the two parts give the holder contradictory words for one state. Brief item 4; viewer-labels.json has no settled/null case. Fix: a viewer label for 'settled, amount unknown (reservation held)', or keep imd-flop's state at sent until an amount is known; pin whichever with a shared vector.

**Reproduction**

Offline. imd-flop (judge.test.ts case B): seat 9001 cap 1, run with readReceipt http 200 {request_id:<rid>, state:'settled', settled_spend:null} -> exit 0, run --json receipt.match {match:'matches'}, latest ledger row {state:'settled', spend:null, cap_charge:0.186}; receipt <rid> prints 'state=settled  reserved=  settled=  ... match=matches' and 'ledger: state settled, spend '. Viewer (judge_viewer.py case B): lane_harness.lane([LED with state 'settled', spend None, cap_charge 0.186], {'req-1': REC with state 'settled', settled None, http 200, error None}) -> chips [['amber', DISAGREE]], flags [DISAGREE], flop [['reservation', 0.186], ['observed spend', null]]. Expected: one label for the same state in both parts.

### 7. Low: Viewer turns a negative cap_charge or spent_before into extra daily headroom and a negative held reservation; imd-flop refuses the next call on the same row

`viewer/build-lane.py:104`

```
    spent_before, cap_charge, daily = amount(e.get("spent_before")), amount(e.get("cap_charge")), amount(e.get("daily_cap"))
```

amount() (line 38) accepts any finite number including negatives, and daily_left subtracts it. A ledger row with cap_charge -0.3 under a 0.5 daily cap renders 'daily cap left 0.8' (above the cap itself), no spend flag and 'reservation -0.3 (held)'; spent_before -1 renders 'daily cap left 1.302'. imd-flop's usableCapCharge (ledger.ts:64-65) rejects a negative charge and run refuses the next call with 'ledger cap_charge unusable; next call refused' (exit 3); spend-guard's spend_amount treats negatives as unknown. rules.json Q-AMOUNT-JSON records that amount() accepts negatives, but not that the daily-cap arithmetic turns an unusable row into a credit, against the viewer's own rule 'unknown spend is blank, never 0'. Fix: treat a negative amount as unknown in amount() (or at least in daily_left and the reservation line) and update the amount-json-negative vector's viewer expectation.

**Reproduction**

Offline, viewer/lane_harness.py (judge_viewer.py cases C). lane([LED with state 'sent', spend None, cap_charge -0.3], {'req-1': REC with http 200, error None}) -> row daily_left 0.8, spend_flags [], chips [['grey','sent'],['amber','gateway observed']], flop contains ['reservation', -0.3]. With cap_charge 0.198 and spent_before -1 -> daily_left 1.302. Expected: daily cap left blank or a flag, reservation blank, never a negative held amount. Counterpart: the same row in an imd-flop ledger makes run exit 3 'ledger cap_charge unusable' (ledger.ts:71).

### 8. Low: status aborts with a raw JSON parse error when any one receipt file is unreadable

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

```
    .map((f) => readJson<Classified>(join(dir, f))!)
```

listReceipts parses every *.json under receipts/ with a non-null assertion and no try/catch. One corrupt file (a hand edit, a partial copy, a stray file) makes imd-flop status exit 1 with the parser message and no seat, cap, spend, link or pending information at all; the message does not name the file. receipt <id> and run keep working, so the worker's one overview of today's spend is the only command lost. The viewer handles the same input as a red 'unreadable' row (build-lane.py:79-81). Fix: catch the parse error per file and list that file as an unreadable receipt, keeping the rest of status.

**Reproduction**

Offline (judge.test.ts case D). init --seat 9001, link --token 9001 --agent 7, cap set 1, one run --provider mock (exit 0), then write '{not json' to <home>/receipts/mock-9001-deadbeef.json. imd-flop status --json -> exit 1, stderr "imd-flop: Expected property name or '}' in JSON at position 1 (line 1 column 2)", stdout empty. receipt <rid> on the good receipt still exits 0. Expected: exit 0 with the readable receipts and the bad file named.

### 9. Low: Earlier finding 6 only partly closed: a child-key mint failure (nothing sent, charge 0) and a gateway HTTP error on the paid call (sent, spend unknown, reservation held) both exit 1

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

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

The fix for finding 6 of the earlier audit gives EXIT.REFUSED_RULE (5) only to PaidGateError. Every other PreSendError from providers.ts complete() (mintChildKey transport error, non-201 status such as 401 api_key_expired or 503, unparsable mint body, wrong token shape, an abort between gate and mint) still exits EXIT.ERROR (1), and run.ts:213 also exits 1 for a FlopHTTPError on the completion itself, which books state 'error' with the padded quote held. The ledger rows distinguish the two (refused_local / cap_charge 0 vs error / cap_charge held); the exit code does not, which was the stated impact of the original finding. contract vector run-pre-send-refusal pins exit 1 for the generic pre-send case, so the pin documents the ambiguity rather than the fix. Merged from audit_flow and audit_economics. Fix: exit EXIT.REFUSED_RULE (or a dedicated code) for every PreSendError and move the vector with it.

**Reproduction**

Offline, stub provider (judge.test.ts case E). Seat 9001 cap 1. (a) complete() throws new PreSendError('child key mint HTTP 503'): exit 1, stderr 'imd-flop: nothing sent: child key mint HTTP 503', latest ledger row {state:'refused_local', cap_charge:0}. (b) complete() throws new FlopHTTPError(500, {}, null): exit 1, stderr 'imd-flop: gateway refused the call (HTTP 500); spend recorded as unknown', latest row {state:'error', cap_charge:0.186}. Expected: distinct exit codes for 'nothing sent' and 'sent, spend unknown'. Actual: both 1.

### 10. Low: reserved_spend is not held to the amount rule: negative, NaN and Infinity reservations print as numbers on stdout and disagree with spend-guard and with the stored file

`imd-flop/src/receipts.ts:64`

```
    reserved: typeof r?.reserved_spend === "number" ? r.reserved_spend : null,
```

The receipt rule (receipts.ts header, rules.json Q-AMOUNT-JSON, spend-guard spend_amount) is that a spend counts only when finite and >= 0, otherwise it is unknown and shown blank. settled_spend goes through knownSettledSpend; reserved_spend does not: any JS number passes, including -0.5, NaN and Infinity. receiptLine (line 86) then prints 'reserved=-0.5', 'reserved=NaN' or 'reserved=Infinity' on stdout for run, status and receipt, while the receipt file stores JSON null for NaN/Infinity and -0.5 for the negative. spend-guard classify_receipt (guard.py:85,90) reports reserved None and lists reserved_spend under invalid for the same input. Booking is unaffected (settlement.ts does not use reserved), so this is a display-rule and cross-part consistency defect on a gateway-controlled field (brief item 4); amount-json.json only exercises settled. Fix: route reserved_spend through knownSettledSpend (or a shared knownAmount) and add a reserved vector.

**Reproduction**

Offline (judge.test.ts cases J). classifyReceipt({request_id:'r', state:'estimated', reserved_spend:v, settled_spend:v}, {job:'j', provider:'mock'}) for v in -0.5, NaN, Infinity -> reserved -0.5 / NaN / Infinity, settled null; receiptLine prints 'reserved=-0.5', 'reserved=NaN', 'reserved=Infinity'; JSON.stringify gives reserved -0.5 then null for NaN and Infinity. End to end: seat 9001, run with readReceipt http 200 {request_id:<rid>, state:'estimated', reserved_spend:-0.5, settled_spend:null} -> run --json receipt.reserved -0.5, receipt <rid> prints 'reserved=-0.5', receipt file reserved -0.5. spend-guard: guard.classify_receipt({'request_id':'r','state':'estimated','reserved_spend':-0.5}) -> reserved None, invalid ['reserved_spend']. Expected: reserved null and 'reserved=' blank, as for settled.

### 11. Info: padQuote accepts negative token counts and inflates the pad; spend-guard refuses the same quote (unpinned cross-part disagreement)

`imd-flop/src/policy.ts:17`

```
  if (typeof value !== "number" || !Number.isFinite(value)) return null;
```

finiteTokenCount only requires a finite number, so estimated_input_tokens / estimated_output_tokens may be negative. padQuote (lines 36-38) then divides raw by a shrunken tin+tout and pads by 20*raw/(tin+tout), up to many multiples of raw. spend-guard nonnegative_count (guard.py:50-56) rejects negatives and refuses the quote. The direction is safe for imd-flop (a larger reservation, more local refusals, max_cost_flop raised to the inflated value) but the two parts answer differently for the same gateway estimate, and rules.json Q-QUOTE-COUNTS does not list negative counts, so contract/vectors/quote.json does not pin it. Fix: one shared rule (refuse counts < 0, as spend-guard does) pinned with a vector.

**Reproduction**

Offline (judge.test.ts case K, judge_viewer.py case K). padQuote({estimated_cost_flop:0.066, estimated_input_tokens:-3, estimated_output_tokens:8}) -> {raw:0.066, padded:0.33}; padQuote({estimated_cost_flop:0.066, estimated_input_tokens:3, estimated_output_tokens:-2}) -> {raw:0.066, padded:1.386} (21x raw). spend-guard with the same estimate stubbed via guard_harness.run_guard(estimate, '1000', ['--user','Hi','--max-tokens','8','--cap','1000']) -> decision refused_local, quote_padded null, returncode 3; guard.nonnegative_count(-3) is False.

### 12. Info: Pinned residual R-e: a 0x-prefixed 64-hex value in a pre-send provider error is stored as [hash] in the ledger but printed raw on stderr

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

```
    let reason = timedOut ? `${err.name}: no answer; spend unknown, not retried` : preSend ? `nothing sent: ${err.message.replace(/0x[0-9a-fA-F]{64}/g, "[hash]")}` : `HTTP ${http}`;
```

Brief item 3 (key-shaped values reaching stderr). The hash stripping is applied only to the ledger reason; stderr gets err.message unchanged unless preSendWithheld is true. preSendWithheld runs scanForSecrets on the already-stripped reason, so a loaded secret, a bare 64-hex run, a Bearer or flop_ token do trip it and are replaced by a fixed message on both channels (earlier finding 4 closed, verified by reading secrets.ts). Only a 0x-prefixed 32-byte hash that is not a loaded secret reaches stderr raw. The project pins this as current behaviour (contract/rules.json quirk R-e; vector run-pre-send-hash-on-stderr asserts stderr_contains the raw hash), and the realistic sources are genesis hashes in PaidGateError text, which are public and which the operator needs to see. Reported for completeness, not as a new defect. Merged from audit_flow and audit_permissions.

**Reproduction**

Offline (judge.test.ts case F). Seat 9001 cap 1, stub provider whose complete() throws PreSendError('child key mint failed (rpc said 0x' + 'cd'x32 + ')'). Observed: exit 1, latest ledger row {state:'refused_local'} with reason 'nothing sent: child key mint failed (rpc said [hash])', and stderr contains the full 0xcdcd...cd string. The existing contract vector run-pre-send-hash-on-stderr (imd-flop/test/contract.test.ts) asserts the same.

### 13. Info: Trust assumption: payer=requester removes the seat's daily cap; the only limits are the per-call cap and a budget the caller supplies per job id, while the FLOP still comes from the holder's gateway k

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

```
  return sumCharges(latestAttempts(h).filter((e) => e.seat === seat && e.provider === provider && e.payer === "holder" && e.day === day));
```

Documented in imd-flop/README.md 'Payer assumption' and recorded as a trust assumption, not a bypass. With config set payer requester, spentToday counts holder rows only, so checkBudget's daily branch (policy.ts:102-106) is never reached; the requester branch compares the padded quote with --job-budget minus this job id's requester spend. Both the payer switch and the budget are set by whoever runs the CLI, and every new --job id starts with a fresh budget, so there is no per-seat or per-day bound in this mode except per_call_cap. The gateway bills the seat's own enrolled key in both modes. Anyone able to run the CLI as the holder (for example the IMD worker agent the tool serves) can spend without a daily bound by setting payer=requester, which is the same privilege as editing config.json or cap set. Material only if the holder expects the daily cap to bound the seat's total outflow irrespective of payer. A minimal change that keeps the model: count requester rows against the seat's daily cap as well, or add a second holder-set ceiling.

**Reproduction**

Offline (judge.test.ts case M). init --seat 9001, link --token 9001 --agent 7, cap set 0.2 (daily 0.2, per-call 0.2), config set payer requester. Five times: run --job job-<i> --prompt 'Say hi' --max-tokens 8 --job-budget 1 (padded quote 0.186 each). Observed exit codes [0,0,0,0,0]; five rows for seat 9001 on one UTC day with cap_charge 0.186 each (0.93 FLOP reserved against a 0.2 FLOP/day cap); cap show --json reports spent_today_mock 0. Expected under a per-seat daily bound: the second run refused with exit 3.

---

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