# Audit report

> Independently audit this exact final 0.11.2 Claus Network revision. This necessary follow-up includes public dispatcher budget admission and original-budget reconciliation, bounded larger chain transaction journals in SQLite/Postgres, and IMD payment deadline handling introduced since 4a2cf501c1a4a60f18350bc3fd4145e91b3747a2. Include adapters/contract-delivery.mjs, runtime/contract-intake-host.mjs, adapters/imd.mjs, adapters/imd-payment.mjs and src/postgres/index.mjs in particular. The older revision already has a separate paid audit a91da909-3bc4-468b-b5fb-cff58907da1a still running; do not launch subjobs or assume its result. Audit this pinned 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 | `18401b940104f701946be523e9100ce3db754f1f` |
| Job | `3af99ba5-482b-484a-9199-59d040f5e96c` |
| Judged | 2026-10-12 11:59 UTC |
| Findings | 1 high · 5 medium · 7 low |

Four agents audited the code as it is at `18401b9`, 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. High: Unauthenticated Sepolia log flood (>5000 Requested events in one 500-block window) permanently stalls the public intake worker: scan, proposals and result delivery

`adapters/contract-intake.mjs:47`

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

ClausRequestInbox.request() (contracts/src/ClausRequestInbox.sol:46-56) is permissionless and fee-free and accepts any binding word. createContractInboxRead.scan() always queries a fixed window (limit defaults to 500 blocks; the worker never passes another value and the persisted cursor only carries afterBlock/blockHash) and throws inbox_scan_too_large when the window holds more than 5000 logs. runtime/contract-intake-worker.mjs:23 awaits chain.scan() outside any try/catch, before the cursor write (line 51), before intake.run() (line 54) and before the delivery/reconciliation loop (lines 55-93). The window never shrinks and the cursor never advances, so every later tick repeats the identical failing query. Consequences for every admitted project at once: no new requests are ingested, no result_ready request is resolved or called back, and already-broadcast dispatcher transactions are never reconciled (wallet slot and reserved budget stay locked). The hosted worker (runtime/contract-intake-host.mjs:34) propagates the error so worker-health is never written. An RPC provider's own getLogs result cap produces the same permanent stall at a lower count. Cost to the attacker: ~5001 x ~70k gas of free Sepolia ETH, no identity, no admission. Recovery needs an operator to hand-edit contract-intake-cursors past the flooded range (losing legitimate events inside it). Fix: filter getLogs by the indexed `binding` topic of currently active bindings, or bisect/narrow the block window on inbox_scan_too_large (and on RPC result-cap errors) and persist progress per sub-range; additionally run the delivery loop even when the scan fails so unrelated chain traffic cannot block reconciliation. Merged from audit_flow, audit_economics and audit_permissions (same mechanism).

**Reproduction**

Executed: test/scratch/repro-scan-dos.test.mjs (passes against this revision). State: intake worker active, cursor absent (startBlock 100), an honest result_ready request of an admitted project waiting for delivery. Input: real createContractInboxRead with a client whose getLogs returns 5001 Requested logs (unknown binding) for blocks 100..599. Expected: unknown bindings are skipped (`if (!binding) continue;`) and the honest request is stepped by the dispatcher. Actual: three consecutive worker.tick() calls reject with code inbox_scan_too_large, each querying the identical range [100n, 599n]; contract-intake-cursors has 0 rows; delivery.step was called 0 times. On Sepolia: any EOA calling inbox.request(bytes32(1), bytes32(1), now+1h, 0, '0x01') 5001 times within 500 blocks reproduces this against the pinned inbox.

### 2. Medium: Accept-then-cancel of never-sent public reviews permanently consumes the 4 IMD lifetime allocation and day counters: free EOAs exhaust the public programme at zero cost

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

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

