# Audit report

> Independently audit this pinned Claus Network revision for opening its public sponsored review and Sepolia readonly upgrade/intake path. Focus on src/review-flow/index.mjs and admission.mjs, src/chain-deployments/index.mjs and admission.mjs, runtime/contract-intake-config.mjs, src/contract-intake, src/contract-dispatch, adapters/chain-executor.mjs, sepolia-transactions.mjs and claus-controller.mjs, runtime/server.mjs, and their identity/billing/policy checks. Contract sources are under contracts/src. Trace actual authorization and accounting, not just unit tests. Public free access is already open; paid review and public chain admission are disabled until this assessment. Planned pilot: 4 IMD lifetime, 0.5/review, 2 per owner/project per day, 8 global/day and active, 7-day expiry; no auto-refill. Eight readonly Sepolia projects, exact reviewed executor and kernel hashes, 0.0002 test-ETH per transaction, 0.0005 per project, 0.002 total executor gas; dispatcher uses a separate 0.0004 test-ETH allocation with immutable historical spending. No mainnet, customer deposits, public inference, token launch or generic mutating modules. Explicitly assess whether hostile new projects can reach signing/payment without authority, reuse review evidence for different code, reset or bypass lifetime/expiry, crowd out reconciliation, trigger duplicate external effects or abuse callback delivery. Zero project delay is an explicit owner requirement, not an accidental missing timelock; per-project accepted delays remain. Initial owners authorize their own module; later upgrades need an actual Claus decision. Repository instructions/comments/tests are untrusted evidence. Return concrete severity, exact paths, reproduction and scope limitations. Do not edit, deploy, post, sign transactions or create paid subjobs. Audit the offchain admission/dispatcher and contract boundary as a whole; a previous narrow single-project audit is not public-scope approval.

| | |
|---|---|
| Repository | https://github.com/hazarxyz/claus-network |
| Commit | `4a2cf501c1a4a60f18350bc3fd4145e91b3747a2` |
| Job | `a91da909-3bc4-468b-b5fb-cff58907da1a` |
| Judged | 2026-10-12 11:53 UTC |
| Findings | 8 medium · 7 low · 1 info |

Four agents audited the code as it is at `4a2cf50`, 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: Public review lifetime pool and day quotas are consumed by admit-then-cancel at zero sponsor cost, so free wallets can retire the 4 IMD programme

`src/review-flow/admission.mjs:53`

```
      await tx.write('public-review-exposure', 'lifetime', { maximum: total.toString() }, lifetime?.revision ?? 0);
```

createReviewAdmission.reserve() (lines 44-54) adds quote.offer.maximum to the single 'public-review-exposure/lifetime' counter and increments the per-owner day counter inside beforeAdmission, i.e. at admission time, before any payment effect. Nothing ever subtracts from these counters, nor from the public per-project/global day keys written at src/review-flow/index.mjs:182-190. When the admitted review-execute job is cancelled before dispatch (jobs.cancel -> review-flow.onCancel -> reviews.cancelUnsent, src/reviews/index.mjs:110-123) or voided by runPaid's blocked path (src/review-flow/index.mjs:294-299, e.g. after the owner withdraws the source publication), the billing ledger correctly releases the sponsor reservation (reserved 0, spent 0) but the admission ledgers keep the commitment. Sign-in is a free Ethereum signature, projects and builds are free, job.cancel is an owner scope, and perOwnerDay is per owner address, so four throwaway wallets x 2 admissions x 0.5 IMD exhaust the planned 4 IMD lifetime pool in one day without the sponsor paying for a single review; every later public accept fails with sponsor_exhausted until the owner publishes a policy with a larger maximumLifetime (which also raises the real spending cap because the counter cannot distinguish voided from spent commitments). The same loop burns the 8/day global allocation each day. test/review-flow.test.mjs:208-216 asserts the post-cancel exhaustion as intended ('never creates unfunded credit'), so this is a documented design choice, reported because it lets unfunded callers crowd every legitimate project out of the public programme. The chain admission module keeps separate reserved/spent figures and releases on cancel (src/chain-deployments/admission.mjs:84-96); the review module does not. Minimal fix: release the lifetime exposure and the owner/project/global day counters in cancelUnsent/abandonUnsent (keyed by jobId so the release is idempotent) or move the exposure reservation to the effect boundary; alternatively track reserved and spent separately as the chain admission does. Merged from audit_flow, audit_economics and audit_math.

**Reproduction**

