# Audit report

> Independently assess this exact 0.12.0 candidate for the first Ethereum mainnet noncustodial readonly profile. Focus on diff from 91e8ba266d13e2532fd2e10fb5c456c639d39055. Public Ethereum use remains disabled and requires exact owner approval. Scope: two explicit chain IDs (1 and 11155111), chain-specific EIP-712 domains, finality, evidence and source verification, fixed per-project chain, separate policy/capability/budget keys, separate approval and executor signers, compatible existing Sepolia jobs, and safe reconciliation after disabling admission. Review integration/mainnet-release.mjs deployment/approval gates and integration/agent-chain-review.patch, which is prepared but not applied. Test the shared core, tests, and actual failure/recovery paths. No new token, wallet, vault, mainnet Inbox, deposits or inference. The unchanged core contract source is https://github.com/hazarxyz/claus-network-contracts commit af8cf6370d5b3893fa5b38df251d4b27b5db5f8d, independently reviewed by completed IMD job 93f20ace-67f2-489e-affa-f10b94e1f359 (verify repository spelling via source docs if needed). Assess its suitability for this exact Ethereum profile together with the routing changes; do not treat the prior Sepolia scope as automatic mainnet approval. Proposed ceiling: 0.02 ETH total = 0.0004 owner funding gas + 0.0046 bootstrap + 0.015 operations over 7 days; 8 projects, 0.002 per project, 0.001 per tx, no refill. Existing separately approved public IMD remainder only. Roles and exact constructor are in operator plan; all owner actions require matching plan digest. The operator retains authority over the release; no API user can invoke release helpers. Unknown payment/transaction records retain original IDs, nonces and reservations. Zero minimum upgrade delay is intentional; recovery delay remains 172800 seconds. Inspect independently, reproduce concrete defects with severity and exploitability, distinguish intentional bounded operational limitations from security failures. Do not modify sources, deploy, sign, post, open paid subjobs or spend anything.

| | |
|---|---|
| Repository | https://github.com/hazarxyz/claus-network |
| Commit | `b9603c83766d8c3e23e19a79b0c5f85be338b092` |
| Job | `9929337d-2983-4143-bac9-0dd85e02e726` |
| Judged | 2026-10-12 12:25 UTC |
| Findings | 3 medium · 6 low · 4 info |

Four agents audited the code as it is at `b9603c8`, 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: Cancelled approval with no local queue record queries UpgradeExecuted logs from block 0; public RPCs reject the range, so the proposal never reaches a terminal state and the project's pipeline is wedg

`adapters/chain-executor.mjs:164`

```
          fromBlock: BigInt(queued?.receipt?.blockNumber ?? 0), toBlock: 'latest' });
```

Shared core path on both chains. When executionNonces[projectKey][nonce] is already true on the executor but the worker holds no 'queue:<id>' transaction record (the project owner, or the Claus signer, called cancelApproval(id, nonce) on-chain before the executor queued; the contract allows this at any time), execution.step calls client.getLogs with fromBlock 0n and toBlock 'latest'. Both public endpoints this release uses refuse that: the hard-coded Sepolia publicnode endpoint (runtime/chain-worker.mjs:19) answers 'exceed maximum block range: 50000' and the mainnet publicnode endpoint answers 'Archive requests require a personal token' (both observed with curl on 2026-10-12). The RPC error is not an authority code, so runExecution (src/approvals/index.mjs:209) records state 'waiting' with retryAt now+60s and repeats the same full-range query every minute indefinitely. The expected { state: 'blocked', error: 'approval_cancelled' } is never produced and the approval_expired check at line 183 sits below the getLogs call so it is never reached either. Because beginEffect already flipped the job to effect 'outcome_unknown', jobs.cancel rejects with effect_requires_reconciliation, and because active-proposals still points at this proposal, approvals.create rejects every new proposal for the project with proposal_in_progress (src/approvals/index.mjs:46). One legitimate on-chain cancellation therefore permanently wedges the project's upgrade pipeline and keeps a worker busy. No transaction is signed on this path; this is a liveness/recovery defect, not an authorization bypass. Fix: bound fromBlock to a block at or before which the approval could not have executed (e.g. BigInt(input.evidence.chain.blockNumber), the finalized evidence block captured at proposal creation, or the binding's registration block) and move the approval_expired check above the log query so an expired cancelled approval ends as failed.

**Reproduction**

