# Audit report

> Review the exact final Claus arbitration correction at commit 1e0c367f4aefb9d8c0ebc95253d9706d8ffa0de0, relative to be5069c2e7cded57f786657a36453afe2aa98f2d and completed-prerequisite job fdf7534f-bfb0-4c8f-9763-c41bcec01d81. This is a necessary delta review of the fixes, not a replay of the prior request. Read docs/deal-audit-response.md. Solidity, caps, recipients, native TS decision host and the underlying payment transaction state machine are unchanged; do not repeat their full test suites. Focus on these changed paths: src/deals/arbitration.mjs, src/deals/decision.mjs, adapters/imd-oracle.mjs, adapters/deal-oracle-proof.mjs, runtime/arbitration-ports.mjs, api/deals.mjs and scripts/prepare-deal-worker.mjs. Verify transient read failures preserve the original case and only observed revocation is terminal; disabled hosts preserve cases and publish disabled health; new admission matches live verified worker escrow targets; explicit allowAmbiguous:null is rejected; quotes are retained before wrapper verification; immutable proof mismatches end visibly without replay; expiry-based reservation release cannot cancel a still-live concurrent submit; signed window fields allow only additional untrusted response metadata. The key-holding host is built with the new minimal packaging script, which includes only api/deals.mjs and dependencies. Public API/model receive no signing key. Explicit retained design: global project-access pause does not cancel admitted financial obligations; the dedicated arbitration capability disable/generation change is terminal. All processing including final signature requires one hour remaining, so three-hour admission offers at most two hours to finish. Eight lifetime cases with one original offer each bound the allocation scan across chains/generations; no reset/refill. Per-principal limits do not prove independent people. Report concrete defects under these current limits, not hypothetical future quota changes. The 13 focused correction checks passed; compiler/source and unchanged contract evidence are preserved. No funding, escrow deployment, native grant or public activation is requested from this review; those remain disabled until assessment and real owned acceptance.

| | |
|---|---|
| Repository | https://github.com/contractclaus/claus-network |
| Commit | `1e0c367f4aefb9d8c0ebc95253d9706d8ffa0de0` |
| Job | `68880fc4-a728-4761-9e94-59e22682a4e9` |
| Judged | 2026-10-14 07:40 UTC |
| Findings | 3 medium · 6 low |

Four agents audited the code as it is at `1e0c367`, 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: Worker handler verifies every pinned chain before reconcile/tick, so one chain's RPC fault halts all admitted cases and the heartbeat

`api/deals.mjs:21`

```
for(const pin of pins){const c=await chain.context(pin.chainId,{fresh:true});targets.push({chainId:pin.chainId,contract:c.public.contract,codeHash:c.public.codeHash});}
```

The correction builds worker-health targets by awaiting chain.context(pin.chainId,{fresh:true}) for every configured pin inside the handler's single try block, before `await ports.reconcile();const result=await deals.tick();` and before the worker-health write on line 22. chain.context -> verified() in runtime/deal-ports.mjs performs getChainId/getBlock/getBytecode/readContract against that chain's RPC with retryCount:0 and throws on any transport error or deal_pin_mismatch. Any throw escapes to the catch on line 23, which answers 503 without having run reconcile, tick or the heartbeat write. Before this delta tick() ran regardless of the other chain and each case only touched its own chain. Consequences under the current limits: admitted mainnet cases (durable financial obligations per docs/paid-deals.md, which says only the dedicated capability disable/generation change or changed onchain state may stop them) do not advance while an unrelated Sepolia RPC is down; expired unsent reservations are not released; after five minutes the stale heartbeat closes admission on both chains; and because all processing needs one hour of margin, an outage on the other chain that outlasts a case's remaining time ends that case permanently with deal_not_reviewable/resolution_expired although its own chain was healthy throughout. Merged from three specialists (audit_math, audit_economics, audit_flow) reporting the same mechanism. Fix: wrap each pin's context() in its own try/catch, publish only the targets that verified (an empty list already reads as unhealthy in workerHealth), and always run reconcile()/tick(); tick's per-case observed() already surfaces that chain's own fault transiently.