createReviewAdmission.reserve() adds quote.offer.maximum to public-review-exposure/lifetime and increments the per-owner day counter at admission time (lines 46-53); beforeAdmission increments the per-project and global day counters (src/review-flow/index.mjs:182-190). None of these is ever decremented. The billing ledger, by contrast, returns the sponsor reservation when the owner cancels the review-execute job before the worker's effect boundary: jobs.cancel (src/jobs/index.mjs:45-57, allowed while effect === 'not_started', i.e. immediately after review_accept) -> flow.onCancel -> reviews.cancelUnsent (src/reviews/index.mjs:110-123) sets billing 'void' and settles the reservation at 0. So admission, not spending, depletes the lifetime pool. Owner identities are free (any EOA that signs the SIWE challenge gets a principal, src/identity/index.mjs:134-138), project creation is public, so perOwnerDay=2 does not bind a hostile party: four throwaway wallets x two projects consume 8 x 0.5 IMD = the entire planned 4 IMD / 7-day allocation in one day while the sponsor spends nothing and no review is performed; every honest review_accept then fails with sponsor_exhausted until the network owner publishes a larger maximumLifetime, which the pilot explicitly does not plan ('no auto-refill'). The same consumption happens via the runPaid denied path (payment_window_expired / source withdrawn) without any cancel call. test/review-flow.test.mjs:188-217 encodes the current behaviour as intended, but only checks that the cap survives policy changes. Fix: in reviews.cancelUnsent (or flow.onCancel / the runPaid denied commit, same transaction) subtract the voided maximum from public-review-exposure/lifetime when the job was admitted under kind 'public', keyed per job so it cannot be released twice; keep the day counters consumed for rate limiting. Merged from audit_flow, audit_economics, audit_permissions and audit_math; the audit_math per-owner Sybil note is folded in as the reason perOwnerDay does not bound the attacker.

**Reproduction**

Executed: test/scratch/repro-exposure.test.mjs (passes against this revision) using the real createApplication with SQLite. State: review.admission policy {maximumPerReview 0.5 IMD, maximumLifetime 4 IMD, perOwnerDay 2, perProjectDay 2, globalDay 8, activeGlobal 8} active, sponsor funded with 4 IMD, no pilots. Input: four fresh EOAs each log in, create two projects, snapshot, compile, publish source, review_create, (worker runQuote), review_accept, then jobs.cancel(executionJobId) before any worker tick. Expected: a cancelled, provably unsent admission returns its share of the lifetime exposure as its billing reservation is returned. Actual: sponsor ledger spent='0', reserved='0', available=4 IMD, while admission.get().committedMaximum = '4000000000000000000'; a fifth honest owner's review_accept rejects with code sponsor_exhausted.

### 3. Medium: A payment that fails during local signing (including the new 1-second IMD window) is parked as signing_unknown forever: never abandoned, not cancellable, holding sponsor funds and a public active-revi

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

```
        const denied = ['scope_denied', 'unauthenticated', 'source_not_published', 'sponsor_not_enabled',
```

adapters/imd-payment.mjs prepare() wraps Permit2/quote signing in try/catch and on any throw rewrites the record to state 'signing_unknown' and throws payment_signing_unknown (lines 92-95). Nothing has left the host at that point (provider.submit is not called; sealedMaterial absent) and abandonUnsent() explicitly treats 'signing_unknown' as cancellable (line 40). But runPaid's denied list (this line) does not contain payment_signing_unknown, so tick 1 ends 'waiting' (line 311). On every later tick payment.status() is non-null so prepare() is skipped, state !== 'prepared' so submit is skipped, reconcile() keeps the state, costReceipt is null, providerJobId is null and line 289 re-queues the job 'waiting' indefinitely. Because beginEffect ran before prepare, jobs.cancel is refused with effect_requires_reconciliation; cancelUnsent is only reachable from the denied path. The review-jobs row stays work=outcome_unknown / billing=pending, the sponsor's 0.5 IMD reservation is never released, the job stays in active-paid-reviews so beforeAdmission counts it against activeGlobal (8) and refuses the project with review_in_progress. Trigger is realistic in this revision: adapters/imd.mjs:137-138 and imd-payment.mjs:53-54 now accept any maxTimeoutSeconds in (0,300] (test/imd-window.test.mjs asserts 1 is accepted); the deadline is computed at line 55 and re-checked at line 87 after two store transactions and two signatures, so a 1 s window that crosses a UTC-second boundary throws payment_window_expired inside the try block and is converted into signing_unknown. Note that payment_window_expired itself IS in the denied list, so the conversion defeats the existing recovery. Any transient signer/KMS error has the same effect. Eight such events close the public programme with no API-level recovery. Fix: add payment_signing_unknown to the denied codes (abandonUnsent + cancelUnsent inside jobs.finish), or in runPaid when state.state === 'signing_unknown' abandon and re-prepare; consider a minimum accepted maxTimeoutSeconds (e.g. >= 60). Merged from audit_flow and audit_math.