State: network-capabilities 'chain:1' enabled with approvalSigner A and executorPayer P; claus-signed-approvals row for proposal 'prop1' whose Approval signature by A verifies; chain-bindings for the project; no chain-transactions rows 'queue:prop1' or 'execute:prop1'; client.readContract('executionNonces') returns true; client.getLogs rejects any request with fromBlock 0n / range > 50000 with Error('exceed maximum block range: 50000') (the public RPC behaviour). Input: createChainExecutor({ chainId: 1, ... }).execution.step({ value, authorize: async () => true }) three times. Expected: { state: 'blocked', error: 'approval_cancelled' }. Actual: every call issues getLogs({ fromBlock: 0n, toBlock: 'latest' }) and throws 'exceed maximum block range: 50000'; runExecution maps that to waiting/60 s forever. Reproduced by test/scratch/judge.test.mjs test 'A' (output: result { state: 'threw', error: 'exceed maximum block range: 50000' }, getLogs calls all { fromBlock: 0n, toBlock: 'latest' }).

### 2. Medium: Expired module and registration jobs throw invalid_transaction on every tick instead of ending; a reviewed module that misses its 24 h window can never be redeployed on that chain

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

```
        return jobs.finish(claim, { state: 'waiting', retryAt: now() + (['chain_gas_exhausted', 'transaction_cost_limit'].includes(error.code) ? 15 * 60000 : 60000),
```

transactions.prepare (adapters/sepolia-transactions.mjs:52) rejects any intent whose expiresAt is not in the future with invalid_transaction. The executor passes value.expiresAt (module rows: now + 86400000, index.mjs:78) and message.expiresAt * 1000 (registrations: one hour after preparation, index.mjs:94). invalid_transaction is neither in authorityDenied nor in runOne's dispatch block list, so runOne parks the job 'waiting' with a 60 s retry, the chain-modules / chain-registrations row stays 'queued', and jobs.claim has no attempt limit: the job wakes every minute forever with no terminal state. For modules the consequence is permanent: deployModule derives the module id from { projectId, caseId, kind, chainId } and returns the existing row unchanged (index.mjs:67-68), so the project can never redeploy that reviewed case on that chain. The window is realistic under the proposed mainnet ceilings: a module whose reservation exceeds 0.001 ETH per transaction is retried every 15 minutes (chain_gas_exhausted) and the row expires after 24 h, so one day of maxFeePerGas above the ceiling, or a one-day worker outage, lands here; registrations only need the executor to be unavailable for the remainder of the one-hour signing window. The execution path already handles its analogue (approval_expired at adapters/chain-executor.mjs:183); the deployment path does not. No funds move. Fix: in deployment.step (or transact) check the window before prepare and return { state: 'failed', error: 'deployment_expired' | 'registration_expired' } so runOne finishes the job and the row becomes terminal; let deployModule create a fresh row (or clear the expired one) when the previous record is in a terminal expired state.

**Reproduction**

State: network-capabilities 'chain:1' enabled with executorPayer = the executor signer; review-cases 'case1' reviewed for 'prj_x' and its public-sources row published; no chain-transactions rows. Input: executor.deployment.step({ kind: 'module-deploy', value: { id: 'm1', projectId: 'prj_x', caseId: 'case1', chainId: 1, state: 'queued', expiresAt: Date.now() - 1 }, artifact: <valid readonly module artifact>, authorize: async () => true }) three times. Expected: a terminal result (e.g. { state: 'failed', error: 'deployment_expired' }). Actual: every call rejects with code invalid_transaction; runOne's catch maps it to waiting with retryAt now+60 s and deployModule keeps returning the expired 'queued' row. Reproduced by test/scratch/judge.test.mjs test 'B' (output: ['invalid_transaction','invalid_transaction','invalid_transaction']). The registration kind reaches the same prepare check with message.expiresAt * 1000 in the past.

### 3. Medium: A broadcast whose send fails or whose transaction is evicted is never rebroadcast; one transient RPC failure holds the single mainnet wallet slot and its budget reservation for the whole window

`adapters/sepolia-transactions.mjs:97`

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