**Reproduction**

Executed on this tree with test/scratch/handler.test.mjs (node --experimental-test-module-mocks --test), which imports api/deals.mjs with its infrastructure modules mocked: store = in-memory openStore, CLAUS_DEAL_PINS=[{chainId:1},{chainId:11155111}], CLAUS_DEAL_ARBITRATION_ENABLED=1, dealPorts.context resolving for chain 1 and throwing ECONNREFUSED for chain 11155111, arbitrationPorts.reconcile and createArbitration().tick counting invocations. GET with the cron bearer. Expected: mainnet target published, reconcile and tick run, worker-health written. Actual: response {status:503,body:{error:'deal_worker_unavailable'}}, contexts called [1,11155111], reconcile count 0, tick count 0, store.read('worker-health','deals') === null. Test passes as written, confirming the defective flow.

### 2. Medium: Confirmation-depth shortfall on the worker's RPC raises the terminal oracle_pinned_block_mismatch, forfeiting the deal's single lifetime case before any payment

`runtime/arbitration-ports.mjs:14`

```
requireThat(b.hash===pinned.toBlockHash&&c.block.number>=BigInt(pinned.toBlock)+5n,'oracle_pinned_block_mismatch');
```

This delta added oracle_pinned_block_mismatch to the permanent list in src/deals/arbitration.mjs line 46, but verifyPinned raises that single code for two different conditions: a block-hash mismatch (an immutable fact about the pinned window) and `c.block.number>=toBlock+5n`, a confirmation-depth wait that depends only on which head the worker's RPC currently reports. Admission runs in the public API process at three confirmations (observation.blockNumber = apiHead-2), oracleInput sets toBlock = blockNumber-3 = apiHead-5, so the worker's first tick needs workerHead >= apiHead. The worker is a separate deployment with its own RPC URL and retryCount:0; load-balanced providers routinely report heads one or two blocks apart, and the minute cron can fire within seconds of admission. When it does, provider.quote throws before any payment, tick marks the case blocked, the finally block removes it from active, and because caseId is permanent and request() returns the existing row (`if(old)return publicCase(old.value);`) the agreement can never be reviewed again. This is exactly the kind of transient read the correction set out to preserve cases through. Fix: split the condition so a hash mismatch stays terminal while an insufficient head raises a distinct non-terminal code (or returns oracle-pending) that the next tick retries; the same split applies to the challenge() path in payment.prepare.

**Reproduction**

Executed on this tree with test/scratch/review.test.mjs test G (node --test). State: fixture case admitted with observation.blockNumber '100' (window {fromBlock:96,toBlock:97}, verifyPinned needs head >= 102n); real arbitrationPorts and real createImdOracleProvider wired with a fake dealPorts whose context returns block.number 101n and getBlock returning the correct pinned hash; fake IMD answering /requests/quote 201 with a valid quote and /requests/local-oracle-quote/submit 402 with the exact prepared input. Expected: case stays queued/oracle-pending and retries when the head reaches 102. Actual after one tick: core.read(id) = {state:'blocked',error:'oracle_pinned_block_mismatch'}, provider calls {quote:1,submit:1}, imd-payments empty, deal-case-capacity.active []. With head raised to 105n a further tick leaves it blocked and request(actor,projectId,1,dealId) returns the same blocked case id.

### 3. Medium: Native host HTTP 403 (network_review_disabled: global Claus switch, pins misconfiguration or infrastructure) is classified as permanent native_review_rejected, ending a paid case

`runtime/arbitration-ports.mjs:20`

```
requireThat(![400,403].includes(r.status),'native_review_rejected');
```