**Reproduction**

Executed: test/scratch/repro-signing-unknown.test.mjs test 'C' (passes against this revision) with the real createApplication, createReviewFlow and createImdPayment. State: public review admitted (0.5 IMD reserved on the sponsor account). Input: provider.challenge returns accepts[0].maxTimeoutSeconds = 1; the payment adapter's clock advances 400 ms per read (two store transactions and two signatures between the deadline computation and the line-87 check). Expected: since nothing was sent, the review is abandoned (billing void, slot released) or at least job_cancel succeeds. Actual: runPaid #1 -> {state:'waiting', result.error:'payment_signing_unknown'}, imd-payments[offer].state === 'signing_unknown', provider.submit called 0 times; runPaid #2..#4 (130 s apart) -> 'waiting' with paymentState 'signing_unknown'; review-job work='outcome_unknown', billing='pending'; sponsor reserved = 500000000000000000; jobs.cancel(executionJobId) rejects with effect_requires_reconciliation; active-paid-reviews still contains the job.

### 4. Medium: A failed provider submit leaves the payment in outcome_unknown with no terminal path, even when the provider reports the order expired unpaid; the accepted 1-second permit window makes this the expect

`adapters/imd-payment.mjs:111`

```
        return summary(await update(offerId, 'submitting', old => ({ ...old, state: 'outcome_unknown' })));
```

submit() moves the record to 'outcome_unknown' whenever provider.submit() throws for any reason (transport error or any non-200/202 status such as 402/409/429). From there nothing can close the review: abandonUnsent refuses (line 40), reconcile() only ever promotes to provider_paid (lines 120-121) and keeps the state when the provider reports expired/payment_failed, runPaid (src/review-flow/index.mjs:289) has no branch for it and re-queues 'waiting' every 60 s forever, jobs.cancel refuses because effect is outcome_unknown, and there is no operator route to settle or void a review-job. The sponsor reservation (0.5 IMD) stays locked, the job stays in active-paid-reviews (consuming one of 8 activeGlobal slots and blocking the project with review_in_progress), and the quote can never be re-admitted (quote_already_admitted). Keeping an uncertain send unreversed is deliberate, but this revision makes the uncertain outcome likely: commit a6a4d12 replaced the fixed 300 s permit deadline by accepted.maxTimeoutSeconds with only 0 < n <= 300 enforced (adapters/imd.mjs:137-138, imd-payment.mjs:53-55), so with maxTimeoutSeconds = 1 the Permit2 deadline is now+1 s; submit() only checks old.deadline > seconds() locally (line 102) and the facilitator settles one or more blocks later, where Permit2 reverts SignatureExpired and the provider answers non-2xx. Fix: require a safety margin (e.g. maxTimeoutSeconds >= 60 and deadline - seconds() >= 30 at submit, else fail with payment_window_expired before any send), and add a reconciliation rule that, once the provider reports expired/payment_failed with paid=false AND the permit deadline is well past, moves outcome_unknown to a terminal cancelled state and calls reviews.cancelUnsent so the reservation and active slot are released. Merged from audit_economics and audit_permissions.

**Reproduction**

Executed: test/scratch/repro-signing-unknown.test.mjs test 'D' (passes against this revision). State: public review admitted (0.5 IMD reserved). Input: challenge with maxTimeoutSeconds = 1; provider.submit throws provider_request_rejected (HTTP 402); provider.status later returns {status:'expired', payment:{paid:false}}. Expected: a permit that can no longer settle is released, or the job reaches a terminal state once the provider confirms the order expired unpaid. Actual: stored permit deadline == signing second + 1; runPaid #1 -> waiting/paymentState outcome_unknown with exactly one submit; runPaid #2..#4 (130 s apart, provider reporting 'expired') -> waiting/outcome_unknown each time; review-job work='outcome_unknown', billing='pending'; jobs.cancel -> effect_requires_reconciliation; a new review_accept for the same quote -> quote_already_admitted; sponsor reserved stays 500000000000000000.

