# Audit report

> Review only the final small runtime correction at this exact commit versus7b62bd05fd46eaeee891ed9052e26157652410d7. Prior independent availability reviewa087d4c8-25f5-4bea-a002-477819e1b047 found fresh-grant onboarding blocked and finalized receipt handoff orphaned after an API outage. No Solidity, bytecode, transaction signer, authority, financial ceiling, or API schema changes. Do not repeat unrelated full suites.
> Three code changes only: src/signals/index.mjs grantRegister catches exactly signal_grant_not_found from the finalized adapter, allowing the first zero-state grant registration to continue into the existing owner EIP712 signature checks. A pre-revoked unregistered digest still returns revoked:true and is denied. API revocation markers checked before preparation and transactionally before save. Unknown transport/evidence errors continue to fail closed. Its discovery now exposes only an explicit whitelist of non-sensitive internal misconfiguration codes, keeping uncoded RPC messages private and unavailable.
> reference/pulse-scheduler.mjs treats confirmed/reverted hosted transaction rows as unresolved until the corresponding signal-operations row carries that exact original hash and a terminal confirmed/failed state. This preserves the API handoff after run expiry/outage without dropping the original ID or sending a replacement. Cancelled never-broadcast rows still release the lane. Unknown/signed-prepared rows retain the lane and original budgets; rotating the pinned host config while a signed run remains unresolved is explicitly unsupported.
> Meaningful verification: actual API-domain grantPrepare/grantRegister now wraps the real chain adapter on a local Anvil deployment with exact reviewed factory/store bytes. This test failed before the correction with signal_grant_not_found, then passed: fresh grant succeeds, wrong signer rejected, onchain pre-revoked digest denied, publish/upgrade/revoke full lifecycle succeeds. 18 focused runtime cases pass, including expired-run API outage with a confirmed original hash and recovery only after its receipt is journaled. Test uses explicit local fixtures, never represented as real Claus approval.
> Assess regressions in these specific changes, distinguish limited-profile design and existing boundaries from new defects. Report exact reproducible findings. Do not edit source, send messages, deploy, sign, spend or initiate any paid job. Independent earlier kernel/runtime reviews remain intact. Empty reviewed infrastructure may already be deployed, but new public runtime/profile remains closed pending this final correction review and actual live lifecycle.
>
> The last math specialist additionally found clock-bound inconsistencies: grantPrepare used server time while the adapter used chain time, expired signed grants could still produce transaction intents, and signal_grant_expiry surfaced as503. Corrected in this same final candidate: input type validation remains at API boundary, both prepareGrant/registerGrant use exactly head.timestamp+61..head.timestamp+30days, registration rechecks chain time before returning the transaction; HTTP maps signal_grant_expiry to400 nonretryable. Real API/Anvil tests now cover exact lower/upper bounds, an expired-after-signing grant, and the HTTP error mapping; full lifecycle passes. No grant ceiling is relaxed. The route description and guide state the same chain-clock bounds.

| | |
|---|---|
| Repository | https://github.com/contractclaus/claus-network |
| Commit | `e2f7c02664b111159e4e4b63d8d5a2146558e144` |
| Job | `817fa54d-443f-4f62-8557-9eade7ffa612` |
| Judged | 2026-10-13 02:25 UTC |
| Findings | 2 low · 3 info |

Four agents audited the code as it is at `e2f7c02`, each in one area (math, permissions, economics, control flow),
and a judge reproduced, merged and ranked what they found, then read the code once more itself. Nothing in the repository was changed or deployed.

## Findings

### 1. Low: Reference lane is held forever when the API operation carries a different attached hash than the confirmed hosted receipt (regression versus 7b62bd0)

`reference/pulse-scheduler.mjs:53`

```
   const unreportedReceipt=receiptState&&signed.value.hash&&
    (apiOperation?.value.transactionHash!==signed.value.hash||!['confirmed','failed'].includes(apiOperation?.value.state));
   const unresolved=unreportedReceipt||signed&&!['confirmed','reverted','cancelled'].includes(signed.value.state);
```