prepare seals the signed raw bytes and durably records hash and nonce before any broadcast, which is correct. But submit only sends when state === 'prepared'. After one failed sendRawTransaction the record becomes 'unknown' and every later tick goes submit -> null, reconcile -> receipt not found -> return unchanged (line 129). The identical sealed bytes are never sent again, the chain-wallet-slots row for '1:<payer>' stays locked to that id (released only on confirmed/reverted/cancelled, lines 42-47, 107, 149), and the admission reservation in public-chain-projects.reserved and public-chain-usage.mainnet.reserved stays held because finalizeGas never runs. intent.expiresAt is never consulted for 'unknown' or 'submitted' records, so there is no automatic release either. The same applies to a 'submitted' transaction that is dropped from mempools because the base fee rose above its signed maxFeePerGas, which the proposed ceilings make likely (0.001 ETH per transaction caps registerProject at about 1.1 gwei maxFeePerGas), and to a nonce the operator replaces by hand: reconcile never checks getTransactionCount('latest') > value.nonce, so the record stays 'submitted' forever. Every later prepare on chain 1 then fails wallet_busy (line 72) and all mainnet jobs park 'waiting'. Retaining one hash and nonce (the stated intent) is compatible with re-sending the identical raw transaction (same hash, same nonce) while no receipt exists. Not a fund-loss issue; a liveness/recovery gap with no code path, helper or documented procedure in docs/mainnet.md 'Pause and recovery'. Fix: (a) in reconcile (or submit) re-broadcast the sealed raw bytes while the record is 'unknown' or 'submitted', getTransaction(hash) is null and getTransactionCount('latest') <= nonce; (b) when getTransactionCount(payer, 'latest') > value.nonce and no receipt exists for value.hash, mark the record 'replaced', call onFinalize with actual '0' and release the slot with consumedNonce = nonce; (c) document the manual procedure otherwise.

**Reproduction**

Store ':memory:', chainId 1, stub client whose sendRawTransaction throws once then succeeds. prepare({ id: 'one', data: '0x60006000', authority, expiresAt: now+60s }) then submit('one') => state 'unknown', sends = 1. Make the stub succeed and call submit('one') + reconcile('one') three times. Expected: the sealed raw transaction is resent (sends = 2) or the intent is released after intent.expiresAt. Actual: sends stays 1, state stays 'unknown', and prepare({ id: 'two' }) rejects with wallet_busy. Reproduced by test/scratch/judge.test.mjs test 'D' (output: 'D sends 1 state unknown').

### 4. Low: Proposed bootstrap ceiling (0.0046 ETH, 5,000,000 gas) only admits the executor deployment below about 1.33 gwei maxFeePerGas, cannot fit the ~19.6M gas the repository itself documents under the state

`integration/mainnet-release.mjs:35`

```
    limits: { gas: '5000000', cost: plan.bootstrapMaximumWei },
```

deployMainnetRelease narrows the host limits to gas 5,000,000 and cost plan.bootstrapMaximumWei. createReadonlyTransactions reserves gas = estimateGas*125/100 + 10000 and requires gas * fees.maxFeePerGas <= cost (adapters/sepolia-transactions.mjs:63-66), where viem's estimateFeesPerGas returns about 1.2 x base fee + suggested priority fee. Measured 2026-10-12 with the exact ClausApprovalExecutor creation data from contracts/out at commit af8cf637 (constructor (signer, recovery, 172800)): eth_estimateGas returns 2,783,947 on public mainnet (padded 3,489,933) and 19,566,580 on public Sepolia (padded 24,468,235), consistent with the comment at adapters/sepolia-transactions.mjs:6-8 that the Glamsterdam state-creation repricing makes this deployment ~19.5M gas and was the reason MAX_GAS became 30,000,000. Consequences: (1) today, the 0.0046 ETH ceiling admits the bootstrap only while maxFeePerGas <= 4.6e15 / 3,489,933 = 1.318 gwei, i.e. mainnet base fee below roughly 1.1 gwei with a 0 priority suggestion (observed mainnet on 2026-10-12: base 0.095 gwei, so it currently fits); (2) once the mainnet client applies the repricing the padded gas is 24,468,235 > 5,000,000 and every deployMainnetRelease call throws transaction_cost_limit before any record is written, so the plan would have to be re-approved with a new digest; (3) the opt-in fixture 'local chain 1 release resumes one deployment ...' does not pin the local chain's fees, and on a default anvil (base fee 1 gwei, priority 1 gwei -> maxFeePerGas 2.2 gwei) the reservation is 3,461,085 * 2.2 gwei = 0.00761 ETH > 0.0046 ETH, so the only test of the resume/stage path fails as shipped and the path it claims to exercise is untested. The gate fails closed and nothing is spent; this is a release-plan/test inconsistency and an operability limit the brief asked to assess, not a security failure. Fix: have the plan carry the gas ceiling and the maximum fee it was estimated under (bounded by MAX_GAS) instead of a hard-coded 5,000,000, state that fee assumption next to the 0.0046 ETH ceiling in docs/mainnet.md, and start the fixture's anvil with a pinned base fee (e.g. --base-fee 100000000) or set bootstrapMaximumWei from observed fees so the gate is actually verified.

**Reproduction**