### 5. Medium: Public dispatcher budget has no per-project share: one admitted project with requester-chosen callbackGas drains the shared 0.0004 test-ETH callback allocation and every other project is refused forev

`adapters/contract-delivery.mjs:99`

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

In 'public-readonly-v1' mode admitContractDispatchBudget requires projectIds to be empty (line 18) and reserve() enforces only the per-transaction ceiling and the global total (lines 99-101). There is no per-project or per-binding accounting for dispatcher spending, unlike executor gas (src/chain-deployments/admission.mjs reserveGas enforces maximumProjectGasWei). The cost of each delivery is largely chosen by the requester: ClausRequestInbox.request() accepts callbackGas up to 1,000,000 (ClausRequestInbox.sol:28,51) and deliverCallback forwards exactly that gas (lines 93-99); a hostile receiver burns all of it and may revert, which still charges the dispatcher's budget via finalize() (sepolia-transactions.mjs:141-142) and yields a 'failed' delivery. The operator's own reference client defaults to CALLBACK_GAS = 1_000_000. Rejected decisions cost no executor gas, so the per-project executor cap does not bound this; a project may hold 32 bindings x 100 requests. With the planned allocation (0.0004 total, 0.0002 per transaction) two callbacks exhaust the pool; afterwards reserve() throws dispatch_budget_exhausted for everyone and the worker defers them on every tick (runtime/contract-intake-worker.mjs:76-78). Spending is immutable by design, so public callback delivery is dead for the other seven projects until the operator authorises a new grant. Amplifier (code-traced): the requester can cancel() its request after result_ready and before the dispatcher's resolve lands (authorize reads 'latest' only, line 139-141); resolve reverts with RequestClosed and the reverted gas is charged to the shared budget. Fix: add a per-project (or per-binding) ceiling to the public dispatch budget and account reservations/spending per request.projectId in reserve()/finalize(), and/or cap the callbackGas the dispatcher is willing to pay for at ingest. Merged from audit_flow and audit_math.

**Reproduction**

Executed: test/scratch/repro-delivery.test.mjs test 'B' (passes against this revision) with the real admitContractDispatchBudget/createContractDelivery/createSepoliaTransactions and a mock client. State: public budget {admissionMode 'public-readonly-v1', projectIds [], maximumTotal 0.0004 ETH, maximumTransaction 0.0002 ETH}, fee 0.12 gwei, deliverCallback estimate 1.2M gas (callbackGas 1,000,000) signed at 1.51M, receiver burns 1.5M gas and reverts. Input: two result_ready 'reject' requests from project 'hostile', one 110k-gas resolve from project 'victim'. Actual: hostile resolve#1 + callback#1 (reverted, 'failed') + resolve#2 + callback#2 (reverted, 'failed') bring the ledger to spent='384000000000000' (0.000384 ETH), reserved='0'; the victim's step rejects with dispatch_budget_exhausted; 4 broadcasts total. Expected: a per-project share so one project cannot deny delivery to the other admitted projects.

### 6. Medium: Projects whose owner pilot expired are admitted through the public chain policy but bypass the public gas ledger (per-project and total executor gas caps)

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

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

createChainDeployments.allowed() (src/chain-deployments/index.mjs:16) falls through to admission.admitted() as soon as a project has no live pilot, so an expired-pilot project is authorised by the public chain.admission policy for registration_prepare/registration_submit, proposal_create and the executor jobs that follow. A public-chain-projects row is only created in deployModule() via reserveProject() (index.mjs:45); registration, Claus decisions and protected-execute never call it. reserveGas() returns at this line before any accounting when that row is missing, so such a project's registerProject/queue/execute transactions are not counted against maximumProjects (8), maximumProjectGasWei (0.0005) or maximumTotalGasWei (0.002); only the host-wide ceilings of createSepoliaTransactions apply (adapters/chain-executor.mjs:12-13 passes no `limits`, so MAX_COST = 0.01 test-ETH per transaction). The same applies to module-deploy jobs queued under a live pilot and executed after expiry. The brief's invariant 'exact per-project and total executor gas' is therefore only enforced for projects that first deploy a module after their pilot ended. Fix: in allowed()/admitted paths, call reserveProject() for any project without a live pilot before authorising executor work (registration, proposal, execute), or make reserveGas() fail closed (chain_gas_exhausted / capacity_reached) when the project row is missing instead of returning silently.