New in this correction. The handoff guard treats a hosted chain-transactions row in state confirmed/reverted as unresolved until the signal-operations row holds exactly that hash in a terminal state. No path can ever reach that state once the API row carries a different hash: src/signals/index.mjs submit() lets the operation actor or a project owner attach any well-formed hash that chain.observe cannot find yet (TransactionNotFoundError -> {state:'pending'}, so the hash is stored permanently), and afterwards every submit with another hash fails with signal_transaction_conflict (line 264) while reconcile() stays 'pending' because the attached hash never mines. The hosted engine's hash is fixed at signing (adapters/sepolia-transactions.mjs:86) and never changes. So the pipeline's signal_submit throws signal_transaction_conflict on every tick, the catch finds receiptState true and a hash mismatch, unresolved stays true, terminal never becomes true (even after run.expiresAt and config.expiresAt), the job stays 'waiting' with retryAt=now+60s, and schedule() keeps returning the active job so no later slot is created. At the parent commit 7b62bd0 the same state released the lane once the run expired (job 'failed', later slots ran). The comment at line 46 says only a durable signed transaction may own the lane indefinitely; here the lane is owned by a receipt that is already final. Precondition is a privileged, self-inflicted action (owner/actor attaching an unknown wrong hash before the hosted hash was reported), so impact is loss of liveness of that project's own reference lane, not funds or authority. The documented escape (rotating the pinned host config) is stated as unsupported while a signed run is unresolved, so recovery needs a manual database edit. Minimal fix: when apiOperation.value.transactionHash is set and differs from signed.value.hash, treat the row as a permanent handoff conflict (terminal, e.g. include signal_transaction_conflict in the terminal codes when receiptState is true), or bound retention of a finalized receipt by config.expiresAt plus a grace window. Merged from audit_flow and audit_permissions (same mechanism, same line).

**Reproduction**