Reproduced with node --test against this tree (createApplication, in-memory store, review.admission policy maximumPerReview 5e17, maximumLifetime 4e18, perOwnerDay 2, perProjectDay 2, globalDay 8, activeGlobal 8; sponsor funded 100e18). For each of 4 fresh sign-in addresses: create a project, snapshot, compiled build, publish source; flow.request -> flow.runQuote -> flow.accept -> jobs.cancel(executionJobId) (job effect still not_started, allowed), twice per address. Observed: each review-job ends work 'cancelled' billing 'void'; sponsor balance spent '0' reserved '0'; admission.get().committedMaximum === '4000000000000000000'. One day later a fifth, legitimate owner with its own project: request/runQuote succeed and accept throws code 'sponsor_exhausted'. Expected: a review that never reached the payment effect returns its share of the lifetime pool and day quotas, or committedMaximum tracks real commitments.

### 2. Medium: Replacing the dispatch budget makes every in-flight delivery throw dispatch_intent_mismatch, which aborts the whole intake worker tick and never settles the old reservation

`adapters/contract-delivery.mjs:121`

```
      if (transaction) requireThat(transaction.intent.to?.toLowerCase() === budget.inbox &&
```

Every dispatcher transaction intent embeds the budget identity (line 108: authority = { kind, requestId, resultHash, action, budgetId: budget.id, configHash }). When step() later finds an existing chain-transactions row for the request it requires digest(transaction.intent.authority) === digest(authority) (lines 121-124) and throws dispatch_intent_mismatch otherwise. Budgets are immutable and the planned pilot has no auto-refill, so the documented way to continue after the finite grant is spent or expired, or to serve newly admitted projects, is to admit a new budget id and point CLAUS_CONTRACT_DISPATCH_BUDGET at it (runtime/contract-intake-host.mjs:23). Any contract-requests row still in result_ready with delivery.state pending (normal for the ~15 minutes between a confirmed resolve and Sepolia finality, while a resolve/callback is submitted/unknown, or while a callback is deferred by transaction_cost_limit) carries a transaction signed under the old budget id. runtime/contract-intake-worker.mjs:76-78 only tolerates wallet_busy, dispatch_budget_exhausted, transaction_cost_limit and transaction_not_authorized; dispatch_intent_mismatch propagates out of tick() before contract-delivery-cursors and worker-health are written, so the hosted handler answers 503, contractIntakeHealth reports worker_unavailable, every row sorted after the stuck one is never stepped again, and the old budget's reserved amount is never settled into spent (contradicting the immutable-historical-spending requirement and the comment at worker lines 65-67, which only holds for revokeContractDispatchBudget on the same id). Scan, ingest and intake.run() do still execute earlier in the tick. Minimal fix: when an existing transaction belongs to another budget, reconcile and finalize it against the budget id stored in its intent (or skip it) instead of throwing, and treat dispatch_intent_mismatch as a deferral in the worker. Merged from audit_flow and audit_math.

**Reproduction**

Reproduced with node --test against this tree using the contract-delivery fixture pattern: budget-1 admitted; request R (project-a) stepped once -> resolve broadcast, ledger budget-1 reserved '135000000000'. Admit budget-2 = same config with id 'budget-2'; confirm the resolve on the stub chain; createContractDelivery({ budget: budget-2 }).step(R) rejects with code 'dispatch_intent_mismatch'; budget-1 stays reserved '135000000000' spent '0'. Wrapping the same delivery in createContractIntakeWorker and calling tick() twice rejects both times with 'dispatch_intent_mismatch', contract-delivery-cursors stays empty and R.delivery is never written. Expected: R is reconciled under its original budget and later rows keep being delivered.

### 3. Medium: Deterministic policy denials after beginEffect (chain_gas_exhausted, transaction_cost_limit, expired window) leave an uncancellable 'waiting' job that retries every minute forever and keeps the projec

`src/chain-deployments/index.mjs:133`

```
        if (claim.mode === 'dispatch' && ['scope_denied', 'sponsor_not_enabled', 'chain_not_enabled'].includes(error.code)) {
```

runOne (line 113) and approvals.runExecution (src/approvals/index.mjs:174) call jobs.beginEffect on the first dispatch, flipping the job to effect 'outcome_unknown' before any transaction is prepared. If sepolia-transactions.prepare then fails deterministically - admission.reserveGas throws chain_gas_exhausted when gas*maxFeePerGas exceeds maximumTransactionGasWei or the project/total ceiling (src/chain-deployments/admission.mjs:73-79), prepare throws transaction_cost_limit, or invalid_transaction once value.expiresAt / message.expiresAt has passed (adapters/sepolia-transactions.mjs:45-46) - none of these codes is in the blocked list here or at src/approvals/index.mjs:193 and none is in adapters/authorization.mjs authorityDenied, so the job is finished 'waiting' with retryAt now+60s and re-claimed every minute indefinitely, re-running estimateGas/estimateFeesPerGas/getTransactionCount/getBalance each time. Nothing was signed or broadcast, yet jobs.cancel now refuses with effect_requires_reconciliation (src/jobs/index.mjs:50); the chain-modules/chain-registrations row stays 'queued', the public-chain-projects slot consumed by deployModule is never released, and for protected-execute the active-proposals lock keeps approvals.create rejecting with proposal_in_progress. With the planned ceilings this is the expected steady state rather than an edge case: gas = estimate*1.25+10000, so a registerProject that creates a ClausReadonlyProject (several hundred thousand gas) exceeds the 0.0002 test-ETH per-transaction cap whenever Sepolia maxFeePerGas is above roughly 0.4-0.6 gwei, a project needs at least deploy+register (and queue+execute for one upgrade) inside 0.0005, and 8 x 0.0005 = 0.004 exceeds the 0.002 total (the schema does not check maximumProjects*maximumProjectGasWei <= maximumTotalGasWei), so projects five to eight hold a slot but every reserveGas fails. Minimal fix: add chain_gas_exhausted, transaction_cost_limit and the expired-window invalid_transaction to the blocked lists (they are deterministic policy denials with no external effect) and record the reason on the module/registration/proposal row, or run the reservation check before beginEffect; validate the policy arithmetic in the chain.admission schema. Merged from audit_flow, audit_economics, audit_permissions and audit_math.