Commands: `anvil --chain-id 1 --port 8598` (defaults) then `CLAUS_TEST_MAINNET_RELEASE=http://127.0.0.1:8598 node --test test/mainnet-release.test.mjs`. Expected: 2 passing tests. Actual: 'local chain 1 release resumes one deployment and cannot stage altered source or activate spending' fails with Error: transaction_cost_limit at adapters/sepolia-transactions.mjs:64 via integration/mainnet-release.mjs:42 (eth_feeHistory on that node: baseFeePerGas 0x3b9aca00 = 1 gwei). Re-running with `anvil --chain-id 1 --port 8599 --base-fee 100000000` passes both tests (viem fees there: maxFeePerGas 1000000338, priority 1 gwei). Gas probe: eth_estimateGas for the exact creation data returns 2783947 on https://ethereum-rpc.publicnode.com and 19566580 on https://ethereum-sepolia-rpc.publicnode.com; 19566580*125/100+10000 = 24468235 > 5000000.

### 5. Low: Worker tick no longer cross-checks its signing key against the staged capability role; a wrong or swapped CLAUS_MAINNET_PRIVATE_KEY converts queued user work into terminal blocked states instead of re

`runtime/chain-worker.mjs:47`

```
    // Keep reconciling already broadcast work after either admission is disabled.
```

At the base commit tick() read the capability and enforced requireThat(expected === signer.address, 'worker_role_mismatch') (expected = approvalSigner for the controller, executorPayer for the executor) before claiming any job, so a misconfigured worker had no side effects. 0.12.0 removes that for both chains and relies on per-step checks. Those checks (currentCapability at adapters/chain-executor.mjs:57-58, executorAllowed at adapters/claus-controller.mjs:15-16) throw executor_not_enabled, which authorityDenied classifies as a terminal authority failure: transact returns { state: 'cancelled' }, the executor returns { state: 'blocked', error: 'authority_or_window_closed' } and runOne/runExecution persist the module, registration or proposal as blocked; the controller DELETEs the Claus review and returns blocked with error authority_revoked. The only startup checks left (lines 21 and 26-27) compare the key to env vars the same operator sets, not to the staged capability, and both roles read the same variable name CLAUS_MAINNET_PRIVATE_KEY, so handing the controller the executor key (or vice versa) passes them. In the documented sequence workers receive mainnet keys at step 5 before the capability is staged at step 6, so a swapped or stale key on either role is first detected by destroying user jobs: module ids are deterministic (deployModule returns the blocked row forever for that caseId on that chain) and each affected project has already consumed one of the 8 mainnet project slots through reserveProject. Removing the 'disabled' short-circuit is justified by the comment (reconcile after admission is disabled); dropping the role/key cross-check was not required for that. Fix: in tick(), for each configured port read network-capabilities 'chain' / 'chain:1' and, when the capability exists and is enabled, require the port's signer to equal approvalSigner (controller) or executorPayer (executor) before claiming dispatch-mode jobs, while still allowing reconcile-mode work.

**Reproduction**

State: network-capabilities 'chain:1' enabled with executorPayer = P; review-cases/public-sources rows so published() passes. Input: createChainExecutor({ chainId: 1, signer: K != P, chain: { capabilityMatches: async () => {} }, ... }).deployment.step({ kind: 'module-deploy', value: { id: 'm1', projectId: 'prj_x', caseId: 'case1', chainId: 1, expiresAt: now+1d }, artifact: <valid readonly module artifact>, authorize: async () => true }). Expected (base-commit behaviour): worker_role_mismatch before any job is claimed, job stays queued. Actual: { state: 'blocked', error: 'authority_or_window_closed' }, which runOne persists on chain-modules m1. Same for createClausController({ chainId: 1, signer: K != approvalSigner }).step(...): returns { state: 'blocked', error: 'authority_revoked' } after DELETEing the review. Reproduced by test/scratch/judge.test.mjs test 'E'.

### 6. Low: Release gates do not enforce the stated separation of approval signer, executor payer and recovery owner, nor the documented 172800 s recovery delay

`integration/mainnet-release.mjs:10`

```
  const args = [plan.approvalSigner, plan.recoveryOwner, BigInt(plan.recoveryDelay)];
```

