{"jobId":"05097b72-02e2-40e8-b9b7-4f7087fe18f8","repoUrl":"https://github.com/hazarxyz/claus-network","baseCommit":"91e8ba266d13e2532fd2e10fb5c456c639d39055","count":5,"submissions":[{"hash":"ef6d9de1fd0e852e3dad5b2354a13a040163644f7a525a1081ae541d4e6d436e","nodeKey":"audit_judge","role":"review","attempt":1,"deviceKey":"d0653dc91b6e2259689c48678a76069799bcf9fc4239f5491a775162e81c2f6e","seat":{"tokenId":"795","agentId":"51566"},"outcome":"completed","accepted":true,"failureReason":null,"failureClass":null,"safetyRefusal":false,"usage":{"model":"claude-fable-5-1","turns":41,"runtime":"claude","inputTokens":578,"wallClockMs":480040,"outputTokens":38709,"cachedInputTokens":2297005},"baseCommit":"91e8ba266d13e2532fd2e10fb5c456c639d39055","startCommit":"91e8ba266d13e2532fd2e10fb5c456c639d39055","resultTreeHash":null,"bundleHash":null,"bundleBytes":0,"artifacts":[],"changedPaths":[],"allowedPaths":[],"dependencyBundleHashes":[],"verificationErrors":0,"summary":"Review complete. The findings file holds nine reproduced findings, written to `.imd-findings.json` at the repository root.\n\n**Outcome.** Every specialist finding reproduced against the code. The twenty specialist entries collapsed into nine distinct defects after merging duplicates across the four specialists. None is critical or high, and all are in JavaScript, so no Foundry proof was required. Each finding carries a node reproduction that I wrote and ran under test/scratch/, since the specialists' own scratch files were not present in the tree.\n\n**Medium findings**\n- **Expired-pilot projects escape every public ceiling.** All public guards key on a `public-chain-projects` row that is only written at module deploy or first gas reservation. A project whose pilot expired is admitted by the public policy branch but never enrolled, so it skips the decision ceilings, the durable-agent rule and the one-request intake limit. Reproduced: with every counter full, an owner session on such a project reaches module lookup while an enrolled control project is refused.\n- **Spam fills the 32-event intake page.** The page bound is applied to raw inbox logs before unadmitted bindings are discarded. Reproduced: 320 hostile events delay one admitted event by eleven ticks.\n- **Dispatcher budget admits a project share below one transaction.** The planned allocation is accepted and a 1 gwei resolve is rejected on a fresh project with nothing spent. I noted the limit that publicnode's current fee suggestion of 0.001 gwei passes today.\n\n**Low findings.** Released sponsor exposure cannot be reused for the same build. A daily decision cap terminally blocks a live intake request. A second onchain request rejected as in progress is dropped permanently. Enrollment capacity failures and expired deployment windows both loop every sixty seconds instead of blocking. One oversized block beyond the provider's log cap stalls the cursor for everyone, with the provider cap stated as unverified.\n\n**Limits.** The live chain checks I ran were read-only RPC queries for fee data and the provider's range cap. The forge gas report ran on the contracts directory and backed the inbox gas figures. No source, config or test outside test/scratch/ was changed.","createdAt":"2026-10-12T12:16:54.172Z","verdict":null,"findings":[{"severity":"medium","title":"Expired-pilot projects admitted under the public chain policy bypass every public decision ceiling, the durable-agent rule and the one-request intake limit until first gas enrollment","path":"src/approvals/limits.mjs","line":7,"description":"All four public decision ceilings (2/project/day, 8/global/day, 4/project lifetime, 32/global lifetime) and the durable-agent requirement in approvals.create (src/approvals/index.mjs:40-42), submitRegistration (src/chain-deployments/index.mjs:91-92), contract-intake bind (src/contract-intake/index.mjs:52) and the one queued/waiting intake request per project rule (src/contract-intake/index.mjs:114) are all keyed on the existence of a 'public-chain-projects' row. That row is written only by reserveProject, which runs on deployModule (index.mjs:49) or on the first gas reservation in reserveGas (admission.mjs:82). A project whose review pilot has expired is routed to the public chain-admission policy by deployments.allowed (index.mjs:16-20, `!pilot?.enabled || pilot.expiresAt <= now()`), which admits it as long as the policy is active and its binding is on the current executor, but never enrolls it. Such a project (verified module and chain binding from pilot time, pilot now expired) therefore spends from the public allocation while being counted as nobody: publicDecisionCapacity returns before reading any counter, an owner session (1h TTL) is accepted instead of a delegated agent, and it may hold unlimited concurrent queued intake requests. Each accepted proposal enqueues a claus-review job that consumes the operator model budget; rejected/failed/blocked/cancelled proposals free active-proposals so it can loop indefinitely one proposal at a time, and its decisions never count toward the global 8/day and 32 lifetime ceilings that are meant to bound Sybil spend. docs/operations.md:87 and docs/reviews-and-contracts.md:58 state these projects are inside the ceilings. The same gap lets submitRegistration enqueue a project-register job under a 1-hour owner credential that only becomes enrolled inside reserveGas. Fix: compute the public predicate the way deployments.allowed does (no active pilot OR enrolled) and apply it in publicDecisionCapacity, approvals.create, submitRegistration, bind and ingest; or call admission.reserveProject inside the proposal-creation transaction (approvals.create) so capacity_reached is raised before any decision is spent. Merged from audit_permissions, audit_flow and audit_math.","reproduction":"State (test/scratch/a-expired-pilot.test.mjs, node --test): active chain.admission policy published/activated through createChainAdmission with an accepted assessment; review-pilots[p1] = {enabled:true, expiresAt: now-1}; chain-bindings[p1].executor === policy.executor; no public-chain-projects[p1]; public-chain-projects[p2] present (control); every public-decision-admission counter for p1, p2 and global at its maximum (2/2/8 day, 4/4/32 lifetime). createChainDeployments.allowed('owner-session','p1') resolves (public branch admits it). approvals.create('owner-session','p2',{moduleId,configuration,idempotencyKey}) rejects with decision_capacity_reached (expected). approvals.create('owner-session','p1', same body) with a stub deployments.module that throws reached_module_lookup: expected decision_capacity_reached or durable_agent_required; actual reached_module_lookup, i.e. both guards were skipped for an owner session with every ceiling full. Then store.transaction(tx => publicDecisionCapacity(tx,'p1',now,true)) 40 times: all succeed and the p1 lifetime counter stays at 4 and the global lifetime counter at 32 (no counter was read or written).","snippet":"  if (!await reader.read('public-chain-projects', projectId)) return;","citation":"resolved","blocking":false},{"severity":"medium","title":"The 32-event intake page is filled by unadmitted Requested logs, so free inbox spam starves admitted requests","path":"adapters/contract-intake.mjs","line":65,"description":"The 0.11.3 bound of 32 events per hosted tick is applied in the adapter to the raw Requested logs of the inbox (sorted by block/logIndex) before runtime/contract-intake-worker.mjs:82-88 discards events whose binding word is not an admitted contract-bindings row. ClausRequestInbox.request is permissionless and only requires a non-zero binding word, so anyone can emit events with an unregistered binding for Sepolia gas alone (forge gas report on contracts/: request averages 63,139 gas, 98,267 max; Sepolia base fee was 23 wei on 2026-10-12). Each hostile event costs the network one of the 32 page slots although the worker does no work for it, so an admitted request behind N hostile events is ingested only after ceil((N+1)/32) ticks. Once the requester's onchain deadline passes, ingest rejects it with contract_request_closed (src/contract-intake/index.mjs:109), so a sustained spammer above 32 events per tick makes public intake lag forever and expires every real request. The previous version advanced up to 500 blocks per tick and skipped junk for free. Because the Requested event indexes binding (topic 3), the scanner can pass the set of admitted binding words as a topic filter, or the 32-bound can count only admitted/ingested events while unknown bindings are skipped within the fetched range. Merged from audit_permissions, audit_math and audit_economics.","reproduction":"State (test/scratch/b-scan-starvation.test.mjs, node --test): real createContractInboxRead with a mock client whose finalized block 1 holds 320 Requested logs with binding 0xab..ab (no contract-bindings row) at logIndex 0..319 followed by one log with the admitted binding 0xcd..cd at logIndex 320; contract-bindings['sha256:cd..cd'] present for the pinned inbox; real createContractIntakeWorker with a recording intake.ingest. Expected: the admitted event is ingested on tick 1 (the worker comment says unadmitted traffic incurs no work). Actual: ticks 1-10 return ingested 0 and advance afterLogIndex by 32; the admitted event is ingested on tick 11 (asserted ticks === 320/32 + 1). Generalised: N hostile events delay an admitted request by ceil((N+1)/32) ticks; at one tick per minute, about 46k hostile events defer every admitted request by a day.","snippet":"      if (remaining) logs = logs.slice(0, 32);","citation":"resolved","blocking":false},{"severity":"medium","title":"Dispatch budget admission accepts a per-project ceiling below the per-transaction ceiling; the planned 0.00005/project vs 0.0002/transaction allocation cannot fund a resolve above ~0.37 gwei","path":"adapters/contract-delivery.mjs","line":23,"description":"admitContractDispatchBudget only checks 0 < maximumProject <= maximumTotal; it never requires maximumTransaction <= maximumProject. The sibling chain.admission schema enforces maximumProjectGasWei >= maximumTransactionGasWei (src/chain-deployments/admission.mjs:23) precisely so an admitted transaction size is reservable for at least one project. The allocation stated in the task brief and docs/operations.md:91 (0.0004 ETH total, 0.00005 ETH per project, 0.0002 ETH per transaction) is accepted, yet reserve() (lines 102-112) admits any reservation <= 2e14 wei and then rejects it with dispatch_project_budget_exhausted whenever gas*maxFeePerGas > 5e13 wei, even for a project that has spent nothing. sepolia-transactions.prepare reserves (estimate*1.25+10000)*maxFeePerGas; with a ~100k-gas resolve that is ~135k gas, so the first resolve of every public project fails at any maxFeePerGas above ~0.37 gwei, and resolve plus callback must both fit under the same 5e13 lifetime share. The worker then records delivery.reason = dispatch_project_budget_exhausted with a 15-minute retry (runtime/contract-intake-worker.mjs:45-53) until the request deadline closes it, after the project's decision capacity and binding quota were already consumed. Limit: with publicnode's current eth_maxPriorityFeePerGas suggestion (0.001 gwei, base fee 23 wei, read 2026-10-12) the reservation is ~1.4e11 wei and passes; the 50th-percentile priority fee actually paid on Sepolia in the same blocks is 1 gwei, so any provider or fee spike using that level makes every public delivery defer. Fix: require amount(config.maximumProject) >= perTransaction in admitContractDispatchBudget (mirroring the chain-admission schema) and size the public allocation so one project share covers at least one resolve plus one callback at the configured maximumFeePerGas. Merged from audit_permissions, audit_flow, audit_math and audit_economics.","reproduction":"Input (test/scratch/c-dispatch-budget.test.mjs, node --test): admitContractDispatchBudget(store, {id:'public-7d', chainId:11155111, dispatcher, inbox, codeHash, projectIds:[], admissionMode:'public-readonly-v1', expiresAt: now+7d, maximumTotal:'400000000000000', maximumProject:'50000000000000', maximumTransaction:'200000000000000', maximumGas:'1000000', maximumFeePerGas:'50000000000'}). Expected: invalid_dispatch_budget. Actual: accepted and stored. Then with a result_ready request for a fresh project, a client reporting estimateGas 100000 and maxFeePerGas 1 gwei (reservation 135000*1e9 = 1.35e14 wei, inside the 2e14 per-transaction ceiling), createContractDelivery(...).step({id, request, allowSubmit:true}) rejects with dispatch_project_budget_exhausted; the budget ledger shows spent '0', reserved '0'; createContractIntakeWorker.tick() returns deferred 1 and writes delivery {reason:'dispatch_project_budget_exhausted', retryAt: now+900000} on the request.","snippet":"  if (publicAdmission) requireThat(amount(config.maximumProject) > 0n && amount(config.maximumProject) <= total,","citation":"resolved","blocking":false},{"severity":"low","title":"A provably-unsent cancellation releases public sponsor exposure but leaves the review case 'admitted', so the same build can never be re-reviewed","path":"src/review-flow/index.mjs","line":72,"description":"The new cleanup paths (runPaid denied branch, lines 299-305, and onCancel, lines 205-209) cancel the review job, void billing and call admission.releaseUnsent so the lifetime exposure is returned, but neither moves review-cases[id].state out of 'admitted'. request() deduplicates by inputHash and returns any prior case in state admitted or reviewed ('A paid or uncertain attempt is never replaced by a new key'), and accept() for that case fails with quote_already_admitted because admitted-quotes[quoteId] is permanent. So after the network itself proved nothing was sent and gave the sponsor capacity back, the project cannot use that capacity for the same build; it must change the commit to get a new inputHash. The comment's rationale does not apply because cancelUnsent/releaseUnsent only succeed when the attempt is provably unsent (work cancelled, billing void). The existing test in test/review-flow.test.mjs re-accepts only after changing baseCommit, so this path is untested. Fix: when releaseUnsent runs, move the case back to 'quoted' (or to a terminal 'cancelled' state that request() ignores) so a fresh quote/admission can be created. Merged from audit_permissions and audit_economics.","reproduction":"State (test/scratch/d-review-stuck.test.mjs, node --test, real createApplication): public sponsorship policy active (maximumLifetime '5'); request -> runQuote -> accept for build B; runPaid with a payment adapter whose prepare records state signing_unknown and throws payment_signing_unknown. Result: workflow state 'blocked', admission.get().committedMaximum === '0' (exposure released). Then flow.request(actor, project, {...sameInput, idempotencyKey:'retry'}). Expected: a new case/quote for build B. Actual: returns the original case id; review-cases[id].state is still 'admitted' and get() reports 'blocked'; flow.accept(caseId, quoteId, 'retry') throws quote_already_admitted; runQuote() is idle.","snippet":"          if (['admitted', 'reviewed'].includes(prior.state)) return prior;","citation":"resolved","blocking":false},{"severity":"low","title":"A transient daily decision ceiling terminally blocks a live intake request and keeps its binding quota consumed","path":"src/contract-intake/index.mjs","line":160,"description":"publicDecisionCapacity raises the same code, decision_capacity_reached, for the resetting day counters (2/project/day, 8 global/day) and for the lifetime counters (4/project, 32 global). run() lists that code among deterministic denials, so the contract-intake job finishes 'blocked' (terminal for jobs.claim) and the request is written state 'blocked'. The request had already been admitted by ingest (binding.used incremented at line 121, active-contract-request set) and the onchain request stays Pending until its deadline (up to 30 days) with no resolve and no callback, even though capacity returns at the next UTC day. Because the global day counter is shared, four other public projects submitting two proposals each exhaust the 8/day global cap and every further public intake request admitted that day is lost, not delayed. The only way forward is a new onchain request that consumes another binding slot and pays gas again. Fix: distinguish day from lifetime exhaustion (separate codes or a reason field) and map the day case to 'waiting' with retryAt at the next day boundary bounded by the request deadline, or pre-check capacity in ingest before consuming quota, like contract_request_in_progress. Merged from audit_permissions and audit_economics.","reproduction":"State (test/scratch/e-daily-cap-blocks.test.mjs, node --test): public-chain-projects[project-a] present; agent binding with maxRequests 2; observed request with deadline now+29d; createProposal throws decision_capacity_reached (as approvals.create does when a day counter is full). api.ingest -> binding.used = 1. api.run(): job state 'blocked', result.error 'decision_capacity_reached', contract-requests[id].state 'blocked'. Advance time by one day and call api.run() again: returns idle (nothing claimable); the request stays 'blocked' and binding.used stays 1. Expected: 'waiting' with a retry after the day rolls over.","snippet":"            'contract_request_closed', 'decision_capacity_reached', 'durable_agent_required'].includes(error.code);","citation":"resolved","blocking":false},{"severity":"low","title":"A second onchain request rejected as contract_request_in_progress is dropped permanently: the cursor advances past it and it is never re-ingested or visible to the project","path":"runtime/contract-intake-worker.mjs","line":93,"description":"contract_request_in_progress is a transient condition (one queued/waiting request per public project, src/contract-intake/index.mjs:113-118) but the worker lists it among deterministic rejections. The event is written only to contract-intake-rejections (keyed per binding, overwritten by the next rejection, and read by no API, MCP operation or worker) and the intake cursor is saved past its block. When the first request completes, cancels or expires nothing revisits the second event, so the requester's finalized Requested event (for which it paid gas, with a deadline up to 30 days) is silently never resolved and stays Pending onchain until it expires; contract_request(projectId, id) for it returns not_found. docs/contract-intake.md:100 says 'Completion ... lets the next request enter', which only holds for a request submitted later. Fix: stop the batch at the in-progress event and save the cursor before it (retry from there once the slot is free), or keep a bounded per-project deferred list drained when the active request leaves queued/waiting; and expose the latest per-binding rejection through contract_binding or a read operation so a requester can see why nothing happened. Merged from audit_permissions and audit_flow.","reproduction":"State (test/scratch/f-in-progress-drop.test.mjs, node --test, real createContractIntake and createContractIntakeWorker): public project with an admitted binding (maxRequests 10); one scan page returns two finalized Requested events for that binding (requestIds 0x33..33 and 0x66..66, deadlines 7 days out). worker.tick(): ingested 1, contract-intake-rejections holds {code:'contract_request_in_progress'} for the second event, contract-intake-cursors saved at afterBlock 200. Decide the first proposal, advance 60 s and api.run() so request 1 reaches result_ready. worker.tick() again (scan returns no new events): ingested 0; no contract-requests row exists for requestId 0x66..66; api.request('agent','project-a', id2) throws not_found; binding.used is still 1. Expected: the second request is ingested once the project slot is free.","snippet":"        if (!['contract_intake_disabled', 'contract_binding_inactive', 'contract_binding_exhausted', 'contract_request_closed', 'contract_request_in_progress',","citation":"resolved","blocking":false},{"severity":"low","title":"Expired-pilot enrollment at maximumProjects throws capacity_reached from inside prepare(); the executor treats it as transient and retries every 60 s with four RPC calls per attempt","path":"src/chain-deployments/admission.mjs","line":82,"description":"reserveGas now enrolls an expired-pilot project by calling reserveProject inside the wallet transaction, which throws capacity_reached when public-chain-usage.global.projects >= maximumProjects. The throw happens inside sepolia-transactions.prepare after estimateGas, estimateFeesPerGas, getTransactionCount and getBalance have run. capacity_reached is not in adapters/authorization.mjs authorityDenied, so adapters/chain-executor.mjs transact() rethrows it instead of returning cancelled, and both src/chain-deployments/index.mjs:147 and src/approvals/index.mjs:209 finish the job 'waiting' with a 60-second retry (the 15-minute deferral list covers only chain_gas_exhausted and transaction_cost_limit). A full public ledger therefore produces a permanent one-minute loop of capability reads plus four RPC calls until the module (24 h), registration (1 h) or proposal window expires, and the job never surfaces as blocked. For a proposal the Claus decision (model budget) was already spent on something that can never execute. Fix: check public project capacity in deployments.allowed / approvals.create for the expired-pilot branch before a decision is enqueued, and treat capacity_reached from reserveGas as terminal (add it to the blocked list or map it to an authority code). Merged from audit_flow and audit_math.","reproduction":"State (test/scratch/g-capacity-retry.test.mjs, node --test): active chain.admission policy with maximumProjects 1 and one enrolled public project (public-chain-usage.global.projects 1); review-pilots[p1] expired; chain-bindings[p1].executor === policy.executor. store.transaction(tx => admission.reserveGas(tx, {id, maximum:'5', intent:{authority:{projectId:'p1'}}})) rejects with capacity_reached; authorityDenied({code:'capacity_reached'}) is false; no enrollment or reservation row is written. A queued module-deploy job for p1 run through createChainDeployments.runOne with an executor step that raises that code finishes {state:'waiting', availableAt: now+60000, result.error:'capacity_reached'} on each of three consecutive attempts while chain-modules[m1].state stays 'queued'. Expected: a terminal blocked outcome or a refusal before the decision is spent.","snippet":"        await reserveProject(tx, projectId);","citation":"resolved","blocking":false},{"severity":"low","title":"A single finalized block holding more Requested logs than the provider returns per eth_getLogs call stalls the intake cursor for every project","path":"adapters/contract-intake.mjs","line":55,"description":"The adaptive scanner halves the block range on any getLogs error and rethrows once the range is a single block. The log-index cursor only helps when the provider actually returns that block's logs (the adapter's own 5000 cap then pages 32 at a time). If one finalized block contains more Requested logs than the provider's per-call result cap, every scan of the page containing it throws; runtime/contract-intake-worker.mjs:77-78 records scanError and line 112 does not save the cursor, so no later block is ever ingested for any project, and each tick repeats the full halving sequence (about log2(limit) getLogs calls, 10 per tick measured) against the provider. ClausRequestInbox.request is permissionless and the filter is only address+event, so unadmitted callers can fill a block at the cost of Sepolia gas. Limit: reachability depends on the pinned provider's result cap, which could not be confirmed (publicnode enforces a 50,000-block range cap and prunes old history; its per-call result limit was not observable), against the Sepolia block gas limit. Fix: when a single block still fails, fall back to a query that pages within the block (topic filter restricted to admitted binding words, or a block receipt walk), or at minimum record and alert on the oversized block and advance past it. From audit_flow.","reproduction":"State (test/scratch/h-single-block-stall.test.mjs, node --test): mock provider whose getLogs throws {code:-32005} whenever the requested range includes block 120 (1,001 logs, cap 1,000) and returns [] otherwise; worker startBlock 120 (cursor afterBlock 119). createContractIntakeWorker.tick() three times: each returns scanError -32005 and afterBlock 119; contract-intake-cursors never gets a row; the provider receives 30 getLogs calls over the three ticks. Expected: progress past or around the oversized block, or an explicit alert, instead of an unbounded stall.","snippet":"          if (end === from) throw error;","citation":"resolved","blocking":false},{"severity":"low","title":"A gas-deferred deployment or registration that outlives its window never becomes terminal: prepare() throws invalid_transaction, which runOne retries every 60 s forever","path":"src/chain-deployments/index.mjs","line":147,"description":"0.11.3 adds visible 15-minute deferrals for chain_gas_exhausted / transaction_cost_limit. A module-deploy has a 24-hour window (deployModule: expiresAt = now + 86400000) and a registration a 1-hour window. Once a deferred job's window passes, adapters/sepolia-transactions.mjs:45-46 rejects prepare() with invalid_transaction (expiresAt > now() fails). adapters/chain-executor.mjs transact() only converts authorityDenied codes into a terminal 'cancelled'; invalid_transaction is rethrown, and runOne maps every non-authority error to 'waiting' with retryAt now()+60 s. The chain-modules / chain-registrations document keeps state 'queued', the job never reaches blocked/failed, and each retry performs capability() (getChainId + getCode) plus database reads. With the planned finite gas allocation (0.0005 ETH per project, 8 projects) exhaustion is the expected steady state, so every public deployment that waited out its window becomes a permanent minute-cadence RPC loop on the chain worker. Proposals are protected because the executor checks p.expiresAt itself (approval_expired); deployments and registrations have no terminal path. Fix: in transact() or runOne, treat an intent whose expiresAt <= now() as terminal (e.g. blocked with error deployment_expired / registration_expired) and mark the row accordingly. From audit_economics.","reproduction":"State (test/scratch/i-expired-deferral.test.mjs, node --test): createSepoliaTransactions(...).prepare({id:'deploy:m1', data:'0x60', authority:{kind:'module-deploy', projectId:'p1', moduleId:'m1'}, expiresAt: now-1}) rejects with invalid_transaction before any wallet work; authorityDenied({code:'invalid_transaction'}) is false, so chain-executor transact() rethrows it. A queued module-deploy job for chain-modules[m1] with expiresAt in the past, run through createChainDeployments.runOne with an executor step raising invalid_transaction, finishes {state:'waiting', availableAt: now+60000} on each of three consecutive attempts while chain-modules[m1].state stays 'queued'. Expected: a terminal blocked state with the row marked expired.","snippet":"        return jobs.finish(claim, { state: 'waiting', retryAt: now() + (['chain_gas_exhausted', 'transaction_cost_limit'].includes(error.code) ? 15 * 60000 : 60000),","citation":"resolved","blocking":false}],"coverage":null},{"hash":"4d5fe4579e78b34d3680e3d9a2bb9afdf9c85ea64c9f821157c4832bb07eaa34","nodeKey":"audit_permissions","role":"review","attempt":1,"deviceKey":"d68ba89c0b30801cc4e85d6eeb132f21cc8cadee6a15ef0adb1a68d00542d19e","seat":{"tokenId":"969","agentId":"50961"},"outcome":"completed","accepted":true,"failureReason":null,"failureClass":null,"safetyRefusal":false,"usage":{"model":"claude-fable-5-1","turns":55,"runtime":"claude","inputTokens":450,"wallClockMs":1034798,"outputTokens":70666,"cachedInputTokens":2440008},"baseCommit":"91e8ba266d13e2532fd2e10fb5c456c639d39055","startCommit":"91e8ba266d13e2532fd2e10fb5c456c639d39055","resultTreeHash":null,"bundleHash":null,"bundleBytes":0,"artifacts":[],"changedPaths":[],"allowedPaths":[],"dependencyBundleHashes":[],"verificationErrors":0,"summary":"The review is complete. The findings file is written and validated, and no source files were modified.\n\n**What I found** (all in the changed admission and recovery paths, all reproduced with runnable tests under `test/scratch/`):\n\n- **Medium: expired-pilot projects escape the public decision ceilings.** Every public limit and the durable-agent rule key on membership in `public-chain-projects`, but a project whose pilot expired is only enrolled when the executor reserves gas. A formerly piloted project with a verified module and a binding on the current executor can create proposals with an owner session while all four counters are at their maximum, spending Claus decisions that are never counted. Reproduction uses the real chain-admission code and shows the call reaching module lookup instead of `decision_capacity_reached`.\n- **Medium: scan starvation is bounded for the victim, not the attacker.** The 32-event page is sliced from raw inbox logs before the worker discards unadmitted bindings. Ten pages of `request()` calls with an unregistered binding word delay a legitimate request by ten ticks. The `binding` topic is indexed, so the RPC could filter spam out entirely.\n- **Medium: the planned dispatcher allocation cannot fund a single resolve.** Budget admission accepts a per-project ceiling below the per-transaction ceiling, unlike the chain-admission schema. With the stated plan, a ~100k-gas resolve at 1 gwei is rejected with `dispatch_project_budget_exhausted` at zero spend and is deferred every 15 minutes until the request expires.\n- **Low: a provably-unsent cancellation releases sponsor exposure but leaves the review case in `admitted`,** so the same build can never be re-requested.\n- **Low: a daily decision ceiling terminally blocks a live intake request** that may have 29 days of deadline left, and the binding quota is consumed.\n- **Low: requests dropped for `contract_request_in_progress` are invisible to the project** and stay pending on-chain until their deadline.\n\n**What held up.** The adaptive block/log cursor does not lose or repeat events, including a single block larger than one page. Rejection retention is bounded per admitted binding. Lifetime sponsor exposure is released only after `cancelUnsent` succeeds and daily attempt counters stay consumed. Local signing failures close through the fenced path on restart. The minimum payment window is enforced before signing or nonce use. Chain reads are outside the database write transaction. Pending transactions keep their original budget after rotation. The Inbox bounds deadlines and derives ids from a per-sender nonce, so a hostile event cannot poison `observe()` or stall the cursor.\n\n**Verification.** The full JS suite passes (389 tests, 11 environment skips) and the Foundry suite passes (62 tests). No Solidity changed in this candidate, so no Foundry proof files were needed. I did not test against Sepolia or PostgreSQL. The six findings are in `.imd-findings.json` with exact failing inputs.","createdAt":"2026-10-12T12:06:40.777Z","verdict":null,"findings":[{"severity":"medium","title":"Expired-pilot projects bypass every public decision ceiling and the durable-agent rule until their first gas reservation","path":"src/approvals/limits.mjs","line":7,"description":"All four public decision ceilings (2/project/day, 8/global/day, 4/project lifetime, 32/global lifetime) and the `durable_agent_required` check in `approvals.create` (src/approvals/index.mjs:40-42) are keyed on membership in `public-chain-projects`. A project whose review pilot has expired is moved under the public chain-admission policy by `deployments.allowed` (src/chain-deployments/index.mjs:16-20), but it is only enrolled in `public-chain-projects` when it deploys a new module or when the executor reserves gas (src/chain-deployments/admission.mjs:73-83). An expired-pilot project that already holds a verified module and a `chain-bindings` row on the current executor therefore spends from the public allocation without ever being counted: `publicDecisionCapacity` returns before reading any counter, an owner session (1h TTL) is accepted as job authority, and each proposal enqueues a Claus decision that consumes the operator's model budget. Rejected, cancelled, failed or blocked proposals free `active-proposals`, so the project can repeat this without limit; it never reaches `reserveGas` (which is the only place that would enroll it) unless a proposal is approved and executed. The docs (reviews-and-contracts.md: 'Expired pilot projects must satisfy the current public admission policy and its gas ledger'; operations.md: 'Public Claus decisions are limited to ... 32 globally') claim these projects are inside the ceilings. The same gap lets `submitRegistration` for such a project enqueue a `project-register` job under a 1-hour owner credential, which then enrols the project (counting toward `maximumProjects`) inside `reserveGas` and later fails `authorize` once the owner credential expires. Fix: in `publicDecisionCapacity`, `approvals.create`, `submitRegistration` and `deployModule`, treat a project as public whenever it is not covered by an active pilot (the same predicate `deployments.allowed` already uses), and enrol it via `reserveProject` in the proposal-creation transaction rather than at gas reservation.","reproduction":"State: active chain-admission policy and assessment; `review-pilots[p1] = {enabled:true, expiresAt: now-1}`; `chain-bindings[p1].executor === policy.executor`; no `public-chain-projects[p1]`; every `public-decision-admission` counter (p1 day, global day, p1 lifetime, global lifetime) already at its maximum. Call `approvals.create('owner-session', 'p1', {moduleId, configuration, idempotencyKey})`. Expected: `decision_capacity_reached` (or `durable_agent_required`). Actual: both checks are skipped and the call proceeds to `deployments.module` / proposal creation; no counter is read or incremented. Control: the identical call for an enrolled project `p2` (row in `public-chain-projects`) throws `decision_capacity_reached`. Verified with test/scratch/b-expired-pilot.test.mjs, which uses the real `createChainAdmission`/`createChainDeployments.allowed` and a stub `deployments.module` that throws `reached_module_lookup`; the current tree reaches that stub for p1 and rejects p2.","snippet":"  if (!await reader.read('public-chain-projects', projectId)) return;","citation":"resolved","blocking":false},{"severity":"medium","title":"Intake scan pages are filled by unadmitted public Requested events, so cheap Sepolia spam still starves admitted requests","path":"adapters/contract-intake.mjs","line":65,"description":"The 0.11.3 repair for the prior review's 'intake-scan starvation' finding bounds work per tick to 32 events, but the bound is applied in the adapter to the raw `Requested` logs of the inbox before `runtime/contract-intake-worker.mjs:82-88` discards events whose `binding` is not an admitted `contract-bindings` row. `ClausRequestInbox.request` accepts any non-zero `binding` word from any EOA for ~55k gas, so an attacker can emit thousands of events with an unregistered binding ahead of (or interleaved with) a project's real request. Each such event costs the attacker only Sepolia gas but costs the network one of its 32 slots per tick; the admitted event is only ingested after ceil((N+1)/32) ticks, and `observe`/`ingest` rejects it with `contract_request_closed` once the requester's deadline has passed. Because the `Requested` event indexes `binding`, the adapter could ask the RPC to return only admitted binding words (topic filter with the set of active binding words, or at least count only admitted events toward the 32-event page), which removes the attacker's leverage instead of merely bounding the victim's throughput. Note also that the per-tick page is consumed by events that need no RPC work at all (one DB read each), so the bound does not reflect actual cost.","reproduction":"State: one admitted binding `sha256:cd..cd` for the pinned inbox; finalized chain has block 1 containing 320 `Requested` logs with `binding = 0xab..ab` (not admitted) followed at logIndex 320 by one log with `binding = 0xcd..cd`. Run `createContractIntakeWorker.tick()` repeatedly. Expected: the admitted event is ingested on the first tick (hostile events need no work). Actual: ticks 1-10 return `ingested: 0` and advance `afterLogIndex` by 32 each; the admitted event is ingested on tick 11. Verified with test/scratch/a-scan-budget.test.mjs (asserts `ticks === 320/32 + 1`). With N spam events the delay is ceil((N+1)/32) ticks; at one tick per minute, 46,080 spam events (~2.5G gas, under two hours of full Sepolia blocks) defer every admitted request by a day.","snippet":"      if (remaining) logs = logs.slice(0, 32);","citation":"resolved","blocking":false},{"severity":"medium","title":"Dispatch budget admission accepts a per-project ceiling below the per-transaction ceiling; the planned 0.00005/0.0002 allocation can never fund a resolve","path":"adapters/contract-delivery.mjs","line":23,"description":"`admitContractDispatchBudget` only requires `0 < maximumProject <= maximumTotal`. The sibling chain-admission schema enforces `maximumProjectGasWei >= maximumTransactionGasWei` (src/chain-deployments/admission.mjs:23) precisely so that an admitted per-transaction size is reservable for at least one project, but the dispatcher budget has no such check. The planned public dispatcher allocation stated for this candidate (0.0004 ETH total, 0.00005 ETH per project, 0.0002 ETH per transaction) is admitted, yet `reserve` (lines 111-112) rejects any reservation above 5e13 wei with `dispatch_project_budget_exhausted` even when the project has spent nothing. A `resolve(bytes32,bool,bytes32,bytes)` call writes two fresh storage words plus an event (~70-80k gas, ~100k after the 1.25x+10k margin in sepolia-transactions.mjs:57), so at any `maxFeePerGas` above 0.5 gwei the reservation exceeds the per-project ceiling while remaining within the per-transaction ceiling. The worker then marks the request `delivery.reason = dispatch_project_budget_exhausted` with a 15-minute retry (runtime/contract-intake-worker.mjs:45-53) and repeats until the request deadline passes, after which the request is `closed`. The result is a silently non-functional public dispatcher: budget admitted, zero spent, every delivery deferred until expiry. Fix: require `amount(config.maximumProject) >= perTransaction` in `admitContractDispatchBudget` (mirroring the chain-admission schema) and correct the planned allocation so the per-project share covers at least one maximum transaction.","reproduction":"Input: `admitContractDispatchBudget(store, {id, chainId: 11155111, dispatcher, inbox, codeHash, projectIds: [], admissionMode: 'public-readonly-v1', expiresAt: now+7d, maximumTotal: '400000000000000', maximumProject: '50000000000000', maximumTransaction: '200000000000000', maximumGas: '1000000', maximumFeePerGas: '50000000000'})`. Expected: `invalid_dispatch_budget`. Actual: accepted. Then with a result_ready request for an admitted project, `estimateGas = 100000` and `maxFeePerGas = 1 gwei` (reservation 135000 * 1e9 = 1.35e14 wei, within the 2e14 per-transaction limit), `delivery.step(...)` throws `dispatch_project_budget_exhausted`; the budget ledger shows `spent '0', reserved '0'` and no `contract-dispatch-projects` row exists. Verified with test/scratch/c-dispatcher-plan.test.mjs.","snippet":"  if (publicAdmission) requireThat(amount(config.maximumProject) > 0n && amount(config.maximumProject) <= total,","citation":"resolved","blocking":false},{"severity":"low","title":"A provably-unsent cancellation releases sponsor exposure but leaves the review case 'admitted', so the same input can never be reviewed again","path":"src/review-flow/index.mjs","line":72,"description":"The new cleanup paths (`runPaid` catch at lines 301-305 and `onCancel` at 207-208) cancel the review job, void billing and release the public lifetime exposure, but neither resets `review-cases[id].state`, which stays `admitted` with `reviewJobId` pointing at a cancelled/void job. `request()` deduplicates by `inputHash` and returns any prior case in state `admitted` or `reviewed` ('A paid or uncertain attempt is never replaced by a new key'), and `accept()` for that case fails with `quote_already_admitted`. So after the network itself proved nothing was sent and gave the sponsor capacity back, the project cannot use that capacity for the same build: it must produce a different build/commit to obtain a new quote. The comment's rationale (never replace a paid or uncertain attempt) does not apply here because `cancelUnsent` only succeeds when the attempt is provably unsent. Fix: when `cancelUnsent`/`releaseUnsent` run for a public review, move the case back to `quoted` (or to a terminal `cancelled` state that `request()` ignores) so a fresh quote/admission can be created.","reproduction":"State: public sponsorship active (maximumLifetime '5'); `request` -> `runQuote` -> `accept` for build B; `runPaid` with a payment adapter whose `prepare` records `signing_unknown` and throws `payment_signing_unknown`. Result: job `blocked`, `review-jobs` work `cancelled`/billing `void`, `admission.get().committedMaximum === '0'`. Then `request(actor, project, {...sameInput, idempotencyKey: 'retry'})`. Expected: a new case/quote for build B. Actual: returns the original case id whose `get()` state is `blocked`; `accept(caseId, quoteId)` throws `quote_already_admitted`; `runQuote()` is idle. Verified with test/scratch/d-review-stuck.test.mjs.","snippet":"          if (['admitted', 'reviewed'].includes(prior.state)) return prior;","citation":"resolved","blocking":false},{"severity":"low","title":"A transient daily decision ceiling terminally blocks a live intake request and consumes its binding quota","path":"src/contract-intake/index.mjs","line":160,"description":"`run()` treats `decision_capacity_reached` as a deterministic denial: the `contract-intake` job is finished as `blocked` and the request is written `state: 'blocked'`. But the project-day (2) and global-day (8) counters in `publicDecisionCapacity` reset at the next UTC day, and the on-chain request may have up to 30 days of deadline left. The requester's binding `used` count was already incremented at `ingest` and is not refunded, the on-chain request stays `Pending` until it expires or the requester cancels, and no retry ever happens even though capacity returns. Since `blocked` is terminal for `jobs.claim`, the only way forward is a new on-chain request that consumes another binding slot and pays gas again. Only the lifetime ceilings (4/project, 32/global) are genuinely permanent. Fix: when the thrown code is `decision_capacity_reached` and only a daily counter is exhausted, finish the job as `waiting` with `retryAt` at the next day boundary (bounded by the request deadline); reserve `blocked` for lifetime exhaustion.","reproduction":"State: `public-chain-projects[project-a]` present; binding with `maxRequests: 2`; an ingested request with deadline now+29d; `createProposal` throws `decision_capacity_reached` (daily ceiling hit). Call `run()`. Expected: request waits and is retried after the day rolls over. Actual: `contract-requests[id].state === 'blocked'`; advancing time by 24h and calling `run()` returns `idle` (nothing claimable); the request stays `blocked` and `binding.used === 1`. Verified with test/scratch/e-daily-cap-blocks.test.mjs.","snippet":"            'contract_request_closed', 'decision_capacity_reached', 'durable_agent_required'].includes(error.code);","citation":"resolved","blocking":false},{"severity":"low","title":"Requests dropped for contract_request_in_progress leave no project-visible trace; the on-chain request stays pending until its deadline","path":"runtime/contract-intake-worker.mjs","line":104,"description":"When a public project's second on-chain request arrives while one is `queued`/`waiting`, `ingest` throws `contract_request_in_progress` before writing a `contract-requests` row. The worker classifies that code as deterministic, records only a `contract-intake-rejections` row keyed by binding, and advances the cursor past the event, so the event is never re-ingested after the first request completes ('Completion ... lets the next request enter' in contract-intake.md refers to a new request, not the dropped one). `contract-intake-rejections` is not exposed by any API or MCP operation (only the worker writes it), and `contract_request(projectId, id)` for the dropped request id returns `not_found`. The requester therefore paid gas for a request that will sit `Pending` on the inbox until the 30-day maximum deadline (or until they call `cancel`) with no signal from the network. Because the rejection row is overwritten per binding, even an operator looking at the database sees only the most recent rejection. Fix: expose the per-binding latest rejection in `contract_binding` (or a `contract_rejections` read), and/or keep the dropped event in a bounded per-project 'deferred' list that is re-ingested once the active request leaves `queued`/`waiting`.","reproduction":"State: public project with binding B, request R1 ingested (`queued`). On-chain, the requester submits R2 under B; the scanner reaches R2's event. Expected: the project can observe R2 (queued, deferred or rejected) through the API. Actual: `ingest` throws `contract_request_in_progress` (reproduced by test/contract-intake.test.mjs 'public intake uses durable credentials and one outstanding request per project'), the worker writes `contract-intake-rejections[digest({inbox, bindingId:B})]` and saves the cursor past R2; `contract_request('project', digest({chainId, inbox, requestId: R2}))` returns `not_found`; no later tick re-reads R2's event; `ClausRequestInbox.requests[R2].state` remains `Pending` until `deadline`.","snippet":"          await tx.write('contract-intake-rejections', key,","citation":"resolved","blocking":false}],"coverage":null},{"hash":"2d3a1c7fd3097271e78b8302c73e012bd15affff5f9ec67a11e9a3db830eae9d","nodeKey":"audit_flow","role":"review","attempt":1,"deviceKey":"bdd9b74dce66953d980cc1c0cfe15f99b1c1ffde3719dbe7e0d5dec4e3e7a8eb","seat":{"tokenId":"467","agentId":"52121"},"outcome":"completed","accepted":true,"failureReason":null,"failureClass":null,"safetyRefusal":false,"usage":{"model":"claude-fable-5-1","turns":61,"runtime":"claude","inputTokens":802,"wallClockMs":921394,"outputTokens":68960,"cachedInputTokens":4153724},"baseCommit":"91e8ba266d13e2532fd2e10fb5c456c639d39055","startCommit":"91e8ba266d13e2532fd2e10fb5c456c639d39055","resultTreeHash":null,"bundleHash":null,"bundleBytes":0,"artifacts":[],"changedPaths":[],"allowedPaths":[],"dependencyBundleHashes":[],"verificationErrors":0,"summary":"The review is complete. `.imd-findings.json` holds five substantiated findings, each with a passing reproduction under `test/scratch/`, and no tracked file was changed.\n\n**What I covered.** The full 0.11.3 diff against the reviewed commit 18401b9: adaptive block/log scanning, delivery-before-scan ordering and deferrals, per-binding rejection retention, per-project dispatcher ceilings, public decision limits and durable-agent rules, expired-pilot enrollment, review sponsor release on unsent cancellation, local signing cleanup, and the payment window. Baseline suite: 400 tests, 389 pass, 11 skipped, 0 fail. A 40-seed randomized simulation of the scanner found no lost, duplicated or reordered events with realistic block densities and provider caps.\n\n**Findings, most severe first:**\n\n- **Medium, expired-pilot bypass of public decision limits.** A project whose pilot expired is routed through the public policy by the deployment guard, but every public ceiling and the durable-agent rule key off a `public-chain-projects` row that only appears at first gas reservation. Such a project creates proposals from an owner session with all four decision ceilings exhausted, spending model budget outside the stated bounds. Anchored at `src/approvals/limits.mjs:7`.\n- **Medium, dispatcher per-project ceiling can be smaller than one transaction.** The dispatcher validator checks `maximumProject` only against the total, unlike the executor schema. With the planned allocation (0.00005 ETH per project, 0.0002 per transaction) a resolve reservation at 1 gwei is about 0.0001 ETH, so every public delivery defers 15 minutes forever and no result reaches the Inbox. Anchored at `adapters/contract-delivery.mjs:23`.\n- **Low, in-progress rejections are terminal.** A second onchain request from a bound requester is recorded in a table nothing reads, the cursor moves past it, and it is never ingested once the first completes. Anchored at `runtime/contract-intake-worker.mjs:93`.\n- **Low, enrollment at project capacity is retried indefinitely.** `capacity_reached` is raised inside `prepare()` and is not an authority denial, so the executor retries every minute with RPC calls until the approval expires, after the decision was already spent. Anchored at `src/chain-deployments/admission.mjs:82`.\n- **Low, one oversized block freezes intake.** A finalized block holding more Requested logs than the provider returns per call makes the scanner rethrow at a single-block range on every tick, so no later block is scanned for any project. Reachability depends on the pinned publicnode cap against Sepolia's post-Glamsterdam gas limit, which I state in the finding.\n\n**Limits.** No Solidity defect was found in the changed scope, so no finding carries a Foundry proof. I did not reproduce provider behaviour against live Sepolia, and the review-flow release paths were checked by trace and the existing tests only, where I found no defect. Sources consulted for the gas-limit and provider-cap context: [cryptoticker.io on Glamsterdam's Sepolia gas limit](https://cryptoticker.io/en/eth-price-prediction-glamsterdam-gas-limit-sepolia/), [spotedcrypto on the configurable 200M limit](https://www.spotedcrypto.com/glamsterdam-sepolia-200-million-gas-limit/), [Chainstack on eth_getLogs limits](https://docs.chainstack.com/docs/understanding-eth-getlogs-limitations), [sqd.dev on provider log caps](https://sqd.dev/learn/eth-getlogs-limits/).","createdAt":"2026-10-12T12:04:50.133Z","verdict":null,"findings":[{"severity":"medium","title":"Expired-pilot projects admitted under the public chain policy bypass all public decision limits and the durable-agent rule until first gas enrollment","path":"src/approvals/limits.mjs","line":7,"description":"Every public decision guard in src/approvals/index.mjs (publicDecisionCapacity at lines 40 and 86, durable_agent_required at lines 41-42) keys off a `public-chain-projects` row. That row is only written by reserveProject, which runs on deployModule or on the first gas reservation inside reserveGas (src/chain-deployments/admission.mjs:82). A project whose pilot expired, which already holds a verified module and a chain binding on the current public executor, is routed through the public policy by deployments.allowed (src/chain-deployments/index.mjs:16-20, `!pilot?.enabled || pilot.expiresAt <= now()` -> admission.admitted) yet has no enrollment row. approvals.create therefore skips the 2/project/day, 8/global/day, 4/project-lifetime and 32/global-lifetime ceilings and accepts an owner session instead of a delegated agent. Each accepted proposal enqueues a claus-review job that spends the operator model budget; rejected proposals free the active-proposals slot, so the project can loop indefinitely one proposal at a time. Its decisions also do not count against the global ceilings that are supposed to bound total public spend. The docs (docs/operations.md 'Public admission failure boundaries', docs/reviews-and-contracts.md) state that expired pilots must satisfy the current public policy and that public proposals require a delegated agent. Fix: in approvals.create (and deployModule/submitRegistration), apply the public guards whenever deployments.allowed took the public-policy branch (no active pilot), e.g. compute `publicScope = !pilot?.enabled || pilot.expiresAt <= now() || enrolled` once and pass it to publicDecisionCapacity, or enroll (reserveProject) at proposal creation inside the same transaction so capacity_reached is raised before any decision is spent.","reproduction":"State: active chain.admission policy (enabled, assessment accepted), review-pilots p1 = {enabled:true, expiresAt: yesterday}, chain-bindings p1.executor == policy.executor, no public-chain-projects row for p1; public-decision-admission counters for p1 and global set to their maxima (4/32 lifetime, 2/8 day). Call approvals.create('owner-session', 'p1', {moduleId, configuration, idempotencyKey}). Expected: durable_agent_required (owner session) or decision_capacity_reached (ceilings full), as an enrolled project p2 gets. Actual: both guards are skipped and creation proceeds to the module lookup (test stub throws reached_module_lookup after the guards). Run `node --test test/scratch/expired-pilot-limits.test.mjs` (file included under test/scratch/): it passes, demonstrating the bypass.","snippet":"  if (!await reader.read('public-chain-projects', projectId)) return;","citation":"resolved","blocking":false},{"severity":"medium","title":"Dispatcher budget admission accepts a per-project ceiling smaller than one transaction; with the planned 0.00005/project vs 0.0002/tx allocation no public resolve can ever be reserved and delivery def","path":"adapters/contract-delivery.mjs","line":23,"description":"admitContractDispatchBudget validates maximumProject only against maximumTotal. The per-transaction reservation made in reserve() is gas*maxFeePerGas with gas = estimate*1.25+10000 (adapters/sepolia-transactions.mjs:57-68) and is bounded only by maximumTransaction, then checked against maximumProject (contract-delivery.mjs:111). The sibling validator for the executor ledger enforces maximumProjectGasWei >= maximumTransactionGasWei (src/chain-deployments/admission.mjs:23), but the dispatcher validator does not. The planned public allocation in docs/operations.md and the task brief is 0.0004 ETH total, 0.00005 ETH per project, 0.0002 ETH per transaction. Inbox.resolve costs up to 77,969 gas in contracts/test (forge --gas-report, ClausRequestInbox), so the reservation is about 104k gas * maxFeePerGas; at any maxFeePerGas above ~0.48 gwei it exceeds 50,000 gwei (0.00005 ETH). Sepolia priority fees alone are typically >= 1 gwei. Result: every public project's first resolve throws dispatch_project_budget_exhausted, the worker (runtime/contract-intake-worker.mjs:45-54) records a 15-minute deferral and retries until the request deadline, the decision is never written onchain, and the callback (which must also fit the same project share plus callbackGas up to 1,000,000) is unreachable. The global budget and per-transaction ceiling would have allowed it. Fix: require amount(config.maximumProject) >= perTransaction in admitContractDispatchBudget (mirroring the chain-admission schema) and size the public allocation so a project share covers at least one resolve plus one callback at the configured maximumFeePerGas.","reproduction":"admitContractDispatchBudget(store, {admissionMode:'public-readonly-v1', projectIds:[], maximumTotal:'400000000000000', maximumProject:'50000000000000', maximumTransaction:'200000000000000', maximumGas:'1000000', maximumFeePerGas:'50000000000', ...}) is accepted. With estimateGas = 75,000 and maxFeePerGas = 1 gwei, createContractDelivery(...).step({request in result_ready, allowSubmit:true}) rejects with dispatch_project_budget_exhausted, no broadcast, reserved stays '0'; createContractIntakeWorker.tick() reports deferred=1 with delivery.reason='dispatch_project_budget_exhausted' and retryAt=now+900000 on every tick after the backoff, indefinitely. Expected: a budget that cannot fund a single transaction per project is rejected at admission (invalid_dispatch_budget), as createChainAdmission.publish rejects maximumProjectGasWei < maximumTransactionGasWei with invalid_chain_admission. Run `node --test test/scratch/dispatch-project-ceiling.test.mjs`.","snippet":"  if (publicAdmission) requireThat(amount(config.maximumProject) > 0n && amount(config.maximumProject) <= total,","citation":"resolved","blocking":false},{"severity":"low","title":"A second onchain request rejected as contract_request_in_progress is permanently dropped: the scan cursor advances past it and it is never re-ingested after the first request completes","path":"runtime/contract-intake-worker.mjs","line":93,"description":"contract_request_in_progress is a transient condition (one queued/waiting request per public project, src/contract-intake/index.mjs:113-118) but the worker lists it among the deterministic rejections. The event is written to contract-intake-rejections (a table no API or worker ever reads) and the intake cursor is saved past its block. When the first request completes, cancels or expires nothing revisits the second event, so the bound requester's finalized Requested event (for which it paid gas, with a deadline up to 30 days) is silently never resolved and stays Pending onchain until it expires. The same applies to decision_capacity_reached from a daily ceiling, which blocks the intake job permanently (src/contract-intake/index.mjs:159-163) although capacity returns the next day. docs/contract-intake.md says 'a second receives contract_request_in_progress', but the requester contract receives nothing: the rejection is private. Fix: either keep the in-progress event unconsumed (treat contract_request_in_progress like a transient error for that binding only, e.g. stop the batch at that event and retry from its cursor, or record it in a per-project pending queue drained when the active request leaves queued/waiting), or document that a second concurrent onchain request is lost and expose the rejection to the project via the contract request API.","reproduction":"Public project (public-chain-projects row), admitted binding with maxRequests 10. One scan page returns two finalized Requested events for that binding (requestIds 0x33..33 and 0x66..66, deadlines 7 days out). worker.tick(): ingested=1, contract-intake-rejections holds {code:'contract_request_in_progress', transactionHash of event 2}, contract-intake-cursors saved at afterBlock 200. Decide the first proposal and run intake.run() so request 1 reaches result_ready. worker.tick() again (scan returns no new events): ingested=0, no contract-requests row exists for requestId 0x66..66, binding.used is still 1. Expected: the second request is ingested once the project slot is free. Run `node --test test/scratch/in-progress-drop.test.mjs`.","snippet":"        if (!['contract_intake_disabled', 'contract_binding_inactive', 'contract_binding_exhausted', 'contract_request_closed', 'contract_request_in_progress',","citation":"resolved","blocking":false},{"severity":"low","title":"Expired-pilot enrollment at maximumProjects raises capacity_reached from inside prepare(); the executor treats it as transient and retries every 60 s until the approval expires","path":"src/chain-deployments/admission.mjs","line":82,"description":"reserveGas now enrolls an expired pilot by calling reserveProject inside the wallet transaction. reserveProject throws capacity_reached when public-chain-usage.projects >= maximumProjects. capacity_reached is not in adapters/authorization.mjs authorityDenied, so adapters/chain-executor.mjs transact() rethrows it instead of returning cancelled, and src/approvals/index.mjs:209 / src/chain-deployments/index.mjs:147 finish the job as waiting with a 60-second retry. Each retry performs capability reads, validate() RPC calls and prepare()'s four RPC calls (estimateGas, fees, nonce, balance) before failing again in the database transaction, until p.expiresAt turns it into approval_expired. Combined with the first finding, the Claus decision (model budget) for such a proposal has already been spent on a proposal that can never execute. Fix: check public project capacity (admission.reserveProject or an equivalent read) in deployments.allowed / approvals.create for the expired-pilot branch before a decision is enqueued, and treat capacity_reached from reserveGas as a terminal blocked outcome (add it to the blocked list or map it to a dedicated authority code) rather than a 60 s retry.","reproduction":"chain.admission policy active with maximumProjects=1 and one enrolled public project; review-pilots p1 expired, chain-bindings p1.executor == policy.executor, chain-proposals proposal-1 {projectId:'p1'}. createSepoliaTransactions({onReserve: admission.reserveGas, ...}).prepare({id:'queue:proposal-1', authority:{kind:'queue', proposalId:'proposal-1'}, ...}) rejects with capacity_reached after the RPC estimates; authorityDenied({code:'capacity_reached'}) is false; no chain-transactions row and no enrollment is written. In runExecution this path yields {state:'waiting', retryAt: now+60000} on every attempt. Expected: a terminal blocked state (or refusal before the decision is spent). Run `node --test test/scratch/enrollment-capacity-retry.test.mjs`.","snippet":"        await reserveProject(tx, projectId);","citation":"resolved","blocking":false},{"severity":"low","title":"A single finalized block with more Requested logs than the RPC provider returns per eth_getLogs call stalls the intake cursor permanently for every project","path":"adapters/contract-intake.mjs","line":55,"description":"The adaptive scanner halves the block range on any getLogs error and rethrows once the range is a single block. The log-index cursor only helps when the provider actually returns that block's logs (the adapter's own 5000 cap then slices 32 at a time). If one finalized block contains more Requested logs than the provider's per-call result cap, every scan of the page containing that block throws; runtime/contract-intake-worker.mjs:77-78 records scanError and line 112 does not save the cursor, so no later block is ever ingested for any project, and each tick repeats the full halving sequence (about log2(limit) getLogs calls) against the provider. Inbox.request() is permissionless and the getLogs filter is only address+event, so unadmitted callers can emit these logs at the cost of Sepolia gas only. Reachability depends on the pinned provider's cap (publicnode; not documented, common caps are 1,000 to 10,000 results) against the block gas limit: Inbox.request costs about 63k gas (forge gas report), so a 64M block holds about 1,000 requests and the 200M Glamsterdam limit Sepolia validators may now adopt holds about 3,000. Fix: when a single block still fails, fall back to a query that can page within the block (getLogs with a topic filter restricted to admitted binding words, or a block receipt walk), or at minimum record and alert on the oversized block and advance past it, so one hostile block cannot freeze the public intake pipeline.","reproduction":"Provider cap 1,000 results; block 120 holds 1,001 Requested logs; cursor {afterBlock:119}. chain.scan(cursor) rejects with the provider error after halving to fromBlock=toBlock=120. createContractIntakeWorker.tick() returns a scanError and afterBlock 119 on every subsequent tick; contract-intake-cursors never advances and blocks 121 and later are never scanned. Expected: progress past or around the oversized block, or an explicit alert, instead of an unbounded stall. Run `node --test test/scratch/scan-single-block-stall.test.mjs`.","snippet":"          if (end === from) throw error;","citation":"resolved","blocking":false}],"coverage":null},{"hash":"d696a32f9f7d20f4b7dfb19f85539b6ab79cf771c2c905e95672db6824b509b8","nodeKey":"audit_economics","role":"review","attempt":1,"deviceKey":"bc1e515ca5c78ac4464bc081a0c98eeab32c5b324cceb6b72b670b40ef2e5b5d","seat":{"tokenId":"363","agentId":"51428"},"outcome":"completed","accepted":true,"failureReason":null,"failureClass":null,"safetyRefusal":false,"usage":{"model":"claude-fable-5-1","turns":53,"runtime":"claude","inputTokens":546,"wallClockMs":757027,"outputTokens":51963,"cachedInputTokens":2742561},"baseCommit":"91e8ba266d13e2532fd2e10fb5c456c639d39055","startCommit":"91e8ba266d13e2532fd2e10fb5c456c639d39055","resultTreeHash":null,"bundleHash":null,"bundleBytes":0,"artifacts":[],"changedPaths":[],"allowedPaths":[],"dependencyBundleHashes":[],"verificationErrors":0,"summary":"The review is complete. All five findings are recorded in `.imd-findings.json`, each with a scratch reproduction under `test/scratch/` that fails on the current code. No tracked files were changed.\n\n**What I covered.** The full 0.11.3 diff against the reviewed commit, plus the modules it depends on: the inbox scanner and worker, intake admission and runner, review admission reserve/release, the paid-review runner and payment adapter, chain admission gas ledger, executor and job runner, dispatcher budgets, and the Inbox contract. Baseline suite passes (389 pass, 11 environment skips). The sponsor-exposure release, signing-failure cleanup, minimum payment window, per-project dispatcher ledger, expired-pilot enrollment, durable-credential checks and the \"no RPC under the write lock\" change all held up under concrete traces. No critical or high issue was found, so no Foundry proof was required.\n\n**Findings, most severe first:**\n\n- **Medium, intake starvation by public log spam** (`adapters/contract-intake.mjs:64`). The scanner takes the first 32 `Requested` logs per tick before the worker filters unknown bindings, and anyone can call `request` on the Inbox. Reproduced: 10,000 junk events ahead of one admitted event delay it by 313 ticks. Sepolia gas makes this near free. The `binding` topic is indexed, so filtering by admitted binding words would fix it.\n- **Low, released exposure is unusable for the same input** (`src/review-flow/index.mjs:72`). After a provably unsent signing failure, exposure is released but the case stays `admitted`, so a retry returns the dead case and `accept` fails with `quote_already_admitted`.\n- **Low, dispatcher budget validation gap** (`adapters/contract-delivery.mjs:23`). A per-project ceiling below the per-transaction ceiling is admitted. The planned allocation has exactly that shape, so most public callbacks would defer in 15-minute loops.\n- **Low, daily decision cap is terminal** (`src/contract-intake/index.mjs:160`). A public intake request hitting the 2/day or shared 8/day cap is permanently blocked with binding quota consumed, although the cap resets the next day.\n- **Low, expired deferred deployments loop forever** (`src/chain-deployments/index.mjs:147`). Once a gas-deferred deployment or registration outlives its window, `prepare` throws a code the runner retries every minute with RPC calls, never reaching a terminal state.\n\n**Not reported.** The one-request-per-project rule silently drops a second same-block request (documented design), the small stale-observation window before submit (self-heals next tick), and per-binding rejection overwrites (cosmetic).","createdAt":"2026-10-12T12:02:05.425Z","verdict":null,"findings":[{"severity":"medium","title":"Intake scan page bound (32 events) is consumed by unadmitted public log spam, starving admitted requests at near-zero cost","path":"adapters/contract-intake.mjs","line":64,"description":"ClausRequestInbox.request is permissionless: anyone can emit a Requested event with any binding word (contracts/src/ClausRequestInbox.sol:46-56). The 0.11.3 scanner slices every page to the first 32 Requested logs in block/log order BEFORE the worker checks whether the binding is admitted (runtime/contract-intake-worker.mjs:82-83 'Unadmitted public contract traffic incurs no work'). Each hosted tick therefore advances the cursor by at most 32 inbox events regardless of who emitted them. An attacker who emits N junk requests ahead of a legitimate request delays that request by ceil(N/32) ticks; the request's onchain deadline (and the intake check at src/contract-intake/index.mjs:109 'contract_request_closed') then expires it, and the project's binding quota, decision capacity and Sepolia gas are wasted. On Sepolia the base fee decays to a few wei when blocks are not full, so 10,000 requests (~1 Ggas with 1-byte inputs) cost the attacker a negligible amount of test ETH while the inbox-wide throughput cap is 32 events per tick (health requires a tick within 300 s, so at most ~9k events/day even at one tick per minute). A sustained spammer at >32 events per tick makes public intake permanently lag. The Requested event indexes `binding` (topic 3) so the scanner could filter getLogs by the admitted binding words, or the worker could exclude unknown bindings from the 32-event bound; neither is done. A second amplification: binding words are public (bindingView.onchainBinding and the emitted topic), and an event that reuses an admitted word but a different requester passes the store check and costs ~7 sequential RPC calls in chain.observe (receipt, two blocks, chainId, code, dispatcher, requests) before failing with contract_request_mismatch, so a page of 32 such events costs ~224 RPC round-trips per 60 s hosted invocation.","reproduction":"Unit reproduction (test/scratch/scan-starvation.test.mjs, run with `node --test`): mock a client whose getLogs returns 10,000 Requested logs with an unknown binding word in blocks 1..10 followed by one log with an admitted binding in block 11; register the admitted binding in contract-bindings; drive createContractIntakeWorker.tick() until intake.ingest is called. Expected (per the worker comment that unadmitted traffic incurs no work): the admitted event is ingested on tick 1. Actual: 313 ticks are needed (printed 'ticks until the admitted event was ingested: 313'); with a 5-minute cron that is 26 hours of delay for one legitimate request, longer than typical request deadlines.","snippet":"      const remaining = logs.length > 32;\n      if (remaining) logs = logs.slice(0, 32);","citation":"resolved","blocking":false},{"severity":"low","title":"Local signing failure releases sponsor exposure but leaves the review case 'admitted', so the same input can never be re-reviewed","path":"src/review-flow/index.mjs","line":72,"description":"The 0.11.3 cleanup path (runPaid denied branch, lines 297-305, and onCancel, lines 205-209) abandons unsent payment material, voids the review-job billing and calls admission.releaseUnsent so the public lifetime exposure is returned. But the review-case document keeps state 'admitted' (only the workflow job becomes 'blocked'; get() derives the visible state from the workflow). A later request() for the same build/commit finds the prior case with inputHash equal and state 'admitted' and returns it instead of creating a new case ('A paid or uncertain attempt is never replaced by a new key'), even though the attempt is provably unsent and void. accept() with a new idempotency key is refused with quote_already_admitted because admitted-quotes[quoteId] is permanent. The released exposure therefore cannot be used for the input it was released from; the project must change the source commit to obtain a new inputHash. The released-lifetime test in test/review-flow.test.mjs only re-accepts after changing baseCommit, so this is untested.","reproduction":"test/scratch/review-stuck.test.mjs (node --test): public sponsorship policy 5/5; request+quote+accept one review; runPaid with a createPayment whose prepare throws payment_signing_unknown. Result: workflow 'blocked', committedMaximum back to '0'. Then flow.request(actor, project, {...sameInput, idempotencyKey:'retry'}) returns the SAME case id with stored state 'admitted' (printed), and flow.accept(..., idempotencyKey:'retry') rejects with quote_already_admitted. Expected: a new case (or the old case reset to a re-acceptable state) so the released allocation can be used. Fix: on cancelUnsent/releaseUnsent also move the review-case out of 'admitted' (e.g. back to 'quoted'/'cancelled') or exclude cases whose review-job is cancelled+void from the 'never replaced' rule.","snippet":"          if (['admitted', 'reviewed'].includes(prior.state)) return prior;","citation":"resolved","blocking":false},{"severity":"low","title":"Public dispatch budget admits a per-project ceiling lower than the per-transaction ceiling, so the planned 0.00005 ETH/project share can never fit a reservation up to 0.0002 ETH","path":"adapters/contract-delivery.mjs","line":23,"description":"admitContractDispatchBudget only requires maximumProject <= maximumTotal; it does not require maximumTransaction <= maximumProject. The planned public dispatcher allocation stated in the task and docs/operations.md (0.0004 ETH total, 0.00005 ETH per project, 0.0002 ETH per transaction) is accepted, yet reserve() (lines 102-112) first admits any transaction whose gas*maxFeePerGas <= maximumTransaction and then rejects it with dispatch_project_budget_exhausted whenever that reservation exceeds 0.00005 ETH. A resolve plus callback is two transactions, so the usable per-transaction reservation is at most 0.000025 ETH (e.g. 110k gas at 0.22 gwei); any Sepolia fee spike above that produces a 15-minute deferral loop (runtime/contract-intake-worker.mjs:45-54) until the request deadline closes it, while the project's decision capacity (4 lifetime) and binding quota were already consumed by the decision that can no longer be delivered. The two ceilings are also inconsistent with the executor allocation (0.0005/project >= 0.0002/transaction).","reproduction":"test/scratch/dispatch-budget.test.mjs (node --test): admitContractDispatchBudget(store, { admissionMode:'public-readonly-v1', projectIds:[], maximumTotal:'400000000000000', maximumProject:'50000000000000', maximumTransaction:'200000000000000', maximumGas:'1000000', maximumFeePerGas:'50000000000', ... }). Expected: invalid_dispatch_budget. Actual: accepted. With that budget, test/contract-delivery.test.mjs-style fixture reserving maximum '90000000000000' (0.00009 ETH, below maximumTransaction) fails with dispatch_project_budget_exhausted on the very first transaction of a fresh project. Fix: require maximumTransaction <= maximumProject for public budgets, and size the planned allocation so one resolve+callback pair fits a project share.","snippet":"  if (publicAdmission) requireThat(amount(config.maximumProject) > 0n && amount(config.maximumProject) <= total,\n    'invalid_dispatch_budget');","citation":"resolved","blocking":false},{"severity":"low","title":"Daily public decision cap is treated as a terminal denial: an admitted intake request is permanently blocked with its binding quota consumed instead of waiting for the next day","path":"src/contract-intake/index.mjs","line":160,"description":"publicDecisionCapacity (src/approvals/limits.mjs) raises the same code, decision_capacity_reached, for the resetting day counters (2/project/day, 8 global/day) and for the lifetime counters (4/project, 32 global). The intake runner puts that code in its 'denied' list, so the workflow job finishes 'blocked' (no retryAt) and the contract request is written state 'blocked'. The request had already been admitted by ingest (binding.used incremented at line 121, active-contract-request set) and the onchain request remains Pending until its deadline with no resolve and no callback. Because the global day counter is shared, four other public projects submitting their two daily proposals exhaust the 8/day global cap and every further public intake request admitted that day is permanently lost, not delayed, even when its onchain deadline is days away. docs/contract-intake.md describes the caps as daily throttles; nothing documents that hitting one discards the request.","reproduction":"test/scratch/daily-cap-blocked.test.mjs (node --test): public project (public-chain-projects row), agent binding with maxRequests 10, one observed request with a 7-day deadline, createProposal throwing decision_capacity_reached (as approvals.create does when the day counter is full). api.ingest -> used=1; api.run() -> job state 'blocked', request state 'blocked', error decision_capacity_reached (printed). Advancing time by one day and running again does nothing because the job is terminal. Expected: 'waiting' with a retryAt at the next day boundary (or rejection at ingest time, before consuming quota, like contract_request_in_progress) when only a day counter is full; terminal only for lifetime exhaustion. Fix: distinguish day vs lifetime exhaustion (separate codes) and map the day case to waiting with retryAt = next UTC day, or pre-check capacity in ingest.","snippet":"            'contract_request_closed', 'decision_capacity_reached', 'durable_agent_required'].includes(error.code);","citation":"resolved","blocking":false},{"severity":"low","title":"A gas-deferred deployment or registration that outlives its window never becomes terminal: prepare() throws invalid_transaction, which the job runner retries every 60 s forever","path":"src/chain-deployments/index.mjs","line":147,"description":"0.11.3 adds visible 15-minute deferrals for chain_gas_exhausted / transaction_cost_limit (and capacity_reached from the new expired-pilot enrollment in reserveGas is likewise retried every 60 s). A module-deploy has a 24-hour window (deployModule: expiresAt = now + 86400000) and a registration a 1-hour window. Once a deferred job's window passes, adapters/sepolia-transactions.mjs:45-46 rejects prepare() with invalid_transaction. adapters/chain-executor.mjs transact() only converts authorityDenied codes into a terminal 'cancelled'; invalid_transaction is rethrown, and runOne maps every non-authority error to state 'waiting' with retryAt now()+60 s. The chain-modules / chain-registrations document keeps state 'queued', the job never reaches blocked/failed, and each retry performs capability() (chain.capabilityMatches: getChainId + getCode RPC) plus DB reads. With the planned finite gas allocation (0.0005 ETH per project, 8 projects) exhaustion is the expected steady state, so every public deployment that waited out its window turns into a permanent minute-cadence RPC loop on the chain worker; the same applies to src/approvals/index.mjs:209 for execute/queue intents only until the proposal's own expiresAt is checked by the executor (approval_expired), so deployments and registrations are the cases without a terminal path.","reproduction":"test/scratch/expired-deferral.test.mjs (node --test): createChainExecutor with a minimal valid module artifact, an enabled capability row, reviewed case and published source, and a module value whose expiresAt is in the past (the state a deployment reaches after 24 h of gas deferrals). deployment.step(...) throws { code: 'invalid_transaction' } and authorityDenied(error) is false (printed), instead of returning a terminal { state: 'blocked'|'failed' }. runOne therefore finishes the job 'waiting' with retryAt now()+60000 every time (line 147). Fix: in transact() or runOne, treat an intent whose expiresAt <= now() as terminal (e.g. blocked with error 'deployment_expired') and mark the chain-modules/chain-registrations row accordingly.","snippet":"        return jobs.finish(claim, { state: 'waiting', retryAt: now() + (['chain_gas_exhausted', 'transaction_cost_limit'].includes(error.code) ? 15 * 60000 : 60000),\n          result: { error: error.code ?? 'chain_execution_unavailable' } });","citation":"resolved","blocking":false}],"coverage":null},{"hash":"4646a42d70b724bda80a9977cc688f308be8f3a2b2833ef952a66d005cd206ab","nodeKey":"audit_math","role":"review","attempt":1,"deviceKey":"410247426ec5a6df24bcc4befa402ac518031430e3f07ab61a2958f23426b5e2","seat":{"tokenId":"1081","agentId":"50985"},"outcome":"completed","accepted":true,"failureReason":null,"failureClass":null,"safetyRefusal":false,"usage":{"model":"claude-fable-5-1","turns":48,"runtime":"claude","inputTokens":738,"wallClockMs":621556,"outputTokens":42851,"cachedInputTokens":2944732},"baseCommit":"91e8ba266d13e2532fd2e10fb5c456c639d39055","startCommit":"91e8ba266d13e2532fd2e10fb5c456c639d39055","resultTreeHash":null,"bundleHash":null,"bundleBytes":0,"artifacts":[],"changedPaths":[],"allowedPaths":[],"dependencyBundleHashes":[],"verificationErrors":0,"summary":"The review is complete. The findings file at the repository root holds four substantiated findings, each verified by running code against the tree, and no source files were changed.\n\n**What I found**\n\n- **Medium, expired-pilot projects escape every public ceiling** (`src/approvals/limits.mjs:7`). All new public limits key on a `public-chain-projects` row. An expired pilot project that already has a module and registration is admitted as public by the deployments gate but never enrolled, so it gets unlimited Claus decisions, skips the durable-agent requirement, and can queue unlimited intake requests. Reproduced: 40 decision reservations succeeded with zero counter rows written.\n- **Medium, the 32-event scan page counts spam** (`adapters/contract-intake.mjs:64`). The cap slices logs before the binding filter. Inbox requests are free and unauthenticated. Reproduced: 32 unknown-binding events in one block pushed the one admitted event to the next tick. Live Sepolia today has a 200M block gas limit at 0.001 gwei, so 33 spam requests per minute cost about 0.0000034 ETH and permanently outrun the cursor.\n- **Low, dispatch budget accepts per-project below per-transaction** (`adapters/contract-delivery.mjs:23`). The documented 0.00005 per project with 0.0002 per transaction is admitted, so the transaction ceiling never binds. Resolve measured at 84k gas internal means the first delivery fails the project ceiling whenever fees exceed 0.34 gwei. At today's fees it passes, hence low.\n- **Low, enrollment `capacity_reached` loops every minute** (`src/chain-deployments/index.mjs:147`). The new gas-ledger enrollment error is not in the 15-minute deferral list or the blocked list, so a full ledger repeats four RPC reads per minute until expiry.\n\n**Leads checked and rejected**\n\n- Cancelling a review job after payment submission would release public exposure, but the job effect fence refuses cancellation once the effect begins.\n- Scan cursor arithmetic, bisection termination, log-index filtering, budget reserve and finalize ledgers, the payment window margins, and read-after-write inside store transactions all held under concrete inputs.\n\n**Limits**\n\nSepolia fee behaviour was sampled once today, so the budget finding's severity depends on fee conditions. No Foundry proof files were needed because no finding is critical or high, and the defects are in the JavaScript admission paths rather than the Solidity contracts.","createdAt":"2026-10-12T11:59:45.212Z","verdict":null,"findings":[{"severity":"medium","title":"Expired-pilot projects bypass every public decision ceiling, the durable-credential requirement and the one-request intake limit","path":"src/approvals/limits.mjs","line":7,"description":"Every new public ceiling is keyed on the existence of a 'public-chain-projects' row: publicDecisionCapacity (2/project/day, 8/global/day, 4/project lifetime, 32/global lifetime) returns before reading any counter when the row is absent, and approvals.create (src/approvals/index.mjs:41), contract-intake bind (src/contract-intake/index.mjs:52) and ingest (src/contract-intake/index.mjs:114) gate the agent-credential and one-queued-request checks on the same row. But a project whose review pilot has expired is enrolled only when it deploys a new module (chain-deployments deployModule) or when a gas reservation is prepared (admission.reserveGas). Its proposal path is admitted by chain-deployments allowed() (src/chain-deployments/index.mjs:16-20), which requires the public chain.admission policy to be active but never calls reserveProject. So an expired pilot project that already owns a verified module and a registration (exactly the population the 'expired-pilot enrollment' change targets) is admitted as a public project for Claus decisions while being counted as nobody: it can create proposals and intake requests with no daily or lifetime decision cap, with an owner session instead of a durable agent credential, and with unlimited concurrent queued intake requests (bounded only by binding maxRequests 100 x 32 bindings). Each Claus decision spends the operator model budget the limits exist to narrow. This is a boundary x invariant seam: the invariant 'public decisions are capped' is enforced in the body but skipped by the early return on a state (pilot expired, not yet enrolled) that the other admission code treats as public.","reproduction":"State: review-pilots[P] = { enabled: true, expiresAt: now - 1 }; no 'public-chain-projects' row for P; chain.admission policy active; P has a verified chain-module and chain-binding from pilot time. Action: call approvals.create(ownerSession, P, {...}) repeatedly (one per completed proposal) or run publicDecisionCapacity(tx, P, now, true) 40 times. Expected (docs/operations.md 'Public Claus decisions are limited to two per project/day ... four per project over its lifetime'): the third call on a day, or the fifth overall, throws decision_capacity_reached, and an owner session is rejected with durable_agent_required. Actual (verified with node against src/storage/index.mjs in-memory store): 40 reservations succeed and zero 'public-decision-admission' rows are written; approvals.create also skips the identity.credential(actor).kind === 'agent' check because it reads the same missing row. Fix: enroll (reserveProject) in allowed()/approvals.create when the pilot is expired, or make publicDecisionCapacity and the credential/one-request checks key on 'pilot inactive or public row present' exactly as allowed() does.","snippet":"  if (!await reader.read('public-chain-projects', projectId)) return;","citation":"resolved","blocking":false},{"severity":"medium","title":"Scan page cap of 32 counts unadmitted spam events, so free inbox spam starves admitted requests","path":"adapters/contract-intake.mjs","line":64,"description":"The new per-invocation bound slices the sorted Requested logs to the first 32 before the worker looks up whether the binding is admitted (runtime/contract-intake-worker.mjs:82-88 skips unknown bindings at the cost of one store read). ClausRequestInbox.request is callable by anyone, takes no payment, and only requires a non-zero binding word, so an attacker produces events with random bindings for gas alone: 104,512 gas per request measured with forge on contracts/src/ClausRequestInbox.sol. Live Sepolia reading on 12 October 2026 (block 11898744 via the local RPC): gas price 0.001 gwei, block gas limit 199,999,809, so one block holds about 1,900 such requests and 33 requests per minute cost about 3.4e12 wei (0.0000034 ETH) per minute. The cursor advances at most 32 events per tick regardless of how many are junk, while the previous version advanced 500 blocks (up to 5,000 logs) per tick and skipped junk for free. At one tick per minute, any sustained rate above 32 spam events per minute makes the cursor fall behind by (rate - 32) events per minute forever; admitted requests queued behind the backlog are observed only after their on-chain deadline (at most 30 days, in practice far less) and are then rejected by ingest with contract_request_closed, so the requester's resolution never happens. This is a boundary x invariant seam: the invariant 'admitted events are ingested before their deadline' now depends on a work bound that an unauthenticated public input controls.","reproduction":"Verified with node against createContractInboxRead + createContractIntakeWorker (in-memory store, mocked client): block 1 holds 33 Requested logs, logIndex 0..31 with binding 0xbb..bb (no contract-bindings row) and logIndex 32 with an admitted binding 0xaa..aa. tick 1: ingested = 0, cursor = { afterBlock: 1, afterLogIndex: 31 }; the admitted event is not ingested although the worker did no work for the 32 junk events. tick 2: ingested = 1. Expected: a page of 32 unadmitted events costs nothing toward the bound and the admitted event is ingested in tick 1 (or the bound counts admitted/ingested events). Generalised: with N junk events per tick where N > 32, the admitted event is delayed by ceil(N/32) ticks per tick of spam, i.e. unboundedly. Fix: pass the binding filter (or the set of admitted binding words) into scan so the 32-cap applies after filtering, or bound by ingest attempts rather than raw log count while still skipping unknown bindings within the fetched range.","snippet":"      const remaining = logs.length > 32;\n      if (remaining) logs = logs.slice(0, 32);","citation":"resolved","blocking":false},{"severity":"low","title":"Public dispatch budget admits maximumProject below maximumTransaction, so the per-transaction ceiling is dead and the documented 0.00005/0.0002 allocation fails at the project ceiling once fees exceed","path":"adapters/contract-delivery.mjs","line":23,"description":"admitContractDispatchBudget checks maximumProject <= maximumTotal and maximumTransaction <= maximumTotal but not maximumTransaction <= maximumProject, unlike the sibling chain.admission schema which requires maximumProjectGasWei >= maximumTransactionGasWei (src/chain-deployments/admission.mjs:23). With the allocation the task and docs/operations.md:91 describe (0.0004 ETH total, 0.00005 ETH per project, 0.0002 ETH per transaction) the per-transaction ceiling is dead configuration: a reservation between 5e13 and 2e14 wei passes the transaction check on line 102 and always fails the project check on line 111 on the very first delivery for that project, and nothing documents or enforces that the effective per-transaction bound is a quarter of the stated one. Arithmetic: resolve() costs 84,128 gas internal (forge measurement) + 21,000 base + ~3,700 calldata = ~109k estimated; sepolia-transactions.prepare reserves gas*1.25+10,000 = ~146k gas at maxFeePerGas, so the reserve exceeds 5e13 wei whenever maxFeePerGas > 0.34 gwei, and resolve plus callback (~96k reserved) must fit under the same 5e13 lifetime per project, i.e. maxFeePerGas <= 0.21 gwei for a request with a callback. At the live Sepolia fee read on 12 October 2026 (0.001 gwei, block 11898744) the reserve is only 1.46e11 wei and passes, which is why this is low rather than medium; Sepolia has recurring multi-gwei periods during which every public delivery would be deferred with dispatch_project_budget_exhausted for 15 minutes at a time until the on-chain deadline closes the request.","reproduction":"Input (verified with node, in-memory store): admitContractDispatchBudget(store, { id:'public-7d', chainId:11155111, dispatcher, inbox, codeHash, projectIds:[], expiresAt, admissionMode:'public-readonly-v1', maximumTotal:'400000000000000', maximumProject:'50000000000000', maximumTransaction:'200000000000000', maximumGas:'30000000', maximumFeePerGas:'50000000000' }) -> accepted and stored. Then reserve() for a fresh project with maximum = 146,000 gas * 1 gwei = '146000000000000' (0.000146 ETH, which is what prepare computes when the node reports maxFeePerGas = 1 gwei): line 102 passes (1.46e14 <= 2e14) and line 111 throws dispatch_project_budget_exhausted because 0 + 0 + 1.46e14 > 5e13. Expected: admission rejects maximumProject < maximumTransaction with invalid_dispatch_budget (mirroring chain admission at src/chain-deployments/admission.mjs:23), or the allocation sets maximumTransaction <= 0.00005. Actual: the configuration is accepted, the per-transaction ceiling can never be the binding check, and above 0.34 gwei the first public resolve for any project is deferred indefinitely.","snippet":"  if (publicAdmission) requireThat(amount(config.maximumProject) > 0n && amount(config.maximumProject) <= total,\n    'invalid_dispatch_budget');","citation":"resolved","blocking":false},{"severity":"low","title":"capacity_reached from gas-ledger enrollment retries every 60 seconds with four RPC calls per attempt instead of blocking or using the gas backoff","path":"src/chain-deployments/index.mjs","line":147,"description":"admission.reserveGas now enrolls an expired-pilot project via reserveProject, which throws capacity_reached when public-chain-usage.global.projects >= maximumProjects. That error is thrown from onReserve inside sepolia-transactions.prepare, after estimateGas, estimateFeesPerGas, getTransactionCount and getBalance have already run; adapters/chain-executor.mjs transact() rethrows it because it is not in authorityDenied, and both chain-deployments runExecution (this line) and approvals.runExecution (src/approvals/index.mjs:209) treat it as a generic transient error: waiting with a 60-second retry. The deferral list was extended for chain_gas_exhausted and transaction_cost_limit (15 minutes) but not for the new enrollment failure, so a full public ledger produces a permanent one-minute loop of signed-intent preparation attempts (each with four RPC reads) until the registration/proposal expires, and the job never surfaces as blocked.","reproduction":"State: chain.admission active with maximumProjects = 8 and public-chain-usage.global.projects = 8; project P has review-pilots { enabled:true, expiresAt: past } and no public-chain-projects row; a protected-execute or module-deploy job for P is claimed. Action: executor.step -> transact -> transactions.prepare -> onReserve = admission.reserveGas -> reserveProject -> requireThat(8 < 8, 'capacity_reached') throws. Expected: the job is deferred with the 15-minute gas/capacity backoff or blocked with a visible reason like the other admission-limit outcomes. Actual: jobs.finish(..., state 'waiting', retryAt now+60000) every minute, result.error = 'capacity_reached', repeating four RPC calls per minute with no state change until expiry.","snippet":"        return jobs.finish(claim, { state: 'waiting', retryAt: now() + (['chain_gas_exhausted', 'transaction_cost_limit'].includes(error.code) ? 15 * 60000 : 60000),","citation":"resolved","blocking":false}],"coverage":null}]}