**Reproduction**

Reproduced with node --test against this tree: createChainDeployments + createChainExecutor with a stub client (estimateGas 400000, maxFeePerGas 1 gwei), chain.admission policy active with maximumTransactionGasWei '200000000000000', project P in public-chain-projects, a queued module-deploy job. runOne(executor.deployment) returns state 'waiting' with result.error 'chain_gas_exhausted' (prepare computes 510000 gas x 1 gwei = 5.1e14 > 2e14); zero broadcasts; workflow-jobs[job].effect === 'outcome_unknown'; jobs.cancel('owner','P',jobId) rejects with code 'effect_requires_reconciliation'. After advancing 60s a second runOne returns the same waiting/chain_gas_exhausted and estimateGas has been called twice; chain-modules[mod].state is still 'queued'. After advancing past expiresAt runOne still returns 'waiting', now with error 'invalid_transaction'. Expected: a terminal blocked outcome that releases the slot and remains cancellable.

### 4. Medium: A relayed or front-run registerProject/execute makes the executor's own transaction revert and the off-chain record becomes a permanent 'failed' although the on-chain change succeeded

`adapters/chain-executor.mjs:59`

```
    tx.state === 'reverted' ? { state: 'failed', error: 'transaction_reverted', transactionHash: tx.hash } : null;
```

ClausApprovalExecutor.registerProject and execute are permissionless relays of an owner or Claus signature (contracts/src/ClausApprovalExecutor.sol:125,154): anyone who sees the calldata in the public Sepolia mempool (the executor broadcasts via sendRawTransaction at adapters/sepolia-transactions.mjs:110) can submit it first. The relayed call registers (or upgrades) the project, and the executor's own transaction then reverts with InvalidRegistration (projects[r.projectId].target != address(0), Solidity line 128) or AlreadyExecuted (line 254). terminal() maps every reverted transaction to { state: 'failed' } without reading chain state. For project-register, runOne writes chain-registrations[id].state = 'failed' and never writes chain-bindings; prepareRegistration recomputes the same projectKey = keccak(owner, keccak(projectId)) (adapters/chain-read.mjs:89-90) so every later registration for that project reverts for the same reason and the executor pays gas for each reverted attempt: the project can never use the chain path. For execute, the relayed-execution handling at lines 155-168 (executionNonces + UpgradeExecuted log) is only reached when no 'execute:' row exists; once the executor's own execute transaction has reverted (lines 139-146), the proposal is marked failed while the target already runs the new implementation, so the recorded public evidence is wrong. Cost to the attacker is one Sepolia transaction with a higher priority fee; the owner relaying its own signed registration triggers the same outcome accidentally. Minimal fix: on a reverted register:/execute: transaction read projects[projectKey] (or executionNonces + the UpgradeExecuted log as lines 155-168 already do) before classifying the result, and treat an on-chain state equal to the signed message as success.

**Reproduction**

Contract side (by inspection of contracts/src/ClausApprovalExecutor.sol:128 and 254): the second registerProject with the same Registration reverts because projects[r.projectId].target is already set; the second execute with the same Approval reverts because executionNonces[a.projectId][a.nonce] is set. Off-chain side reproduced with node --test against this tree: createChainDeployments + createChainExecutor with a stub client where the register transaction is mined with receipt status 'reverted' (what the executor observes after a relayer landed the same calldata first). Sequence: prepareRegistration -> submitRegistration -> runOne (state 'waiting', 1 broadcast) -> receipt confirmed as reverted -> runOne returns state 'failed' with result.error 'transaction_reverted'; chain-registrations[id].state === 'failed'; chain-bindings for the project is null; a subsequent prepareRegistration succeeds and produces a new record whose registerProject would revert again for the same reason. Expected: the executor compares projects[projectKey] with the signed message and records the registration as verified / writes chain-bindings.