**Reproduction**

Executed: test/scratch/repro-expired-pilot.test.mjs (passes against this revision). State: chain.admission policy active and assessed (maximumProjects 8, 0.0002/0.0005/0.002 ETH caps); project 'former' has review-pilots {enabled:true, expiresAt: past}, no public-chain-projects row; chain-proposals 'proposal-1' belongs to it. Actual: deployments.allowed('owner','former') returns the capability; createSepoliaTransactions({onReserve: admission.reserveGas}).prepare({id:'register:r1', authority:{kind:'project-register', projectId:'former'}}) with 3.76M gas at 2 gwei (0.0075 ETH, >30x the per-transaction cap) returns state 'prepared'; public-chain-usage/global stays null and public-chain-reservations has 0 rows; reserveGas for {kind:'execute', proposalId:'proposal-1'} with 0.009 ETH also records nothing. Contrast: a project that went through reserveProject() is refused with chain_gas_exhausted for the same amount. Expected: chain_gas_exhausted (limits 0.0002/0.0005/0.002) or refusal to admit the project without a public slot.

### 7. Low: After a dispatcher budget rotation, callbacks for requests resolved under the revoked/expired prior budget are never dispatched although the new budget is active

`adapters/contract-delivery.mjs:58`

```
    return createBoundContractDelivery({ ...options, budget: prior }).step(input);
```

createContractDelivery.step() selects the budget from the stored ':resolve' transaction's authority and then runs the whole step, including the separate not-yet-created 'callback' transaction, under that prior budget. For the callback, expiresAt is prior.expiresAt and active() returns false whenever the prior budget is revoked or expired (lines 76-77), so line 155 answers 'pending' and no callback is ever prepared, even though the currently selected public budget is active, funded and admits the project. The worker re-steps such rows on every tick (several RPC reads each) because 'pending' is not terminal; the requester never receives onClausResult unless it pulls the result itself. With the planned 7-day budget expiry this affects every request resolved shortly before expiry and all in-flight requests at the pilot -> public-readonly-v1 migration this revision prepares. Fix: apply the prior budget only to actions that already have a stored transaction under it; for a missing callback transaction fall through to the selected budget (or record a terminal 'failed' delivery with a reason). Merged from audit_economics and audit_math.

**Reproduction**

Executed: test/scratch/repro-delivery.test.mjs test 'I' (passes against this revision). State: budget 'public-budget' selected; request with callback intent; step -> resolve broadcast and confirmed. Then admit 'public-next' (same inbox/codeHash/dispatcher, public mode) and revoke 'public-budget'. Input: three step() calls with 'public-next' selected and allowSubmit true. Actual: 'pending' each time, broadcasts stay at 1, no 'contract-delivery:<id>:callback' row exists, public-next ledger reserved='0' spent='0'. Expected: callback prepared and sent under the active budget, delivery ending 'delivered' or 'failed'.

### 8. Low: A callback whose cost exceeds the per-transaction budget is retried on every tick forever and never recorded; the reference client's 1,000,000 callbackGas makes this the normal case

`runtime/contract-intake-worker.mjs:76`

```
        if (!['wallet_busy', 'dispatch_budget_exhausted', 'transaction_cost_limit',
```

For the callback action transactions.prepare() estimates deliverCallback() (which must leave callbackGas*64/63 + 50,000 gas, ClausRequestInbox.sol:95) and adds the 25 % + 10,000 margin. With ClausRequestClient.CALLBACK_GAS = 1_000_000 the signed gas is about 1.5M, so above roughly 0.13 gwei the cost exceeds a 0.0002 test-ETH maximumTransaction and prepare() throws transaction_cost_limit (sepolia-transactions.mjs:58-60). The worker classifies that code as transient (this line), step() has returned nothing, request.delivery is never written, and the request stays result_ready without a delivery state, so every subsequent tick repeats chain.profile, chain.resolution, transactions.status, two deliveryState reads and a fresh estimateGas for every such request indefinitely, although the resolve already succeeded and the design promises one bounded callback attempt. Hostile requesters make this permanent for their own requests by choosing callbackGas = 1,000,000. Fix: when the callback can never fit (cost > maximumTransaction at the current fee, or after N attempts), record a terminal delivery state such as 'failed' with reason callback_unaffordable so the request leaves the scan set; optionally bound accepted callbackGas at ingest.