docs/mainnet.md and the brief require separate approval and executor signers, a separately held recovery owner and a 172800-second recovery delay. mainnetDeployment, requireMainnetApproval, deployMainnetRelease and stageMainnetCapability verify chainId, profile, automaticRefill, buildHash, deployment data hash, constructor bytes, the nonce-0 address and on-chain role readbacks, but never that plan.approvalSigner, plan.executorPayer and plan.recoveryOwner are pairwise distinct or that plan.recoveryDelay === 172800. The contract constructor (contracts/src/ClausApprovalExecutor.sol:102-103) only rejects zero addresses and delay < 1 day. chain-worker.mjs only checks that the mainnet key differs from the Sepolia key within one role (lines 26-27), not approvalSigner != executorPayer across roles. A plan that puts every role on the hot executor key, with a one-day recovery delay, deploys, verifies and is staged as an enabled 'chain:1' capability with approvalSigner === executorPayer, collapsing the two-key design the readonly profile relies on; a compromise of the executor host would then also hold Claus approval and signer-recovery power on mainnet. The shipped fixture already relies on a collision: test/mainnet-release.test.mjs:30-32 deploys with recoveryOwner === executorPayer. Operator-only code and the plan is owner-approved by digest, so this is a defence-in-depth gap rather than a reachable bypass for API users. Fix: in mainnetDeployment requireThat(new Set([approvalSigner, executorPayer, recoveryOwner].map(a => a.toLowerCase())).size === 3 && plan.recoveryDelay === 172800, 'release_plan_mismatch'), and in stageMainnetCapability require cap.approvalSigner.toLowerCase() !== cap.executorPayer.toLowerCase().

**Reproduction**

hot = 0x00..aa; plan = { chainId: 1, profile: 'claus-readonly-v1', automaticRefill: false, buildHash: digest(artifact), approvalSigner: hot, executorPayer: hot, recoveryOwner: hot, recoveryDelay: 86400, expectedExecutor: getContractAddress({ from: hot, nonce: 0n }), deploymentDataHash / constructorArguments computed for args [hot, hot, 86400n], runtimeCommit: 'x', contractCommit: 'y' }; authorization = { approved: true, planHash: digest(plan), owner: hot }; assessment matching. Run against contracts/out/ClausApprovalExecutor.sol/ClausApprovalExecutor.json: requireMainnetApproval(plan, authorization, assessment) returns and mainnetDeployment(plan, artifact) returns { address: 0x45eb6484D76cfe3f45708B91F5AF8Ce495134FAc }. Expected: release_plan_mismatch. Reproduced by test/scratch/judge.test.mjs test 'G'.

### 7. Low: Resuming the bootstrap after the owner authorization expired or was revoked permanently cancels the only deployment id for that plan; a renewed authorization for the unchanged plan can never prepare a

`integration/mainnet-release.mjs:32`

```
  const id = 'bootstrap:mainnet-readonly:' + digest(plan).slice(7);
```

The documented recovery path is to resume the same id after an interruption. If the process stops between prepare (signed, wallet slot held) and submit, and the operator resumes after authorization.expiresAt or after setting revoked: true, submit runs authorize -> false and the record becomes 'cancelled' with the slot released. That is the right fail-closed outcome, but the id is derived only from digest(plan), so every later deployMainnetRelease call with a renewed authorization for the same plan finds txs.status(id) === 'cancelled', skips prepare (only !tx prepares, lines 37-41), calls reconcile which returns the cancelled view (adapters/sepolia-transactions.mjs:127) and returns { state: 'cancelled' } forever, although nothing was broadcast and the payer nonce is still 0. The operator must alter the plan to get a new digest, which invalidates the owner approval and the independent assessment binding even though nothing on chain changed. Low severity: no spend, operator-only, but it turns a routine authorization renewal into a full re-approval cycle. Fix: when txs.status(id) is 'cancelled' and getTransactionCount(payer, 'pending') is still 0, derive the id from { planHash, authorizationHash } (or allow a fresh prepare for a cancelled record with a renewed authorization).

**Reproduction**

Plan P with executorPayer = signer, authorization A1 with expiresAt = T. Prepare the bootstrap intent for id 'bootstrap:mainnet-readonly:' + digest(P).slice(7) at T-1 (emulating an interruption after prepare; sendRawTransaction never runs). At T+1 call deployMainnetRelease({ ..., authorization: A1 }): result.state === 'cancelled', sends = 0. Obtain A2 = { ...A1, expiresAt: T + 3600000 } for the same P and call deployMainnetRelease again: result.state is still 'cancelled' and getTransactionCount(payer) is 0. Expected: a fresh authorization for an unchanged plan and unused nonce 0 can prepare and broadcast. Reproduced by test/scratch/judge.test.mjs test 'H' (output: 'H cancelled cancelled nonce 0').

### 8. Low: stageMainnetCapability trusts the caller-supplied deployment.verification instead of the stored chain-verifications record and never checks that the staged target artifact resolves

`integration/mainnet-release.mjs:62`

```
  requireThat(deployment.state === 'verified' && deployment.planHash === digest(plan) &&
```