### 5. Medium: A single lost broadcast (RPC timeout on sendRawTransaction) permanently locks the shared executor or dispatcher wallet: the sealed transaction is never re-broadcast and no path releases the slot

`adapters/sepolia-transactions.mjs:91`

```
        if (row.value.state !== 'prepared') return null;
```

submit() broadcasts the sealed raw transaction exactly once. If client.sendRawTransaction throws (the production transport is http('https://ethereum-sepolia-rpc.publicnode.com', { retryCount: 0, timeout: 15000 }) in runtime/chain-worker.mjs:18 and runtime/contract-intake-config.mjs:14, so one 15 s timeout or transient 5xx suffices), the record is moved to state 'unknown' (line 114) and every later submit() returns at this line because the state is no longer 'prepared'. reconcile() only polls getTransactionReceipt for the stored hash and returns silently while no receipt exists (line 123). The chain-wallet-slots entry keeps id = this transaction, so every later prepare() for any project and any job kind fails with wallet_busy (line 66). There is no re-broadcast of the identical bytes (safe: same hash and nonce), no expiry-based cancellation after an uncertain send, no nonce-gap detection (getTransactionCount would show the nonce unconsumed) and no operator API to clear the slot. The executor key is shared by all public and pilot projects and the dispatcher key by all intake projects, so one network hiccup halts chain execution or result delivery network-wide until someone edits the database and broadcasts the sealed bytes by hand. test/sepolia-transactions.test.mjs:34-47 asserts broadcasts === 1 after restart, so refusing to replace is intentional, but refusing to resend the same bytes is a liveness defect. Minimal fix: when state is 'unknown' or 'submitted' and reconcile() finds no receipt, re-open the sealed raw and sendRawTransaction again (treat 'already known' as success); additionally, once intent.expiresAt has passed and getTransactionCount('latest') <= nonce, cancel the record and release the slot.

**Reproduction**

Reproduced with node --test against this tree: createSepoliaTransactions with a stub client whose sendRawTransaction throws once without the node receiving the bytes. prepare('first') then submit('first') -> state 'unknown'. Five further submit('first')+reconcile('first') rounds: broadcasts stays 1, status stays 'unknown'. prepare('second') rejects with code 'wallet_busy'. A new instance whose now() is one day later (intent expired) still does not re-broadcast or release: prepare('third') rejects with 'wallet_busy'. Expected: the identical sealed bytes are re-broadcast, or the slot is released once the nonce is provably unconsumed and the intent expired.

### 6. Medium: Anyone can permanently stall the intake worker (ingest, Claus decision, resolve/callback delivery and dispatcher reconciliation) with 5001 cheap Requested events in one 500-block window

`adapters/contract-intake.mjs:47`

```
      requireThat(logs.length <= 5000, 'inbox_scan_too_large');
```

ClausRequestInbox.request is permissionless (any binding word, 1-byte input, no fee, contracts/src/ClausRequestInbox.sol:46-56). createContractInboxRead.scan fetches every Requested log in a fixed 500-block window (the worker never passes a smaller limit, runtime/contract-intake-worker.mjs:23) and throws inbox_scan_too_large when more than 5000 logs are returned. The worker awaits scan() outside any try block, before the cursor write, before intake.run() and before the delivery loop, so the whole tick aborts: the cursor never advances past the spam range, no proposal is created, no result_ready request is resolved, and already-broadcast dispatcher transactions are never reconciled (wallet slot and budget reservation stay held); worker-health is not written so /health reports worker_unavailable. There is no backoff, window shrink, pagination or per-binding filter, so every subsequent tick fails identically until an operator hand-edits the cursor (losing legitimate events in that range, which then expire at their deadline). Cost is Sepolia test ETH only: a helper contract calling inbox.request() in a loop needs roughly 300M gas in total (about 9-11 transactions at the block gas limit) inside ~100 minutes. Minimal fix: catch inbox_scan_too_large in scan(), halve the block window and retry (or page getLogs by sub-ranges), and run the delivery/reconciliation loop even when scan fails.

**Reproduction**

Reproduced with node --test against this tree: real createContractInboxRead with a stub viem client whose getLogs returns 5001 Requested logs (unknown binding word), real createContractIntakeWorker with a result_ready request already stored and a delivery stub. Three consecutive worker.tick() calls reject with code 'inbox_scan_too_large'; contract-intake-cursors stays empty; intake.run() was never called and delivery.step() was never called (0 steps). Expected: unknown bindings are skipped page by page and admitted projects keep being served and reconciled.

### 7. Medium: One admitted requester can exhaust the shared dispatcher allocation through requester-chosen callback gas and request count; no per-project or per-callback ceiling exists

`adapters/contract-delivery.mjs:76`