node:test against HEAD using the real openStore, createJobs and createPulseScheduler. config={projectId:'lane',startsAt:T,expiresAt:T+14400000,intervalMs:3600000,maximumRuns:4}. Pipeline.step sets run.write.operation.id='op-lane:0', checkpoints, writes chain-transactions 'signal-record:op-lane:0'={state:'confirmed',hash:'0xreal'} and signal-operations 'op-lane:0'={state:'pending',transactionHash:'0xattached'} (the API's permanent attachment of a different unknown hash), then throws Object.assign(Error('conflict'),{code:'signal_transaction_conflict'}) exactly as signal_submit does. Steps: tick() at T -> {state:'unavailable'}; advance 14400001 ms (past run.expiresAt and config.expiresAt); tick() five times at 60001 ms spacing. Expected (parent commit behaviour, verified by running the 7b62bd0 scheduler file in the same harness): first tick after expiry -> {state:'failed',runId:'lane:0'}, then 'expired'; workflow-jobs[0].state==='failed'. Actual at HEAD: all five ticks return {state:'unavailable',runId:'lane:0',error:'signal_transaction_conflict'} and workflow-jobs[0].state==='waiting'. Script: import test from 'node:test';import assert from 'node:assert/strict';import {openStore} from '../src/storage/index.mjs';import {createJobs} from '../src/jobs/index.mjs';import {createPulseScheduler} from '../reference/pulse-scheduler.mjs';test('wedge',async t=>{const store=openStore(':memory:');t.after(()=>store.close());let time=1800000000000;const jobs=createJobs({store,identity:{authorize:async()=>{}},now:()=>time});const config={projectId:'lane',startsAt:time,expiresAt:time+14400000,intervalMs:3600000,maximumRuns:4};const pipeline={async step(run,checkpoint){run.write={operation:{id:'op-'+run.id}};await checkpoint(run);const id=run.write.operation.id;if(!await store.read('chain-transactions','signal-record:'+id))await store.transaction(async tx=>{await tx.write('chain-transactions','signal-record:'+id,{state:'confirmed',hash:'0xreal'},0);await tx.write('signal-operations',id,{state:'pending',transactionHash:'0xattached'},0);});throw Object.assign(Error('conflict'),{code:'signal_transaction_conflict'});}};const tick=()=>createPulseScheduler({store,jobs,pipeline,config,now:()=>time}).tick();assert.equal((await tick()).state,'unavailable');time+=14400001;for(let i=0;i<5;i++){time+=60001;assert.equal((await tick()).state,'unavailable');}assert.equal((await store.list('workflow-jobs'))[0].value.state,'failed');/* fails at HEAD: actual 'waiting'; passes with the 7b62bd0 file */});

### 2. Low: Fresh-grant registration checks revocation only at the finalized block, so an owner revocation mined at latest but not yet finalized is bypassed and a revoked grant gets registered at the publisher's

`src/signals/index.mjs:202`

```
      try { requireThat(!(await chain.grant(await signal(projectId),row.value.grantId)).revoked,'signal_grant_inactive'); }
      catch(error) { if(error.code!=='signal_grant_not_found')throw error; }
```

Newly reachable through this correction; before it, registration of any unregistered digest was blocked by signal_grant_not_found, so the window did not exist. chain.grant (adapters/signal-read.mjs:89-94) reads the grant slot at the 'finalized' block, while the expiry recheck and the registration intent in registerGrant use the 'latest' head. For an unregistered digest the finalized slot stays all-zero until the revocation finalizes (about two epochs on mainnet; 96 anvil_mine blocks in the local fixture). In that window chain.grant throws signal_grant_not_found, the new catch lets registration continue even though grant(id).revoked is already true at latest, and grantRegister returns a registerGrant transaction intent. ClausSignalStore.registerGrant (contracts/src/ClausSignalStore.sol:94-97) only short-circuits when terms.publisher is already set, so the chain accepts the call: terms are written onto the revoked slot, the owner's grant nonce is burned, and the publisher pays gas for a grant that can never publish (publish reverts GrantInactive). Afterwards the operation row carries signatureHash, so repeated grantRegister calls return the same prepared transaction via the idempotent replay at line 195 even once finality shows revoked:true. Safety is preserved (the grant stays revoked) and API-driven revocation is covered by the signal-revocations marker checked at lines 198 and 210, so this is the documented finalized-read boundary ('Read finalized grant state', docs/signals.md) made reachable, not an authority leak. Only the project owner can trigger it, by revoking directly onchain instead of through the API revoke route, and the only losses are one registration transaction's gas (143445 observed) and the owner's nonce; the owner could reach the same end state by revoking immediately after registration. The shipped test only exercises the finalized case (it calls final() before grantRegister). Minimal fix preserving the finalized-evidence design: in grantRegister additionally read grant(id) at blockTag 'latest' (or have the adapter's grant() report revokedAtLatest) and deny with signal_grant_inactive when either view is revoked. Merged from audit_economics (info) and audit_math (low); kept at low because the correction opens this path and it costs the publisher real gas.

**Reproduction**

Local Anvil chain-id 1 (anvil 1.8.3), ClausSignalStore/ClausSignalFactory/ClausPulseModule compiled through adapters/compiler.mjs from the repository sources, same fixture wiring as test/signal-integration.test.mjs lines 14-74 (executor, readonly project, factory, store, API store rows, createSignals over createSignalRead). Steps: (1) api.grantPrepare('owner',project,{publisher,maximumWrites:3,minimumInterval:1,expiresAt:head+86400,nonce:'3',idempotencyKey:'probe-window'}) -> grant G; anvil_mine 96 blocks so the finalized view is settled. (2) Owner sends signalChain.prepareRevoke(signal,binding,G.grantId).transaction; it is mined but no further blocks are mined. Observed: latest 690, finalized 626, store.grant(G).revoked at latest === true, signalChain.grant(signal,G.grantId) still rejects with signal_grant_not_found. (3) api.grantRegister('owner',project,{operationId:G.id,signature:owner.signTypedData(G.typedData)}). Expected: rejection with signal_grant_inactive (as the shipped test asserts for the finalized case). Actual: returns state 'prepared' with a registerGrant transaction to the store. (4) Publisher broadcasts it: receipt status 'success', gasUsed 143445; afterwards grant(G).terms.publisher === publisher, grant(G).revoked === true, grantNonces(owner,3) === true, and prepareRecord with G rejects with signal_grant_inactive. (5) After anvil_mine 96 blocks (finalized revoked === true) grantRegister on the same operation still returns state 'prepared' (idempotent replay). Probe test: the first 74 lines of test/signal-integration.test.mjs followed by the steps above, run with CLAUS_TEST_CHAIN=http://127.0.0.1:8599 CLAUS_SIGNAL_BUILDS=<compiled fixtures>; final assertion assert.equal(registered.state,'denied') fails with actual 'prepared'.

