# Audit report

> Review only the final Phase3 recovery delta from 024c414799661334cdc530a16dcf8ecaa1f43593 to 7661efa9ad06c5b740ad364e37a244d00356c59a in contractclaus/claus-network. Previous six independent reviews are already retained; do not repeat the entire repository audit. Relevant paths: runtime/chain-worker.mjs passes the already configured Sepolia client and Mainnet client to createApplication, so internal signal consumer validation uses the same intended chains. src/approvals/limits.mjs permits an operator-only expiring per-project daily allocation up to the EXISTING project lifetime ceiling, never changing counters, global/lifetime limits, caller scopes or monetary limits. Allocation records are private operator configuration with no public write endpoint. For this release only one extra Sepolia reference attempt is planned: daily maximum 3, today only; two prior attempts have terminal verified zero-cost provider timeout outcomes and no approval. test/public-decision-admission.test.mjs covers concurrent admission and unchanged caps. docs/agent-review-transport.patch changes only the internal agent's HTTP transport/deadlines to its existing research timing; no model, prompt, quote ceiling, saved request, decision schema or signing behavior changes. Two real model calls timed out at the prior 240s provider deadline; canonical relay proof marks both authorizations expired and unused, accounting zero. New transport: provider600s, caller650s, reservation760s, missing-request fence800s. The actual model still makes the decision; there is no operator-forged approval, no automatic new model retry, and unknown requests remain held. Evaluate this precise delta for real privilege, accounting, recovery or chain confusion issues. No contract, token, custody, public inference, fee or source-verification change. Report concrete actionable findings; distinguish trusted-operator policy from unprivileged caller control. Project/global lifetime and global daily caps must remain unchanged.

| | |
|---|---|
| Repository | https://github.com/contractclaus/claus-network |
| Commit | `7661efa9ad06c5b740ad364e37a244d00356c59a` |
| Job | `9a4e3e44-adba-4049-909c-6bb039268284` |
| Judged | 2026-10-13 11:55 UTC |
| Findings | 1 low · 3 info |

Four agents audited the code as it is at `7661efa`, 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. Low: Operator allocation record format (key derivation, millisecond expiresAt, numeric day) is undocumented and any mismatch silently falls back to the default cap, so the planned single extra attempt can

`src/approvals/limits.mjs:14`

```
    Number.isSafeInteger(allocation.expiresAt) && allocation.expiresAt > now && allocation.expiresAt <= (day + 1) * 86400000 &&
```