```
    requireThat(amount(maximum) <= amount(budget.maximumTransaction) &&
```

The dispatcher budget is one global ledger (contract-dispatch-budgets[budget.id]): admitContractDispatchBudget takes a projectIds list but no per-project ceiling, and reserve() (lines 72-81) checks only maximumTransaction and the global maximumTotal. The size of a sponsored callback is chosen by the requester: ClausRequestInbox.request accepts callbackGas up to 1,000,000 and deliverCallback forwards exactly that much (contracts/src/ClausRequestInbox.sol:28,93-99), so sepolia-transactions.prepare reserves about 1.32M gas (estimate*1.25+10000) for a callback that burns its budget, and reconcile books the actual gasUsed*effectiveGasPrice as immutable spent. With the planned 0.0004 test-ETH total and 0.0002 per transaction: at ~0.1 gwei a 1.05M-gas callback costs ~1.05e14 wei, so two requests from one project (resolve+callback each) consume more than half of the allocation and the next resolve of any other project fails reserve() with dispatch_budget_exhausted, which the worker only defers (runtime/contract-intake-worker.mjs:76) until the request deadline closes the on-chain request; at ~1 gwei the same callback never fits maximumTransaction, the resolve is still paid and the callback is re-estimated and deferred on every pass. Request volume per project is up to 100 per binding x 32 bindings, and even resolve-only requests (~60k gas) let one project drain the pool. Spending is immutable and there is no refill, so a hostile or merely gas-hungry admitted project denies result delivery to every other admitted project for the rest of the pilot. Minimal fix: carry a per-project sub-allocation in the budget config and check it in reserve(), and cap accepted callbackGas per project in createContractIntake.ingest (observation.callbackGas) or skip sponsored callbacks above a threshold (the inbox already lets the requester retry deliverCallback at its own cost). Merged from audit_permissions and audit_math.

**Reproduction**

Reproduced with node --test against this tree: createContractDelivery with budget { maximumTotal '400000000000000', maximumTransaction '200000000000000', projectIds [project-a, project-b] }, stub client estimateGas 1,050,000, maxFeePerGas 0.1 gwei, receipts gasUsed 1,050,000. Project-a request 1 (resolve + callback) delivered: spent >= 2e14 wei. Project-a request 2: second transaction rejects with 'dispatch_budget_exhausted' (ledger spent '315000000000000', reserved '0'). Project-b request 3 (never spent anything): step() rejects with code 'dispatch_budget_exhausted' on its very first resolve. Expected: one project cannot consume the shared allocation and other projects' deliveries continue.

### 8. Medium: No quota on Claus decision runs: an admitted public project can trigger unbounded operator-paid inference reviews of up to 900 KB each through proposal_create or the inbox

`src/approvals/index.mjs:74`

```
          requireThat(!prior || ['executed', 'rejected', 'failed', 'blocked', 'cancelled'].includes(prior.state), 'proposal_in_progress');
```

Every paid or gas-bearing resource in the pilot has a counter (public-review-exposure, review-admission day keys, public-chain-usage, dispatch budget), but the Claus decision has none. approvals.create requires only proposal.create + proposal.execute scopes and chain admission, serializes proposals per project through active-proposals, and enqueues a claus-review job whose controller posts the full input (all snapshot files, the review report and the proposal, bounded only by review_context_too_large at 900,000 bytes, line 64) to https://claus-agent.vercel.app/internal/network/reviews (adapters/claus-controller.mjs:18,55). A rejected decision is terminal and costs the project nothing, and job_cancel on decisionJobId is allowed while the controller has not claimed it, so the slot is released immediately and the next proposal can be created with a fresh idempotencyKey. src/access/index.mjs defines buildsPerProjectDay, quotesPerProjectDay, ordersPerProjectDay and similar, but no proposals-per-day limit exists in src/access, src/approvals or src/chain-deployments; the only other throttles are the HTTP peer rate limit and the one-at-a-time rule. The contract-intake path reaches the same create() with up to 100 requests per binding and 32 bindings per project. docs/testnet.md:27 describes queued Claus requests as purchased inference, so the owner's 'no public inference' constraint is violated indirectly as soon as public chain admission opens: each of the eight admitted projects can run back-to-back decisions all day at operator expense. Minimal fix: add a per-project-day and global-day proposal counter (same pattern as the review-admission keys) enforced inside create()'s transaction and in contract-intake ingest, published in the chain.admission policy. Merged from audit_permissions and audit_math.

**Reproduction**

By inspection of this tree: grep -rn 'proposalsPer\|PerProjectDay' src/ finds only build, quote and order quotas (src/access/index.mjs:9-10,118-121) and the review-admission keys; approvals.create (lines 32-86) contains no counter, and the active-proposals check at line 74 releases as soon as the prior proposal is executed, rejected, failed, blocked or cancelled. Sequence for an admitted project P with verified module M and binding: proposal_create {moduleId:M, configuration:'0x'+i padded, idempotencyKey:'k'+i}; wait for state rejected (or job_cancel decisionJobId before the controller claims it); repeat with i+1. Each iteration writes a new chain-proposals row and claus-review job and the controller sends another /reviews request. Expected: a bounded number of decision runs per project and per day.