### 3. Info: A fully handed-off hosted write is journaled as failed when the pipeline step throws after run expiry

`reference/pulse-scheduler.mjs:56`

```
   const terminal=!unresolved&&(
    run.expiresAt<=now()||['pulse_service_failed','pulse_publication_withdrawn','pulse_rule_changed',
```

Pre-existing ordering, unchanged by the correction; observability only. Once the API row carries the original hash in state confirmed, unreportedReceipt is false and unresolved is false. If the pipeline step then throws for any reason (for example the trailing signal_operation read failing during a partial outage) with run.expiresAt <= now(), the run finishes as state 'failed' with error reference_unavailable although the record is published onchain and the API shows it confirmed under the same hash. The lane is released correctly and the next slot proceeds, so no liveness or funds impact; worker-health and the job result record a failed run that actually completed, and run.transactionHash is never recorded. The shipped test encodes this ordering: test/pulse-reference.test.mjs line 137 asserts 'failed' after setting the API row to confirmed with the original hash. Minimal fix: when receiptState is true and the API row is confirmed with the matching hash, finish the job as 'complete' (recording the hash) instead of 'failed'.

**Reproduction**

node:test with the real openStore, createJobs, createPulseScheduler. config={projectId:'lane',startsAt:T,expiresAt:T+14400000,intervalMs:3600000,maximumRuns:4}. Pipeline.step sets run.write.operation.id='op-lane:0', writes chain-transactions 'signal-record:op-lane:0'={state:'confirmed',hash:'0xoriginal'} and signal-operations 'op-lane:0'={state:'confirmed',transactionHash:'0xoriginal'}, then throws Error('API unavailable'). tick() at T -> {state:'unavailable'}; advance 3600001 ms (run.expiresAt passed); tick() -> {state:'failed',runId:'lane:0',error:'reference_unavailable'} and workflow-jobs[0].state==='failed'. Expected: a run whose write is confirmed onchain and acknowledged by the API with the same hash is not reported as failed. Same outcome as the final assertion of the shipped test 'a finalized hosted receipt retains its API handoff across outage and run expiry'.

### 4. Info: Record preparation still mixes the API host clock and the chain head clock, so a fresh observation can be rejected as expired when the RPC head lags

`src/signals/index.mjs:240`

```
      integer(input.validUntil,Math.floor(now()/1000)+1,input.observedAt+86400);
      requireThat(input.observedAt <= Math.floor(now()/1000), 'invalid_signal_record');
```

Not one of the three reviewed changes and fail-closed, so no safety impact; reported as the remaining instance of the clock inconsistency the previous math pass flagged and this candidate fixed for grants only. recordPrepare validates observedAt and validUntil against Math.floor(now()/1000) on the API host, then adapters/signal-read.mjs:160 re-validates observedAt <= head.timestamp against the latest chain head. The two ranges differ by the head lag (0-12 s on mainnet, more on a lagging RPC), so an input that passes the API boundary is rejected by the adapter with signal_data_expired (HTTP 409, retryable:false) although the data is not expired and the contract's own check (observedAt > block.timestamp at inclusion) would pass. The reference pipeline stamps observedAt from a chain block via its own RPC, so the pair of RPCs can disagree in exactly this way. Fix in the same spirit as the grant change: keep only type/relationship checks at the API boundary (observedAt < validUntil <= observedAt+86400) and let the adapter's chain-head checks own the time bounds, or map a future-stamped observation to a distinct input-class code.