Merged from four specialist reports (audit_economics, audit_permissions, audit_flow, audit_math), which all describe the same mechanism. The allocation gate itself is correct and bounded: the record is read at id digest({projectId, day}) and accepted only when projectId matches, day is the numeric UTC day index Math.floor(now/86400000), expiresAt is a millisecond epoch inside that day, and maximumProjectDay is an integer in [2,4]; lifetime caps, the global daily cap (8), counters and caller scopes are untouched (verified by the delta's tests and the full suite: 481 tests, 470 pass, 0 fail). No public endpoint writes 'public-decision-allocations': the only reference in src/, interfaces/, runtime/, adapters/ is the read in limits.mjs; portability import writes only its own fixed collections. This is therefore trusted-operator policy, not unprivileged-caller control. The defect is operational: the record must be hand-written into the document store (no script, admin route, CLI or validator exists), and the only documentation, docs/operations.md line 126, says 'keyed by the digest of {projectId, day}' and 'expiresAt must fall within that UTC day' without stating that the key is 'sha256:' + sha256 of canonical sorted compact JSON (src/shared/index.mjs digest/canonical), that day is an integer day index rather than a date, or that expiresAt is in milliseconds. Every other operator-facing expiresAt in this repository's public docs is Unix seconds (signal_grant_prepare in docs/openapi.json, docs/signals.md). Every mismatch is indistinguishable at runtime from 'no allocation': the third attempt is rejected with decision_capacity_reached and retryAt = next UTC day, with no event, log, health field or error detail saying an allocation row exists but was ignored. For this release the recovery plan is exactly one extra Sepolia reference attempt today; a mis-formatted record means that attempt does not happen and the operator learns only from the generic cap error. Fail-closed, so no privilege, accounting or chain impact. Fix: document the exact key derivation (canonical sorted compact JSON with the sha256: prefix, worked example), the integer day index and the millisecond unit in docs/operations.md, and preferably add an operator-only helper analogous to admitSignalPublisherBudget in adapters/signal-publisher.mjs that validates the shape with shape()/integer() and derives the key itself. Optionally expose whether an allocation row for the project/day exists but fails validation (worker-health field or error detail) so an ineffective record is visible before the attempt is spent. The guard itself should stay as it is.

**Reproduction**

Run against this tree with an in-memory sqlite store (src/storage/index.mjs openStore(':memory:')), now = 1800000000000 (day index 20833), projectId 'reference'. Correct key is digest({projectId:'reference', day:20833}) = 'sha256:03f3ef0ec6ec881876dce00388a2b80698b6c9d60abc132ba1292a983d8de959'. For each variant: write the allocation record, call publicDecisionCapacity(tx,'reference',now,true) in three separate transactions. Observed output of the script: (1) canonical record {projectId:'reference',day:20833,expiresAt:1800057600000,maximumProjectDay:3} at the correct key -> third admitted. (2) same record with expiresAt:1800057600 (Unix seconds, end of day) -> third rejected: decision_capacity_reached retryAt 1800057600000. (3) day:'20833' (string) at digest({projectId,day:'20833'}) -> third rejected identically. (4) correct record at the id without the 'sha256:' prefix -> third rejected identically. (5) correct record at 'sha256:' + sha256(JSON.stringify({projectId,day})) (unsorted, non-canonical) -> third rejected identically. (6) expiresAt:1800057600001 (1 ms past the day end) -> third rejected identically. Expected per docs/operations.md ('daily maximum 3 today'): third attempt admitted, or a distinguishable indication that the allocation is ineffective. Actual: silent fallback to the two-attempt default with the same error and retryAt as having no allocation at all; the refused transaction consumes no admission count.

### 2. Info: No test exercises the chain-worker application wiring this delta fixes; reverting it to chainPorts: { chain } leaves the suite green

`runtime/chain-worker.mjs:47`

```
  const app = createApplication({ store, origin: env.CLAUS_NETWORK_ORIGIN, rateSalt: env.CLAUS_NETWORK_RATE_SALT, chainPorts: { chain, client, mainnetClient } });
```

The change is correct and introduces no chain confusion: createApplication (runtime/server.mjs) builds the signal router from chainPorts.client for Sepolia and chainPorts.mainnetClient for mainnet, createSignalRead rechecks client.getChainId() === chainId on every read (adapters/signal-read.mjs correct()), and createChainRead does the same (adapters/chain-read.mjs correctChain()). Before this commit the worker passed only { chain }, so signals.consumer -> chain.assertTrackedStore fell back to the hard-coded public Sepolia RPC instead of the operator's verified archive RPC and a mainnet-bound project had no mainnet signal reader; 'signal_chain_unavailable' is not in runDecision's blocked list (src/approvals/index.mjs lines 177-179), so such a decision retried every 60 s indefinitely. None of this is asserted by a test. test/chain-recovery-guards.test.mjs only calls createChainWorker with invalid environments and expects 'historical_rpc_required' (it throws before line 47); test/chain-integration.test.mjs constructs createApplication with chainPorts: { chain } only, the same shape that was wrong in the worker. Fix: factor the chainPorts construction out of createChainWorker so a test can build it from fake clients reporting chain ids 11155111 and 1 and assert that signals.consumer for a mainnet-bound project reads through the mainnet client, or have createApplication reject a chainPorts.chain router supplied without matching client/mainnetClient.

**Reproduction**

Extracted 7661efa into a scratch directory (git archive, outside the repository), changed line 47 back to the 024c414 form `chainPorts: { chain }`, linked node_modules and ran `node --test test/*.test.mjs`. Result: tests 481, pass 470, fail 0, identical to the unmodified tree. Expected: at least one test fails when the worker's signal router is not bound to the configured clients. Actual: the regression is invisible to the suite.

### 3. Info: Worker mainnet client omits cacheTime: 0, unlike the Sepolia client and readonly-ports, so head reads it now also serves to the signal reader are cached for up to 4 s

`runtime/chain-worker.mjs:41`

```
  const mainnetClient = configuredMainnet ? createPublicClient({ transport: http(env.CLAUS_MAINNET_RPC_URL, { retryCount: 0, timeout: 15000 }) }) : null;
```

Pre-existing line (commit 48c611ad, an ancestor of 024c414), but this delta newly hands the same mainnetClient to createApplication for mainnet signal reads. The Sepolia client on line 32 and runtime/readonly-ports.mjs line 9 both pass cacheTime: 0; this one does not, so viem uses its default cacheTime = pollingInterval = 4000 ms (node_modules/viem/_esm/clients/createClient.js line 12) and getBlockNumber results are reused within that window. The same client object is used by the chain-1 executor (adapters/chain-executor.mjs lines 114 and 262 compute finality as head - receipt.blockNumber >= 2n from getBlockNumber) and by chain-read's observedBlock (adapters/chain-read.mjs line 99). Effect is conservative: a head up to 4 s stale can only delay a finality decision, never advance it, and chain identity is still verified on every read. Consistency item only; no privilege, accounting or chain-confusion impact. Fix: add cacheTime: 0 to this createPublicClient call to match the other two.

**Reproduction**

Script using the vendored viem (2.57.1) with a custom transport that counts eth_blockNumber requests: createPublicClient({transport}) then two getBlockNumber() calls -> 1 RPC request (second served from cache); createPublicClient({transport, cacheTime: 0}) then two getBlockNumber() calls -> 2 RPC requests. Expected: the worker's mainnet client behaves like its Sepolia client and readonly-ports (uncached). Actual: mainnet head reads are cached for the 4000 ms default.

### 4. Info: Agent transport patch cannot be verified from this tree: the new inferenceFetch module is absent, the only test mocks it out, and the deadline ordering rests on payments-side constants not present her

`docs/agent-review-transport.patch:6`

```
+import {inferenceFetch} from './inference-transport.js';
```

Merged from three specialist reports (audit_economics, audit_permissions, audit_flow). Unverified lead rather than a confirmed defect, because the agent and payments sources are not in this repository. What is checkable here: the patch keeps Idempotency-Key, X-Max-Quote-Micro-USDC 2000000, the saved request body and the decision/finish path unchanged, adds the X-Inference-Purpose: research header, raises the caller abort from 270000 to 650000 ms and the not_found fence from 300000 to 800000 ms, and swaps the default fetch for inferenceFetch from ./inference-transport.js. That module appears nowhere in this tree (grep for 'inference-transport' matches only this patch line), and the patch's test injects a mock fetcher, so inferenceFetch is never executed by any test shown. The assignment's claims 'no automatic new model retry' and 'unknown requests remain held' therefore depend on an unreviewed transport: if it retried on network error or 5xx, a second POST /api/completion with the same Idempotency-Key would reach the relay and idempotency would rest solely on relay-side behaviour not shown here. Three further timing dependencies are outside this tree: (a) the 650 s wait must run under an agent function budget of at least that (this repository's vercel.json limits api/gateway.mjs to 60 s and api/compile.mjs to 120 s; the agent's own maxDuration is not inherited from here), otherwise the catch that moves the row to 'uncertain' never runs; (b) the 800 s fence is measured from row.started_at (set at claim) while the 760 s reservation starts at relay admission, leaving a 40 s margin, and it is sound only if the relay never answers 404 not_found for a request it admitted; (c) the ordering provider 600 < caller 650 < reservation 760 < fence 800 is asserted by the patch's test against completionTiming(false,'research') in payments/vendor/src/response.js, which is not here. On the claus-network side the change is safe regardless: adapters/claus-controller.mjs polls the agent with 25 s calls, re-POSTs /reviews only on 404 or an elapsed queued retryAt, treats failed as terminal, and admission is consumed once per proposal at creation, so a longer agent window cannot consume extra admission from this repository. Fix: include agent/src/inference-transport.ts in the reviewed delta (or quote its retry policy), add one agent test that drives runNetworkReview through the real inferenceFetch against a failing local server and asserts exactly one POST, record the agent function's maxDuration beside the deadlines, and confirm in the payments vendor that /api/requests/{id} returns a terminal status rather than 404 for every admitted id.

**Reproduction**

At 7661efa, `grep -rn inference-transport . --exclude-dir=node_modules --exclude-dir=.git` returns only docs/agent-review-transport.patch:6, and `grep -rn completionTiming` returns only the patch's test lines 47 and 59; no X-Inference-Purpose handling exists in this repository. Applying the patch to an agent tree without agent/src/inference-transport.ts fails at import resolution; applying it to a tree that has it changes the transport to code no reviewer of this delta has seen. Expected: a delta claiming 'only transport/deadlines changed, no automatic retry' contains or references the transport implementation and a test exercising it. Actual: the module is missing and the only test mocks it out. Not reproducible as a runtime defect from this tree.

---

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