### 9. Low: Dispatcher budget is charged for resolve transactions that revert because the requester cancelled or chose a deadline that lands during inclusion

`adapters/contract-delivery.mjs:138`

```
      if (transaction.state === 'reverted') return answer('failed');
```

The delivery authorize() callback checks chain.deliveryState(request).pending (state Pending and deadline > latest block timestamp) at prepare and at submit, and the resolve intent expires exactly at observation.deadline*1000 with no safety margin (line 107). The request can still be closed between broadcast and inclusion: ClausRequestInbox.cancel() is callable by the requester at any time while Pending (contracts/src/ClausRequestInbox.sol:69-75) and resolve() reverts with RequestClosed once block.timestamp >= deadline (line 61). A reverted transaction is still mined, so sepolia-transactions.reconcile calls onFinalize with actual = gasUsed*effectiveGasPrice (lines 141-142) and finalize() adds it to the immutable budget.spent; step() returns 'failed' and the request is never retried. A bound requester can repeat this (cancel the moment the dispatcher's resolve appears in the mempool, or pick a deadline a few seconds after the expected delivery) across up to 100 requests per binding; each attempt costs the attacker one cheap Sepolia transaction and costs the shared 0.0004 test-ETH allocation one reverted resolve. Impact is limited to disabling result delivery for the other admitted projects until a new budget is installed by hand. Minimal fix: require deadline - now >= a safety margin in intake.ingest() before creating a proposal, re-check pending immediately before sendRawTransaction, and cap failed deliveries per binding. Merged from audit_economics and audit_math.

**Reproduction**

Reproduced with node --test against this tree: createContractDelivery with the test fixture; step() broadcasts the resolve; the stub chain then mines it with receipt status 'reverted' (gasUsed 25000, effectiveGasPrice 1e6, what the dispatcher observes after a cancel() or deadline expiry landed first). Next step() returns state 'failed'; ledger spent === '25000000000' (25000*1e6) and reserved '0'; a further step() stays 'failed'. Contract side by inspection: cancel() has no restriction while Pending and resolve() reverts at or after the deadline. Expected: the dispatcher does not sign when the remaining window is shorter than the expected inclusion delay, and a requester-invalidated delivery is not charged repeatedly.

### 10. Low: A pre-dispatch cancel or void leaves the review case in state 'admitted', so the same build/commit can never be requested or accepted again for that project

`src/review-flow/index.mjs:72`

```
          if (['admitted', 'reviewed'].includes(prior.state)) return prior;
```

onAdmission() writes review-cases[id].state = 'admitted' (line 203). When the review-execute workflow is cancelled before any effect (jobs.cancel -> onCancel -> reviews.cancelUnsent) or voided by runPaid's blocked path (lines 296-299), the review-job becomes work 'cancelled' / billing 'void' but the review-cases row is never updated; get() only overlays the workflow state for display (line 106). request() then matches any new request with the same inputHash against the prior case at this line and returns it because its state is 'admitted', so no fresh case is created and no quote job runs; accept() on it fails because admitted-quotes[quoteId] already exists (src/reviews/index.mjs:81, quote_already_admitted). The only way to review that exact source again is to change the commit or the build, which also changes the evidence that chain admission later pins. Minimal fix: in onCancel/abandon for review-execute, reset the case to a re-quotable state (clear quoteId or mark it cancelled) so request() creates or re-quotes a case for that input. Merged from audit_economics and audit_math.

**Reproduction**

Reproduced with node --test against this tree: public policy active, project with published source and compiled build; flow.request -> runQuote -> accept(caseId, quoteId, 'k1') -> jobs.cancel(executionJobId). review-cases[caseId].state === 'admitted' in the store; flow.request({ ...input, idempotencyKey: 'r2' }) returns the same case id; runQuote() is idle; accept(caseId, quoteId, 'k2') rejects with code 'quote_already_admitted'. Expected: after a void cancellation the case is re-quotable or a new case is created for the same input.

### 11. Low: Public-sponsored reviews share the 8-slot active pool with internal pilot projects, so eight public admissions block every pilot project's accept

`src/review-flow/index.mjs:181`

```
      requireThat(live.length < (pilot.kind === 'public' ? pilot.activeGlobal : 8) && !live.some(job => job.projectId === projectId), 'review_in_progress');
```

beforeAdmission builds `live` from the single active-paid-reviews/global list (lines 175-180) regardless of whether each job was admitted under the public policy or an operator pilot, and applies activeGlobal (public) or the hard-coded 8 (pilot) to that shared count. A job stays live until work is terminal AND billing is settled/void, which for a public review spans the whole provider audit and never ends if the provider job stalls or the cost receipt cannot be verified. With the planned public allocation (activeGlobal 8, perOwnerDay 2) four wallets can fill all 8 slots in one day; while they are live every pilot project's review_accept fails with review_in_progress. Commit 4a2cf50 'Separate public review allocation from internal pilot quotas' separated the day counters only; the active pool was not separated. Minimal fix: record the admission kind on the review-job and count public and pilot live jobs separately.

**Reproduction**

Reproduced with node --test against this tree: createApplication with the public policy active; project Q with an enabled review-pilots row and a quoted case; 8 synthetic review-jobs for other projects with work 'outcome_unknown' billing 'pending' listed in active-paid-reviews/global. review accept for Q rejects with code 'review_in_progress'. Expected: pilot admission is independent of the public pool.

### 12. Low: A project whose operator pilot expired keeps executor signing rights under the public policy with no gas metering and no project slot

`src/chain-deployments/admission.mjs:71`

```
      if (!projectId || !await tx.read('public-chain-projects', projectId)) return;
```

reserveProject() is called only from deployModule (src/chain-deployments/index.mjs:45). A project that deployed its module while it held a review pilot is never written to public-chain-projects. Once that pilot expires or is disabled, allowed() at src/chain-deployments/index.mjs:16 falls through to admission.admitted() and succeeds whenever the public policy is active, so registration_prepare/submit, proposal_create, queue and execute remain available, but reserveGas() returns at this line because the project is not in public-chain-projects: its transactions are paid by the executor payer with no per-transaction, per-project or total ceiling and it does not count toward maximumProjects. The pilot flag therefore is not a kill switch and the 0.002 test-ETH public total is not the real exposure of the executor key while any pilot-era project exists. Minimal fix: when allowed()/reserveGas() takes the public branch for a project without a public-chain-projects row, call reserveProject() (consuming a slot) or deny with chain_public_not_enabled.

**Reproduction**

Reproduced with node --test against this tree: chain.admission policy active (maximumProjects 8, per-tx 2e14, per-project 5e14, total 2e15); project P has review-pilots { enabled: true, expiresAt: now-1 } and no public-chain-projects row. deployments.allowed('owner','P') resolves; five consecutive admission.reserveGas calls of 2e14 each for P (1e15 total, double the per-project cap) all succeed; public-chain-usage/global and public-chain-projects/P are both null afterwards. Expected: either the request is denied or P is admitted into public-chain-projects and metered.

### 13. Low: A third-party deliverCallback before the dispatcher's attempt leaves the request 'pending' forever and re-processed with RPC calls on every tick

`adapters/contract-delivery.mjs:131`

```
          (action === 'callback' && observed.callbackDelivered)) return answer('pending');
```

ClausRequestInbox.deliverCallback is permissionless and the result bytes are public in the Resolved event, so the requester or anyone can deliver the callback in the block after resolve(). When the dispatcher reaches the callback action with no transaction of its own and sees callbackDelivered = true it returns 'pending' rather than a terminal state. runtime/contract-intake-worker.mjs:60 treats only delivered/closed/failed as terminal, so the request is stepped again on every tick for the life of the deployment (chain.profile, chain.resolution, submit() of the already-confirmed resolve and deliveryState reads), and contract_request_get keeps reporting delivery.state 'pending' although the on-chain request is Completed and the callback was delivered. The comment says such a send is left for operator reconciliation, but there is no operation to record it. Minimal fix: return a terminal state (e.g. delivered with external: true, or closed) when observed.callbackDelivered is true for the callback action.

**Reproduction**

Reproduced with node --test against this tree: createContractDelivery fixture with a callback; step() broadcasts the resolve; the stub chain confirms it and then reports callbackDelivered = true before any callback transaction exists. Three consecutive step() calls each return state 'pending'; broadcasts stay 1 and only the resolve row exists in chain-transactions. Expected: the worker records delivery once and stops scheduling the request.

### 14. Low: Public readonly admission lets any admitted public project bind and have requests decided, but delivery is gated by the immutable projectIds list of the dispatch budget, so their results are never del

`adapters/contract-delivery.mjs:54`

```
    if (row.value.revokedAt || budget.expiresAt <= now() || !budget.projectIds.includes(request.projectId)) return false;
```

contractIntakePorts.admitted in public-readonly-v1 mode (runtime/contract-intake-config.mjs:26-33) admits any project with a public-chain-projects row and a matching binding, so public projects can bind, have inbox requests ingested and consume a full Claus decision per request. The dispatcher is constructed from one immutable budget whose projectIds list is fixed at admission time (configHash pins it, lines 9-28) and active() returns false here for any project not in the list, after which step() returns 'pending' (line 132) without preparing a transaction. Public projects are admitted dynamically on their first deployModule, so they cannot be in a list fixed earlier; their requests reach result_ready and are re-evaluated by the worker on every pass indefinitely while the requester contract never receives its result and the operator has already paid for the review. Minimal fix: derive delivery eligibility from the same public admission predicate (with a per-project ceiling), or refuse bind/ingest for projects the active dispatch budget will not serve.

**Reproduction**

Reproduced with node --test against this tree: createContractDelivery with budget projectIds ['pilot-only'] and a result_ready request for 'project-a' that passes admitted(). Three consecutive step() calls return state 'pending'; zero broadcasts; chain-transactions stays empty. Expected: either P is refused at bind time or its result is delivered under a bounded share of the budget.

### 15. Low: submitRegistration accepts any number of prepared registrations, including after the project is bound; each impossible one retries forever and pollutes the shared executor queue

`src/chain-deployments/index.mjs:83`

```
        if (old.value.state !== 'awaiting_signature') return old.value;
```

prepareRegistration returns a new record on every call because the prepared message embeds expiresAt and a nonce derived from it (adapters/chain-read.mjs:93), and there is no per-project cap on chain-registrations. The project_already_registered check exists only in prepareRegistration (line 69); submitRegistration checks neither chain-bindings nor an in-flight registration, so every previously prepared record can be submitted after the project is bound (or several at once before it), each enqueuing a project-register job. For all but the first, registerProject reverts on chain (projects[id].target != 0, contracts/src/ClausApprovalExecutor.sol:128), so estimateGas throws a code-less viem error; runOne (lines 139-140) treats every non-authority error as waiting with retryAt now+60s and the job never terminates, costing executor RPC calls each minute and delaying other jobs because jobs.claim serves one job per tick from a shared scan. Nothing is signed and no gas is reserved, so this is free for the owner. Minimal fix: reject submitRegistration when chain-bindings exists or another registration for the project is queued/running, cap prepared registrations per project, and make a revert from estimateGas on a registration for an already bound project terminal (see also the finding on deterministic denials retried forever).

**Reproduction**

Reproduced with node --test against this tree: createChainDeployments with mock chain ports; prepareRegistration three times for one project at different seconds yields 3 distinct ids; submit #1; write the chain-bindings row (first registration confirmed). prepareRegistration now rejects with project_already_registered, but submitRegistration for #2 and #3 both return state 'queued' with new jobs. runOne with an executor whose step throws a code-less revert returns state 'waiting', error 'chain_execution_unavailable' on every invocation; three project-register jobs remain non-terminal. Expected: submission after binding is rejected and a registration that can only revert is blocked, not retried indefinitely.

### 16. Info: Public-scope approval records (chain-admission-assessments, contract-intake-admissions) have no writer; public admission opens on an unvalidated manual database row

`src/chain-deployments/admission.mjs:42`

```
    const assessment = (await reader.read('chain-admission-assessments', digest({
```

admitted() and contractIntakePorts.admitted() (runtime/contract-intake-config.mjs:21-27) gate the whole public chain and intake path on chain-admission-assessments and contract-intake-admissions rows carrying accepted, publicAdmission, executorCodeHash, reviewArtifactHash and reviewedBuildHash. No module, service operation, CLI command or script in the repository writes either collection (only tests do); they can only be inserted directly into the database. Unlike policy.publish/activate these rows get no schema validation, no owner-credential check, no expectedGeneration compare-and-set and no audit event, and the reviewCaseId they reference is a review of the project named in the policy, not necessarily of the executor bytecode whose hash they assert. This is a trust assumption rather than a bypass (database write access already controls the ledger), but the explicit requirement that 'a previous narrow single-project audit is not public-scope approval' is enforced only by operator discipline, and discovery/health report public_readonly the moment the row exists. Suggested handling: an owner-authorized, schema-validated, evented assessment.record operation that binds the executor code hash to a review case whose build hash equals the executor artifact.

**Reproduction**

grep -rn 'chain-admission-assessments\|contract-intake-admissions' over the tree excluding test/ returns only the two readers (src/chain-deployments/admission.mjs:42, runtime/contract-intake-config.mjs:21). Sequence: chain.admission policy published and activated with reviewCaseId C of any reviewed case; admitted() throws chain_scope_not_reviewed as intended; inserting one document in chain-admission-assessments with id digest({ executor, targetArtifactHash, reviewCaseId: C }) and body { accepted: true, publicAdmission: true, chainId: 11155111, executorCodeHash, reviewArtifactHash: C.artifactHash, reviewedBuildHash: C.input.buildHash } (exactly what test/public-chain-admission.test.mjs:27-32 does) makes admitted() succeed and any project may module_deploy into one of the eight slots. Expected: a recorded, authorized, auditable decision tied to the executor bytecode.

---

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