The delta newly maps any HTTP 400 or 403 from the Claus native host to native_review_rejected and lists that code as permanent in src/deals/arbitration.mjs line 46; before this commit a 403 fell through to the transient native_review_unavailable. The unchanged native route (reference/deal-review-native/network-review-routes.ts line 21) returns 403 exactly for `network_review_disabled`, which submitNetworkReview raises via dealDecisionRequest (src/deals/decision.mjs line 48) when `config.settings.enabled` is false or `policy.capabilities.networkDealReview` is not true, and via pins() when the native host's CLAUS_DEAL_PINS fails to parse. None of these is the network's dedicated deal-arbitration capability disable or generation change, which docs/paid-deals.md line 64 names as the only terminal operator action for admitted cases; settings.enabled is the host-wide Claus switch and a pins parse error is pure misconfiguration. The GET status route never emits 403 itself, so a 403 on the pre-signing re-read at line 50 can only come from deployment protection or a firewall. A 401 on the same request is still retried, so the two credential/authorization shapes diverge. Each affected case has already paid the IMD oracle fee and consumes one of eight lifetime slots with no refill. Merged from audit_economics and audit_permissions. Fix: treat only 400 (deterministic network_review_invalid_input / proposal_expires / revoked) as terminal; map 403 to native_review_unavailable (transient) like 401, or to a distinct non-terminal 'worker_misconfigured' state that preserves the case.

**Reproduction**

Code-path reproduction on this tree (the native POST is reachable only after a genuine IMD-attester-signed proof, which cannot be produced locally). State: case at claus-pending with oracle paid and attested; native host answers POST /internal/network/reviews with 403 {error:'network_review_disabled'} because config.settings.enabled=false for one minute (or CLAUS_DEAL_PINS on the native host is malformed). Trace: advance() line 41 -> native('',input); fetcher returns status 403; line 20 `requireThat(![400,403].includes(r.status),'native_review_rejected')` throws with code native_review_rejected; tick catch at src/deals/arbitration.mjs line 46 finds that code in the permanent list and saves {state:'blocked',error:'native_review_rejected'}; the finally block removes the id from deal-case-capacity.active. Expected: transient native_review_unavailable (as for 401/5xx) so the case resumes once the host is re-enabled. The terminal effect of any code in that list is demonstrated by the existing test 'fixed invalid oracle proof ends visibly' pattern (advance throwing a listed code -> blocked after one tick). Verified on this tree: reference route line 21 maps network_review_disabled to 403; decision.mjs line 48 raises it from settings.enabled/networkDealReview; network-deal-review.ts line 8 raises it from a pins parse failure; git diff be5069c..1e0c367 shows the 403 branch is new.

### 4. Low: Deterministic faults in the checkpointed oracle artifact raise codes outside the permanent list and replay every tick until deal_not_reviewable

`src/deals/arbitration.mjs:46`

```
}catch(e){const permanent=['deal_not_reviewable','deal_state_changed'
```

The correction classifies terminal failures by an allow-list of codes. verifyDealOracle runs every tick against the checkpointed, never-refetched oracle object, so every failure it raises is deterministic, yet several of its codes are not listed: invalid_oracle_id from uuidWord() when the provider requestId or request.jobId is not a lowercase UUID (adapters/deal-oracle-proof.mjs line 7; the provider id() validator accepts [A-Za-z0-9_-]{1,128} so such values are admitted upstream); invalid_integer, invalid_json, value_too_deep, value_too_large from canonical() over provider-supplied consumer/definitions/guards/types/domain; and a viem error with no .code at all when attestation.signature is malformed (line 19). For all of these tick() treats the failure as transient: only lease:null is saved, state stays oracle-pending with error null, and every minute observed() chain reads, payment.reconcile (a provider /requests/{id} call) and the same local failure repeat until resolutionDue-3600, when the case is blocked with the unrelated deal_not_reviewable. docs/deal-audit-response.md states 'Deterministic proof/input failures end visibly'. Merged from audit_math, audit_economics and audit_permissions. Fix: in verifyDealOracle wrap uuidWord/canonical/recover failures and rethrow as oracle_attestation_mismatch / oracle_signature_invalid, or classify any error raised after the oracle artifact is checkpointed as permanent (equivalently invert to an explicit transient list).