Two gaps in the same staging gate, both fixable by reading the store it already holds. (1) The gate requires deployment.state === 'verified' and exact_match creation/runtime matches, but every one of those fields is read from the deployment argument the operator passes in; the authoritative record written by createSourceVerification (collection 'chain-verifications', id digest({ chainId: 1, address })) is never consulted. The on-chain checks (from, nonce 0, input bytes, finalized receipt, code hash) still bind the executor bytecode to the plan, so what can be skipped is the independent Etherscan/Sourcify source match that docs/mainnet.md lists as a release requirement. (2) The gate copies plan.artifactProjectId and plan.targetArtifactHash into the 'chain:1' capability (line 76) without checking that artifacts.get(artifactProjectId, targetArtifactHash) resolves to the ClausReadonlyProject build artifact (it is not even given the artifact store). The first use of those values is adapters/chain-executor.mjs:101, after registerProject has been mined and finalized and the gas reservation spent: artifacts.get throws not_found, which is not an authority code, so runOne schedules a 60 s retry forever; on chain the project is registered, the chain-registrations row stays 'registering', chain-bindings is never written, so proposals for that project are dead while it has consumed a mainnet project slot and up to 0.001 ETH. The shipped fixture stages exactly such an unresolvable hash (test/mainnet-release.test.mjs:37, digest('target-fixture')). Operator-only input, so low severity. Fix: inside the store.transaction read 'chain-verifications' digest({ chainId: 1, address }) and require state === 'verified' with exact creation/runtime matches and etherscanVerified === true; pass artifacts to stageMainnetCapability and requireThat((await artifacts.get(plan.artifactProjectId, plan.targetArtifactHash)).contract === 'ClausReadonlyProject') before writing the capability.

**Reproduction**

State: review-cases 'core' reviewed with matching artifactHash/buildHash; chain-verifications[digest({ chainId: 1, address })] = { state: 'pending' }; no artifact row for (plan.artifactProjectId, plan.targetArtifactHash). Stub client: getTransaction returns { from: payer, nonce: 0, to: null, input: <deploy data>, value: 0n }, receipt success at block 10 with contractAddress = expected address, finalized block 100, getCode returns the code whose keccak equals deployment.codeHash. Input: stageMainnetCapability({ store, plan, authorization, assessment, client, artifact, deployment: { state: 'verified', planHash: digest(plan), address, transactionHash, codeHash, verification: { creationMatch: 'exact_match', runtimeMatch: 'exact_match' } } }). Expected: exact_verified_release_required (stored verification is pending) or a target-artifact error. Actual: the capability 'chain:1' and the chain-admission-assessments row are written and cap.enabled === true while the stored verification is still 'pending' and the target artifact hash does not resolve. Reproduced by test/scratch/judge.test.mjs test 'I/J'.

### 9. Low: A worker without the job's chain port marks the job's effect as started before the port is resolved, so a chain-1 job claimed by a Sepolia-only worker loops every minute and can no longer be cancelled

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

```
        if (claim.mode === 'dispatch') await jobs.beginEffect(claim, (_job, tx) => authorize(tx));
```

In 0.12.0 the chain port is chosen inside executor.step / controller.step (runtime/chain-worker.mjs:39-45), but the job runners call jobs.beginEffect before step (here, and src/approvals/index.mjs:146 and :184). beginEffect flips the job to effect 'outcome_unknown'. A worker that has no mainnet port (CLAUS_MAINNET_RPC_URL / CLAUS_MAINNET_PRIVATE_KEY unset, or a controller/executor pair where only one side received mainnet settings) then throws chain_not_configured from selected(), which is not in any dispatch block list, so the job is parked 'waiting' for 60 s with its effect already marked started although nothing was signed or broadcast. Consequences: jobs.cancel (src/jobs/index.mjs:50) rejects the project's job_cancel with effect_requires_reconciliation even though no external effect exists, and the Sepolia-only worker keeps re-claiming the chain-1 job every minute while the mainnet-capable worker only ever sees it in reconcile mode. Job kinds are not chain-scoped, so nothing in the data model prevents the wrong worker from claiming; docs/mainnet.md only mitigates it procedurally. Availability/operability defect, not loss of funds or authority. Fix: resolve the port (ports.has(chainId)) before beginEffect, or make kinds chain-scoped (e.g. 'module-deploy:1') so claim({ kinds }) filters them, leaving effect 'not_started' and the job cancellable.

**Reproduction**