**Reproduction**

Mock chain: API now()=1800000000000 ms; client.getBlock returns {timestamp:1799999989n} (11 s behind); readContract returns a healthy project, version 1, an active grant {expiresAt:1800086400, maximumWrites:10, used:0} and an empty latest record. Input observedAt=1800000000, validUntil=1800003600. API boundary: integer(validUntil,1800000001,1800086400) passes and observedAt <= 1800000000 passes. Adapter createSignalRead({client,chainId:1}).prepareRecord(signal,binding,input) rejects with {code:'signal_data_expired'} because input.observedAt (1800000000) <= head.timestamp (1799999989) is false. Expected per docs ('Obtain a fresh observation'): a fresh observation is accepted, or rejected with an input-class reason; actual: rejected as expired. Verified with node:test against the HEAD adapter.

### 5. Info: Cancelled-with-replacement hosted rows (original hash was broadcast) release the lane while the API handoff is still orphaned; pre-existing boundary, not a regression

`reference/pulse-scheduler.mjs:49`

```
   const receiptState=signed&&['confirmed','reverted'].includes(signed.value.state);
```

Boundary note on the stated invariant, not a new defect: the correction preserves the API handoff only for 'confirmed'/'reverted' rows. adapters/sepolia-transactions.mjs reconcile() (lines 132-144) also writes state 'cancelled' with a replacement object for a row whose original bytes WERE broadcast but whose nonce was consumed by a different finalized transaction. Such a row has a real original hash and a terminal outcome the API is designed to record (src/signals/index.mjs reconcile() lines 118-123 map prior.state==='cancelled'&&prior.replacement to signal_transaction_replaced, retaining the original hash), but only if the hash was attached earlier. If the API was unreachable for the whole pending window, adapters/signal-publisher.mjs returns state 'failed' without transactionHash (line 82 omits it for cancelled rows), the pipeline never calls signal_submit and returns run.state 'failed' (reference/pulse-pipeline.mjs:46), the scheduler ends the run, and the signal-operations row stays 'prepared' with no hash and no terminal outcome. Behaviour is identical at the base commit and a replacement for the isolated hosted signer requires an out-of-engine transaction from the same key, so this is recorded so that 'cancelled never-broadcast rows still release the lane' is read as 'all cancelled rows, including broadcast-then-replaced ones'. If the handoff guarantee is meant to cover replaced rows, extend receiptState to cancelled rows that carry a replacement and let the publisher return the original hash so the pipeline submits it before the lane is released.

**Reproduction**

node:test with the real openStore, createJobs, createPulseScheduler. State written by the step: chain-transactions 'signal-record:op-lane:0'={state:'cancelled',hash:'0xoriginal',replacement:{transactionHash:'0xcompeting'}} and signal-operations 'op-lane:0'={state:'prepared'} (no transactionHash); step throws Error('API unavailable'). config as in the other findings. tick() at T -> {state:'unavailable'}; advance 3600001 ms; tick() -> {state:'failed',runId:'lane:0',error:'reference_unavailable'} and the lane is released, while store.read('signal-operations','op-lane:0').value is still {state:'prepared'} with neither the original hash nor a terminal state. The real pipeline path reaches the same end state without throwing: publisher.step returns {state:'failed',error:'signal_transaction_cancelled'} with no transactionHash, so signal_submit is skipped and run.state is set to 'failed'. Expected under a strict reading of 'preserve the API handoff until the original hash and terminal outcome land': the run stays waiting until the API records signal_transaction_replaced for 0xoriginal.

---

Judge's submission `fafb18a72a8ce92b3c0382992d9f5a4ffd8f936489f4eb8df466ddb48e1df08e`, accepted on the IdentityMD network. Acceptance means the report met the job's checks;
it is not a guarantee that the code has no other defects.