**Reproduction**

Executed on this tree with test/scratch/review.test.mjs test D. (1) A fully valid fixture attestation (recover stubbed to IMD_ATTESTER) passes verifyDealOracle with workerAmount '600'; the same input with request.jobId='job-123' rejects with e.code==='invalid_oracle_id'; with requestId 'ABCDEFAB-1111-1111-1111-111111111111' rejects with e.code==='invalid_oracle_id'; with attestation.signature='0x1234' and the default recover rejects with e.code===undefined. (2) An admitted fixture case whose ports.advance throws {code:'invalid_oracle_id'} (and separately an Error with no code) is ticked three times: advance called 3 times, core.read(id) = {state:'queued',error:null}, case still active. Expected per the documented correction: blocked with a visible error after the first attempt and no further provider calls.

### 5. Low: Retained provider quote is never checkpointed on the case, so a post-expiry or persistent 4xx challenge rejection replays every minute with no visible error

`adapters/imd-oracle.mjs:95`

```
const secret=sealer.open(key,intent.sealed),challenge=await call('/requests/'+id(quote.id)+'/submit',{method:'POST',token:secret.requestToken,statuses:[402]});
```

The correction persists the quote in imd-orders before the wrapper challenge (line 92) so a transient challenge failure keeps the original provider ID. But the case-level offer checkpoint in runtime/arbitration-ports.mjs line 26 happens only after provider.quote returns, i.e. after the /submit challenge succeeds, and quote() never checks the persisted quote's remaining validity before re-issuing the challenge. call() turns any non-402 answer into provider_request_rejected, which is not in the permanent list. So once the retained quote has lapsed (quotes are at most 600 s) or the provider deterministically refuses the challenge (410/409/404), every tick re-sends the same rejected challenge and the public case stays 'queued' with error null. The documented expiry termination `requireThat(offer.expiresAt>now()+60000,'oracle_quote_expired')` (line 29) is unreachable because v.offer was never set; the only exit is deal_not_reviewable when the dispute window drops under one hour, up to two hours later, with one provider round-trip per minute. docs/deal-audit-response.md says expired cancellations are reported as expiry. Merged from audit_math and audit_flow. Fix: in quote(), when intent.quote exists require intent.quote.expiresAt*1000 > now()+60000 and throw oracle_quote_expired otherwise before issuing the challenge; alternatively checkpoint {id,expiresAt} on the case as soon as the quote is persisted, or classify provider_request_rejected with a 4xx httpStatus as terminal.

**Reproduction**

Executed on this tree with test/scratch/review.test.mjs test H. State: admitted fixture case; real arbitrationPorts + createImdOracleProvider with a fake IMD answering /requests/quote 201 with a valid 600 s quote, the first /submit throwing a socket error, every later /submit answering 410 {error:'quote_expired'}. Tick 1: calls {quote:1,submit:1}, deal-cases row state 'queued' with no offer field, imd-orders holds quote id 'local-oracle-quote'. Clock advanced 700 s, three more ticks: calls {quote:1,submit:4}, core.read(id) = {state:'queued',error:null}, case still in deal-case-capacity.active. Expected: the case ends visibly with oracle_quote_expired after the quote's own expiry with the original order retained and no further provider calls.

### 6. Low: validateQuote has no minimum remaining validity, so a quote already inside the 60 s processing margin is bound as the case's single immutable offer and then permanently expired

`adapters/imd-oracle.mjs:60`

```
requireThat(quote.expiresAt > quote.issuedAt && quote.expiresAt - quote.issuedAt <= 600 &&
```