Using the jobs module directly on openStore(':memory:'): enqueue { kind: 'module-deploy', payload: { moduleId: 'm1' } }; claim({ kinds: ['module-deploy'] }) -> mode 'dispatch'; beginEffect(claim, async () => true) (authorize only checks lease + allowed, which pass for a chain-1 project); the worker's selected(value) then throws chain_not_configured and runOne's catch calls jobs.finish(claim, { state: 'waiting', retryAt: now+60s, result: { error: 'chain_not_configured' } }). Observed: row state 'waiting', effect 'outcome_unknown'; jobs.cancel(actor, 'prj_x', id) rejects with effect_requires_reconciliation. Expected: effect 'not_started' and the job cancellable, or the job never claimed by a worker without that chain. Reproduced by test/scratch/judge.test.mjs test 'L'.

### 10. Info: Owner authorization and independent assessment are unsigned local JSON objects; the release gate cannot distinguish an owner-approved plan from one asserted by whoever runs the helper

`integration/mainnet-release.mjs:20`

```
  requireThat(authorization?.approved === true && authorization.planHash === digest(plan) &&
```

requireMainnetApproval checks only structural equality: approved === true, planHash === digest(plan), owner equal to plan.recoveryOwner (an address the plan itself supplies), revoked !== true, and matching assessment fields. No EIP-712 or personal_sign signature by the owner over the plan digest is verified, the owner is not compared with the network owner recorded in the store, expiresAt is not checked here (only later in deployMainnetRelease/stageMainnetCapability, and not on the resume-after-confirmation path), and the assessment carries no provenance. The brief says all owner actions require a matching plan digest, and the gate does enforce that digest; what it does not provide is cryptographic proof of who approved it. The module is operator-only, unreachable from HTTP/MCP, and the operator already holds the executor key, so this is a trust assumption to document rather than a bypass. The shipped unit test builds the authorization inline without key material (test/mainnet-release.test.mjs:12-15). If the gate is meant to be cryptographic: carry authorization.signature and verify verifyTypedData over { planHash, expiresAt, chainId: 1 } from the recorded network owner, and cross-check the assessment against the IMD job id / review-cases artifact in stageMainnetCapability.

**Reproduction**

plan = any plan object; authorization = { approved: true, planHash: digest(plan), owner: plan.recoveryOwner, expiresAt: Date.now() + 3600000 } typed by anyone with shell access on the release host; assessment = { status: 'accepted-for-mainnet-readonly', chainId: 1, sourceCommit: plan.runtimeCommit, contractCommit: plan.contractCommit, buildHash: plan.buildHash }. requireMainnetApproval(plan, authorization, assessment) returns without any signature (this is what test/mainnet-release.test.mjs:11-15 does; also exercised by test/scratch/judge.test.mjs test 'G' with an all-hot-key plan). Expected, if the gate is meant to prove owner approval: rejection without a verifiable owner signature over digest(plan).

### 11. Info: Public decision capacity counters are chain-agnostic, so free Sepolia public usage can exhaust the 8/day and 32-lifetime decision budget that mainnet proposals need

`src/approvals/limits.mjs:11`

```
    ['day', 'global', day, PUBLIC_DECISION_LIMITS.globalDay],
```

publicDecisionCapacity keys the 'global' day and lifetime counters only by scope/subject/period; chainId is not part of the key. Gas budgets and project counts were split per chain in this release (public-chain-usage 'mainnet' vs 'global'), but the Claus decision admission counters were not. Any publicly admitted Sepolia project consumes the same 8-per-day and 32-lifetime global pool a chain-1 proposal needs, with no mainnet-specific floor, and the 32-lifetime cap is already partially consumed by historical Sepolia public decisions, so the eight planned mainnet projects (up to 4 lifetime decisions each) cannot all be served even in the best case. Sharing the operator's IMD/model remainder across chains is stated as intentional ('Existing separately approved public IMD remainder only'), so this is a bounded operational limitation to state in the plan, not a security failure; the docs line 'usage is independent of Sepolia' (docs/mainnet.md step 6) is only true for gas, not for decisions. If a mainnet allocation is wanted: key the counters per chain (subject 'global:1' / 'global:11155111') or document the shared pool explicitly.

**Reproduction**

State: public-chain-projects['prj_main'] = { projectId: 'prj_main', chainId: 1 }; public-decision-admission[digest({ scope: 'day', subject: 'global', period: today })] = { count: 8 } (eight Sepolia public decisions today). Input: publicDecisionCapacity(store, 'prj_main', Date.now()) (called by approvals.create). Actual: rejects with decision_capacity_reached although public-chain-usage['mainnet'] is untouched. Reproduced by test/scratch/judge.test.mjs test 'M'.

### 12. Info: Operations ceilings: 0.001 ETH per transaction admits registerProject only below about 1.1 gwei maxFeePerGas and the 8 x 0.002 ETH project budgets exceed the 0.015 ETH global ceiling; project and main