**Reproduction**

Executed: test/scratch/repro-delivery.test.mjs test 'L' (passes against this revision). State: public budget maximumTransaction 0.0002 ETH, fee 0.15 gwei, request with callbackGas 1,000,000 (deliverCallback estimate 1.2M gas -> signed 1.51M -> 0.0002265 ETH); resolve confirmed. Actual: three further step() calls each reject with transaction_cost_limit; no ':callback' transaction row is created; two createContractIntakeWorker.tick() runs each report deferred=1 and contract-requests[id].delivery stays undefined. Expected: one bounded attempt or a recorded terminal delivery state.

### 9. Low: A broadcast dispatcher/executor transaction that is never mined holds the single wallet slot and its budget reservation indefinitely; no replacement, expiry or release path exists

`adapters/sepolia-transactions.mjs:123`

```
      try { receipt = await client.getTransactionReceipt({ hash: value.hash }); } catch { return view(value); }
```

reconcile() returns early whenever no receipt is available and never consults intent.expiresAt, the on-chain request deadline or the account nonce. A transaction in state 'submitted' or 'unknown' therefore keeps chain-wallet-slots[walletId].id set forever, so every later prepare() for the same signer fails with wallet_busy (dispatcher: all public result delivery; executor: all deployments, registrations, queue/execute), and the onReserve amount stays in budget.reserved (contract-dispatch-budgets or public-chain-usage), up to maximumTransaction of the 0.0004 ETH dispatcher allocation. The fee is fixed at prepare() time from estimateFeesPerGas(); a Sepolia base-fee rise above the signed maxFeePerGas for longer than mempool retention (cheap to induce with free test ETH) leaves the transaction unmineable, and once the inbox request deadline passes the resolve() call can never succeed anyway. The design note accepts a locked slot across restarts, but no code path or host tool can ever resolve it other than editing chain-wallet-slots and the budget row by hand. Fix: when intent.expiresAt has passed and getTransactionCount(signer,'finalized') > value.nonce with no receipt for value.hash (the signed bytes can no longer be mined), finalize the row as 'superseded', settle the reservation at 0 and release the slot; or expose a host-only same-nonce replacement bounded by the same ceilings. Merged from audit_flow and audit_economics.

**Reproduction**

Executed: test/scratch/repro-stuck-tx.test.mjs (passes against this revision). State: prepare + submit of 'resolve:1' with sendRawTransaction returning the hash (state 'submitted'); getTransactionReceipt always throws (dropped from every mempool). Input: advance the clock 30 days past intent.expiresAt, call reconcile three times, then prepare 'resolve:2'. Actual: reconcile returns 'submitted' each time; prepare rejects with wallet_busy; the onReserve amount (135000000000000 wei) is never finalized; chain-wallet-slots still names 'resolve:1'. Expected: after expiry with the transaction provably unmineable, the slot and reservation are released.

### 10. Low: Dispatcher authorization performs Sepolia RPC reads inside the store transaction that holds the schema-wide PostgreSQL advisory lock, stalling every API write for the RPC round trips

`adapters/contract-delivery.mjs:140`

```
          const observed = await chain.deliveryState(request);
```

createSepoliaTransactions.prepare() and submit() invoke the authorize callback inside store.transaction (adapters/sepolia-transactions.mjs:64 and 94). The contract-delivery authorize callback calls active() (store reads) and then chain.deliveryState(request) on this line, i.e. getBlock, getChainId, getCode, readContract(dispatcher), getBlock(observation.blockNumber) and readContract(requests) sequentially against the public Sepolia RPC. The PostgreSQL store serialises all transactions behind pg_advisory_xact_lock(schema) (src/postgres/index.mjs:53) and sets idle_in_transaction_session_timeout='15s' (line 50); the intake host shares that database with the public API (runtime/contract-intake-host.mjs:18-26). While the worker waits on the RPC, every store.transaction in the API is blocked, including the per-request rate-limit admission in runtime/server.mjs:83-87 that runs for every HTTP/MCP call; if the RPC round trips exceed 15 s idle time PostgreSQL kills the worker session and tick() fails with a non-authority error. The module's own invariant says the lock 'serializes short state transitions, not provider calls'; this path violates it. The executor path keeps RPC validation outside the transaction (adapters/chain-executor.mjs validate()), so the fix pattern exists. Fix: evaluate chain.deliveryState() before entering the transaction (the pre-check at line 149 already does) and pass the observed snapshot into authorize, keeping only store reads inside the lock. Merged from audit_flow and audit_economics.