validateQuote only requires expiresAt > issuedAt, a total validity of at most 600 s, and issuedAt not more than 5 s in the future; there is no lower bound and no check of expiresAt against the current time (issuedAt may be arbitrarily far in the past). advance() in runtime/arbitration-ports.mjs then checkpoints the offer as the case's one immutable offer (line 26), reserves the subsidy (line 27), and only afterwards requires offer.expiresAt > now()+60000 (line 29), throwing oracle_quote_expired, which this delta lists as permanent. The case is therefore blocked before any payment intent exists, request() returns the existing blocked case forever, and the slot in the eight-case lifetime allocation is consumed (total never decrements). The trigger is provider-side (a quote with <= 60 s validity, or an idempotent re-issue of a stale order for the same requestKey) or latency-induced. The acceptance check and the processing check use different bounds, and the invariant 'a bound offer is usable' fails in the gap. Fix: in validateQuote require quote.expiresAt >= Math.floor(now()/1000) + 120 (covering the 60 s advance margin and the 60 s prepare margin) before persisting or binding the quote, so an unusable quote is rejected without consuming the case.

**Reproduction**

Executed on this tree with test/scratch/review.test.mjs test E. State: admitted fixture case, billing funded with 0.5 token, real arbitrationPorts + provider, fake IMD returning a well-formed quote with issuedAt=now and expiresAt=now+45 and a matching 402 challenge, fake chain head 102n. One tick. Expected: the quote is rejected before binding (or at least the case is not terminally blocked before any payment intent). Actual: calls {quote:1,submit:1}; deal-cases row has offer.id 'local-oracle-quote' with expiresAt (now+45)*1000; deal-oracle-allocations billing 'reserved'; core.read(id) = {state:'blocked',error:'oracle_quote_expired'}; imd-payments empty; capacity total 1; a later ports.reconcile() releases the hold ('released-unsent'); request(actor,projectId,1,dealId) returns state 'blocked'.

### 7. Low: invalid_provider_expiry is terminal although its clock-skew clause is time-relative and the same order validates on the next tick

`adapters/imd-oracle.mjs:61`

```
quote.issuedAt <= Math.floor(now() / 1000) + 5, 'invalid_provider_expiry');
```

This delta added invalid_provider_expiry to the permanent list in src/deals/arbitration.mjs line 46. validateQuote raises that single code for a structural bound (expiresAt > issuedAt, validity <= 600 s) and for the skew clause issuedAt <= now+5, i.e. whenever the provider's clock is more than five seconds ahead of the worker's. Validation runs before the store write on line 92, so a failed quote is not persisted; the next tick re-POSTs /requests/quote with the same requestKey, receives the same order, and by then the local clock has advanced so the same quote passes. The failure is time-transient, not an observed authority change or an immutable artifact fault, yet the case is blocked permanently and its lifetime slot is consumed without any review or payment. With a consistently skewed provider clock every admitted case burns on its first tick. Fix: split the skew check into a separate retryable code (keep only the structural expiresAt-issuedAt bound permanent), or remove invalid_provider_expiry from the permanent list.

**Reproduction**

Executed on this tree with test/scratch/review.test.mjs test F. State: admitted fixture case; fake IMD returning a quote with issuedAt=now+6 and expiresAt=issuedAt+600. One tick: core.read(id) = {state:'blocked',error:'invalid_provider_expiry'}, calls {quote:1,submit:0}, imd-orders quote still null, capacity total 1. The same fetcher through createImdOracleProvider with the same clock rejects provider.quote with /invalid_provider_expiry/, and after advancing the clock 2 s the identical order is accepted (offer.id 'local-oracle-quote'). Expected: a non-terminal failure retried on the next tick.

### 8. Low: workerHealth compares escrow codeHash case-sensitively while pins accept mixed-case hex, so differently-cased pin configs refuse all admission while discovery reports enabled

`src/deals/arbitration.mjs:16`

```
t.contract?.toLowerCase()===target.contract.toLowerCase()&&t.codeHash===target.codeHash
```