`docs/mainnet.md:27`

```
The initial proposed operating window is seven days with eight projects, 0.001 ETH maximum per transaction, 0.002 per project and 0.015 globally. Bootstrap is separately capped at 0.0046 ETH; owner funding gas at 0.0004 ETH. Total new exposure is at most 0.02 ETH. These are proposed ceilings, not authorization or current costs. No automatic refill. Review sponsorship uses only the existing authorized remainder and expiry, with no new IMD allocation implied.
```

Intentional bounded limitation, quantified so the operator can judge the proposed window; not a security failure. reserveGas (src/chain-deployments/admission.mjs:96-97) compares the full reservation gas * maxFeePerGas with maximumTransactionGasWei, where gas = estimate*1.25 + 10000 (adapters/sepolia-transactions.mjs:63). Foundry gas report for the unchanged contracts (forge test --gas-report in contracts/): registerProject max 719,158 (reservation 908,947 gas), queue max 95,174 (128,967), execute max 118,036 (157,545); a ClausBalanceLens-sized module creation is roughly 216k (reservation about 280k). At 0.001 ETH per transaction, registerProject is reservable only while maxFeePerGas <= 1e15 / 908,947 = 1.10 gwei (viem returns about 1.2 x base fee + priority, so mainnet base fee below roughly 0.9 gwei; observed 0.095 gwei on 2026-10-12, about 10x headroom). Above that, prepare throws transaction_cost_limit / reserveGas throws chain_gas_exhausted and the job retries every 15 minutes until the module row expires after 24 h or the one-hour registration window closes (see the expired-job finding). Per project the minimum sequence deploy + register + queue + execute reserves about 1.48M gas, so 0.002 ETH needs an average fee <= 1.35 gwei; 8 x 0.002 = 0.016 > 0.015, so the global ceiling binds before the eighth project completes. Also note deployModule on chain 1 calls reserveProject before any transaction exists (src/chain-deployments/index.mjs:70) and public-chain-usage.mainnet.projects is never decremented, so a project whose deployment is refused for 24 h by the fee ceiling still consumes one of the 8 slots. These numbers are consistent with the stated 0.02 ETH exposure cap; they mean the window is usable only at sub-1 gwei fees and the plan should state that assumption.

**Reproduction**

State: chain.mainnet.admission policy with maximumTransactionGasWei '1000000000000000'; stub mainnet client returning estimateGas 719158 for registerProject and maxFeePerGas 1200000000 (1.2 gwei). Input: executor deployment step for a chain-1 project-register job. Expected per the plan: the registration fits the per-transaction ceiling. Actual: reservation = (719158*125/100+10000) * 1200000000 = 1,090,736,400,000,000 wei > 1e15, reserveGas throws chain_gas_exhausted (or prepare throws transaction_cost_limit), runOne records waiting with a 15-minute retry; the project slot reserved by deployModule remains consumed. Numbers from `cd contracts && forge test --gas-report` (registerProject max 719158, queue max 95174, execute max 118036) and the padding formula at adapters/sepolia-transactions.mjs:63.

### 13. Info: Mainnet RPC URL must be https on the API host but not on the chain workers that sign and broadcast

`runtime/chain-worker.mjs:24`

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

runtime/readonly-ports.mjs:7-8 parses CLAUS_MAINNET_RPC_URL and requires url.protocol === 'https:' for the API process, but createChainWorker builds the mainnet client straight from the same variable with no scheme check. The worker is the process that signs approvals and broadcasts funded transactions based on what this RPC returns (chain id, code hashes, receipts, finalized block), so it is where a plaintext endpoint matters most: a network-path attacker could feed the executor a forged finalized receipt or code hash. Per-call getChainId checks still reject a wrong chain. Operator configuration only, so info. Fix: apply the same requireThat(new URL(url).protocol === 'https:', 'invalid_mainnet_rpc') in createChainWorker (or reuse readonlyPorts).

**Reproduction**

Code inspection: with CLAUS_MAINNET_RPC_URL=http://rpc.example:8545 and a CLAUS_MAINNET_PRIVATE_KEY set, createChainWorker('executor') reaches line 24 and wires createChainRead({ chainId: 1 }) to a plaintext transport (no check between lines 23 and 28), while readonlyPorts() with the same variable throws invalid_mainnet_rpc at runtime/readonly-ports.mjs:8. Expected: the worker applies the same https requirement.

---

Judge's submission `4ff4652ac78de176ae18f976b09d45ae80095b9ffeef4102462744f850048623`, 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.