**Reproduction**

Code-traced (not executed against PostgreSQL): adapters/sepolia-transactions.mjs:61-64 opens store.transaction and awaits authorize(intent, tx); adapters/contract-delivery.mjs:135-141 is that callback and awaits chain.deliveryState(request) (adapters/contract-intake.mjs:97-118: getBlock, verify() = getChainId+getCode+readContract, getBlock, readContract); src/postgres/index.mjs:48-53 takes pg_advisory_xact_lock for the whole schema at the start of every transaction. Input: one result_ready request being prepared while the public RPC answers each call in ~3 s (or stalls to its 15 s timeout). Expected: API writes commit in milliseconds; RPC latency is paid outside the lock. Actual: all concurrent API transactions (rate-limit admission included) wait behind the advisory lock for the whole RPC sequence; beyond 15 s idle the worker transaction is terminated.

### 11. Low: Contract bindings (and long-running chain jobs) created from a 60-minute owner session credential silently stop working after the credential expires

`src/contract-intake/index.mjs:32`

```
    await identity.authorize(binding.actor, 'contract.request', { projectId: binding.projectId }, reader);
```

bind() stores the calling credential digest as binding.actor and every later step re-authorises that exact credential: ingest() and run() via admitted() (this line and 33), delivery via identity.authorize(binding.actor, ...) in adapters/contract-delivery.mjs:87-88. Owner session credentials expire after OWNER_TTL = 60 minutes (src/identity/index.mjs:18,138) and cannot be renewed; bind() accepts them (contract.request is an ordinary owner project scope) and allows expiresAt up to 30 days. After one hour identity.credential() throws unauthenticated, allowed() converts that to false (lines 70-72) and authorize() throws scope_denied, so new inbox requests for the binding are recorded as 'blocked' in contract-intake-rejections while contract_binding_get still shows a healthy binding, and a request already in result_ready is never resolved on chain because active() returns false and step() answers 'pending' forever. The same pattern affects proposals created with an owner session whose Claus decision or per-project accepted delay exceeds an hour (approvals.runDecision/runExecution authorize claim.job.authority.actor), where the off-chain record ends 'blocked' although the published Claus signature stays executable by anyone after readyAt. Fail-closed, so no authorization bypass, but the public intake path is unreliable for exactly the free owner accounts the pilot targets. Fix: require an agent credential/mandate for bind() (reject kind 'owner'), or authorise later steps against the project's current owner/mandate rather than the original credential digest. Merged from audit_flow and audit_permissions.

**Reproduction**

Code-traced: src/identity/index.mjs:138 sets owner credential expiresAt = now()+OWNER_TTL (3,600,000 ms); credential() (line 44) throws unauthenticated once passed; allowed() (lines 70-72) returns false for unauthenticated; authorize() (line 76) throws scope_denied. src/contract-intake/index.mjs:36-59 bind() stores actor in the binding (line 44) and accepts any actor that passes admitted() (owner scopes include contract.request and proposal.create). Input: owner logs in, contract_binding_create {expiresAt: now+7d, maxRequests 10}; the requester contract submits a request 61 minutes later; another request became result_ready meanwhile. Expected: both are processed until the binding expires or is revoked. Actual: ingest -> admitted() -> scope_denied -> recorded in contract-intake-rejections (worker line 38-45); delivery active() -> scope_denied -> false -> step returns 'pending' every tick (contract-delivery.mjs:89-90,155).

### 12. Low: Executor jobs whose gas reservation can never succeed (chain_gas_exhausted, transaction_cost_limit, invalid_transaction after expiry) are retried every 60 s forever and hold their public project slot

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

```
        return jobs.finish(claim, { state: 'waiting', retryAt: now() + 60000,
```