The new target match lowercases contract but compares codeHash with ===. The worker's published targets (api/deals.mjs line 21 copies c.public.codeHash, which runtime/deal-ports.mjs sets to entry.pin.codeHash verbatim) and the public API's c.public.codeHash each come from that deployment's own CLAUS_DEAL_PINS string; deal-ports validates codeHash with /^0x[0-9a-fA-F]{64}$/ and compares it to keccak256(code) with toLowerCase(), so mixed-case values are valid, chain-verified configuration in both deployments. If the private signer host and the public API carry the same hash in different letter case, every request() fails deal_worker_unavailable, while discovery() calls workerHealth(store) without a target and still reports enabled:true, so clients are told admission is open. Merged from audit_math, audit_economics and audit_flow. Fix: compare t.codeHash?.toLowerCase()===target.codeHash.toLowerCase() (or normalise pins to lowercase at load) and pass the same target set to discovery so it reflects admissibility.

**Reproduction**

Executed on this tree with test/scratch/review.test.mjs test B. State: worker-health 'deals' = {observedAt:now,enabled:true,targets:[{chainId:1,contract,codeHash:'0x'+'AB'.repeat(32)}]}; dealPorts.context returns public.codeHash '0x'+'ab'.repeat(32); capability enabled. discovery().enabled === true, while request(actor,projectId,1,dealId) rejects with /deal_worker_unavailable/ and deal-case-capacity stays null. Expected: both agree, and since the hashes denote the same verified code, admission proceeds.

### 9. Low: Worker health targets do not prove the worker holds the escrow's arbiter key, so a misconfigured worker admits cases that can only fail after the oracle spend

`api/deals.mjs:21`

```
for(const pin of pins){const c=await chain.context(pin.chainId,{fresh:true});targets.push({chainId:pin.chainId,contract:c.public.contract,codeHash:c.public.codeHash});}
```

New admission requires the worker heartbeat to list the case's {chainId, contract, codeHash}. The worker builds that list from chain.context, which checks the contract's immutable arbiter against pin.arbiter, but nothing compares the worker's own CLAUS_DEAL_ARBITER_KEY account to that arbiter: arbitrationPorts (runtime/arbitration-ports.mjs line 13) only requires the payment and arbiter signers to differ. The only check, fresh() at runtime/arbitration-ports.mjs line 21 (c.public.arbiter === arbiterSigner.address), runs after the IMD oracle has been paid and attested and after the Claus native review completed. A worker with the wrong arbiter key therefore publishes matching targets, admits cases, spends the oracle subsidy and Claus review, and then blocks each case permanently with the misleading deal_state_changed. This undercuts the correction's claim that unhealthy targets cannot admit new cases. Fix: in api/deals.mjs push a target only when c.public.arbiter.toLowerCase()===arbiterSigner.address.toLowerCase() (or include arbiter in the target and compare it in workerHealth), and report the mismatch with its own error code.

**Reproduction**

Code-path reproduction on this tree. State: worker CLAUS_DEAL_PINS=[{chainId:1,contract:C,codeHash:H,arbiter:A,...}] and CLAUS_DEAL_ARBITER_KEY for address B != A (B also != payment signer). Trace: arbitrationPorts line 13 accepts the configuration (only payment != arbiter is checked); api/deals.mjs line 21 verifies each pin via chain.context (onchain arbiter A equals pin.arbiter A) and pushes {chainId:1,contract:C,codeHash:H} with no reference to arbiterSigner (grep: arbiterSigner appears on line 20 only); worker-health {enabled:true,targets:[...]} is written; src/deals/arbitration.mjs line 30 workerHealth(tx,c.public) matches and admits the case; advance() pays IMD (lines 27-33), fetches the attestation (line 37), gets a Claus approval (lines 41-47), and only then fresh(v) at line 51 throws deal_state_changed because c.public.arbiter (A) != arbiterSigner.address (B); tick line 46 lists deal_state_changed as permanent. Expected: the worker reports itself unhealthy or omits the target so admission is refused before any spend.

---

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