reserveGas (src/chain-deployments/admission.mjs:73,79) throws chain_gas_exhausted from inside createSepoliaTransactions.prepare when a public project's maximumProjectGasWei or the total is used up, and prepare itself throws transaction_cost_limit when gas*maxFeePerGas exceeds the ceiling, and invalid_transaction once value.expiresAt has passed (sepolia-transactions.mjs:46). None of these codes is in adapters/authorization.mjs authorityDenied, so chain-executor.transact rethrows and runOne's catch re-queues the job 'waiting' with a 60 s retry (this line); the 'blocked' branch (line 133) only covers scope_denied/sponsor_not_enabled/chain_not_enabled and only in dispatch mode, which beginEffect has already consumed. Each retry performs estimateGas/estimateFeesPerGas/getTransactionCount/getBalance against the RPC; the chain-modules row stays 'queued' and the project's reserved public slot (1 of 8) is consumed by a module that will never exist. The same loop applies to protected-execute jobs. A hostile public project that exhausts its own 0.0005 ETH therefore creates permanent background work for the executor worker. Fix: treat chain_gas_exhausted, transaction_cost_limit (when no transaction exists yet) and invalid_transaction (intent expired) as terminal in runOne/runExecution, mark the module/proposal 'blocked' and release the public project slot when a module is abandoned.

**Reproduction**

Code-traced: adapters/authorization.mjs lists the authorityDenied codes (chain_gas_exhausted, transaction_cost_limit and invalid_transaction are absent); adapters/chain-executor.mjs:30-35 rethrows non-authority errors from prepare(); src/chain-deployments/index.mjs:131-140 maps any other error to {state:'waiting', retryAt: now()+60000}. Input: public project p1 with public-chain-projects spent = maximumProjectGasWei; a module-deploy job for p1 claimed every tick. Expected: the job ends 'blocked'/'failed' and the module is marked accordingly. Actual: every tick re-estimates and re-queues 'waiting' indefinitely; after 24 h the intent's expiresAt makes prepare throw invalid_transaction, which is equally non-terminal.

### 13. Low: No cap on proposals or ingested contract requests per admitted project: unbounded operator-paid Claus decisions and intake queue crowding

`src/approvals/index.mjs:74`

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

approvals.create() only serialises proposals per project (this line); there is no per-project, per-day or global count and no access-policy key for it (src/access/index.mjs ACCESS_DEFAULTS has none). Every proposal enqueues a claus-review job whose runDecision() POSTs the full input (up to 900 KB including the review report and all source files, line 64) to the Claus agent (adapters/claus-controller.mjs:55 call('/reviews', input)), an operator-paid model review; rejected proposals cost no gas, so an admitted public project can loop proposal_create -> wait for decision -> proposal_create bounded only by decision latency and the 300 req/5 min rate limit, driving unbounded inference spend although the brief excludes public inference. On the intake side the only bounds are maxRequests <= 100 per binding and 32 bindings per project (src/contract-intake/index.mjs:40,53), i.e. 3,200 queued requests per project; createContractIntake.run() claims one job per tick, and every request behind an in-progress proposal reaches createProposal(), performs the full artifact/evidence/RPC assembly of approvals.create() and only then fails with proposal_in_progress (not in the denied list, lines 147-148), returning to 'waiting' for 60 s, so other admitted projects' result recording is delayed behind thousands of unproductive jobs. Fix: add an access-policy limit on proposals per project per day (and a lifetime decision budget per public project), check active-proposals before assembling the input, and bound queued contract requests per project.

**Reproduction**

Code-traced: src/approvals/index.mjs:32-86 contains no counter read or write other than active-proposals; src/access/index.mjs:5-14 defines no proposal limit; adapters/claus-controller.mjs:50-55 records the decision request and POSTs the input per proposal. Input: admitted project P repeats proposal_create {moduleId, configuration: random bytes, idempotencyKey: i} after each decision; P also creates 32 bindings and its requester contract submits 3,200 inbox requests with a 30-day deadline. Expected: an admission limit such as N decisions per project per day, and cheap rejection of requests behind an in-progress proposal. Actual: every proposal is accepted and triggers one Claus review call; every queued request is re-assembled and re-queued each 60 s while P's first proposal is reviewing, one claim per tick for all projects.

---

Judge's submission `9221100d7da30ba9bcf80337249a2828096c2ace76470e78123f02f96bfddba4`, 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.
