{"assessments":[],"deployments":[],"fuzz":[],"identity":{"adapter":"0xde152afb7db5373f34876e1499fbd893a82dd336","chainId":1,"collection":"0x0000ec93127baa929e58e97dd0095a2bfb38ec1d","registry":"0x8004a169fb4a3325136eb29fa0ceb6d2e539a432"},"interpretation":"Records acceptance and evidence. Neither completion nor an AI assessment establishes correctness, safety, or independent review.","jobId":"a087d4c8-25f5-4bea-a002-477819e1b047","kind":"audit","nodes":[{"acceptedSubmissionHash":"fbb4354e3c57fd77baa71a9a5d814d5e513efa27a347a85b8a8488111c1ae495","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_economics","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"52d998c8fef17e21761d6bbfe27f7bc2dfae32fed4370884655af505a001657f","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_flow","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"d4ab5c6d919b576224ac57bea616c67683c4ef2be20c6cbd19c7be042b373cdb","dependsOn":["audit_math","audit_permissions","audit_economics","audit_flow"],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"af3aa01159bbf354f621cafa5c0006f6169e0938a8b015be508b7377dbf165bc","skillId":"audit-judge","tools":[]},"key":"audit_judge","kind":"code","role":"review","skillHash":"af3aa01159bbf354f621cafa5c0006f6169e0938a8b015be508b7377dbf165bc","skillId":"audit-judge","state":"accepted"},{"acceptedSubmissionHash":"6d3495d9c24fc8176831ea01db4971474937fb0040c018f8da00dbc24f420778","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_math","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"b5fd51840929644572d35962e00165544d93089cadf4b71bb049dd7d0579a46c","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_permissions","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"}],"objective":"Review only the final runtime availability/recovery correction at this exact commit against previous candidate 8b03d350c768cd4ef6f36107aca22e1e09729935. Solidity, permissions model, source validation, Mainnet intake and reviewed kernel are unchanged. Prior independent reviews: kernel 4bfe5f40-24e0-4429-b930-397b3927ff5e, runtime 5184c276-7378-44a6-9d86-a25e7916ccf7, delta d3229638-a2a2-4dd7-97f0-8e2552c31e96. Do not repeat an unrelated full audit or tests.\nChanged runtime scope: src/signals/index.mjs discovery isolates chain dependency failures (without exposing RPC details) while writes still fail closed; grant registration rejects API/onchain revoked grants; pending hosted record becomes terminal only when its isolated transaction engine has exact finalized replacement evidence matching original hash, chain, sender, target and calldata, preserving that hash. No user-supplied replacement proof is accepted. adapters/signal-read.mjs checks grant maximum against latest chain time and requires 60s inclusion margin on grant/record validity. reference/pulse-pipeline.mjs reads finalized grant once per fresh run, clamps service and record expiry, rejects near-stale publication with a terminal code. reference/pulse-scheduler.mjs retains only unresolved signed transaction rows, not terminal rows. Test the security/liveness boundaries of these changes with targeted cases, including ordinary RPC failure, mismatched replacement proof, revoked registration, expiry, restart and unknown broadcast preservation.\n17 targeted runtime cases and the real Anvil lifecycle with time boundary assertions pass. Original Solidity is unchanged. One consumer profile is selected at first successful preparation and is deliberately permanent, documented; no profile-switch/custody admission is added. Budget/grant rotation during unknown transactions is unsupported: reconcile original config first. External wallets handle their own replacement proof; server trust applies only to its internal isolated transaction engine. No fabricated Claus approval or new financial grant.\n","parentJobId":null,"planHash":"da7d24490b314691e1f184ab7e476c01d6b807e94cc2d8ac236e13edb0a40bf2","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"a087d4c8-25f5-4bea-a002-477819e1b047","publication":{"commit":null,"deliveredAt":null,"repoUrl":null},"receiptIdentity":{"adapter":"0xde152afb7db5373f34876e1499fbd893a82dd336","chainId":1,"collection":"0x0000ec93127baa929e58e97dd0095a2bfb38ec1d","registry":"0x8004a169fb4a3325136eb29fa0ceb6d2e539a432"},"registry":"0xb6d0a187b050fa5bb0b87033a203f37becf4a775","research":[],"schema":"identitymd-work-v1","signals":[{"agentId":"52222","feedbackHash":"63d0987ef50866ebf9ca3cac34a0f13578f304e8069bf610a3632223a52d751c","nodeKey":"audit_economics","submissionHash":"fbb4354e3c57fd77baa71a9a5d814d5e513efa27a347a85b8a8488111c1ae495","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52210","feedbackHash":"6453dc170a22b863d1ee08673d14ab435848b8c6a6668b32d3e7158d07042d04","nodeKey":"audit_flow","submissionHash":"52d998c8fef17e21761d6bbfe27f7bc2dfae32fed4370884655af505a001657f","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51319","feedbackHash":"3a9eb8ef50e408ffa61dc19d466c9d7fa34f9e6ad860c14b594c8c61a42a34ff","nodeKey":"audit_judge","submissionHash":"d4ab5c6d919b576224ac57bea616c67683c4ef2be20c6cbd19c7be042b373cdb","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51432","feedbackHash":"1705a668dee88b15957e7ce000cf88254d9e87363e3bdf8d6da1b6208d5f29dd","nodeKey":"audit_math","submissionHash":"6d3495d9c24fc8176831ea01db4971474937fb0040c018f8da00dbc24f420778","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52420","feedbackHash":"9ac6d7c7e07892ed3640acc7f5ad36d29fe40be4578db1e4848e2c2c988db270","nodeKey":"audit_permissions","submissionHash":"b5fd51840929644572d35962e00165544d93089cadf4b71bb049dd7d0579a46c","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"7f10822783a8756ee7be0498ed84d77071952d6524bd1760c0c24255eb40fd62","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"dff6c0d3de4aa913","findings":[{"citation":"resolved","description":"This line was added in 7b62bd0 (absent at 8b03d35). It calls chain.grant() before chain.registerGrant(). adapters/signal-read.mjs grant() (line 69-74) does `requireThat(value?.terms && (!zero(value.terms.publisher) || value.revoked), 'signal_grant_not_found')`, and ClausSignalStore.grant(bytes32) (contracts/src/ClausSignalStore.sol:125) returns the zeroed GrantState for any id that has not been registered yet (publisher == address(0), revoked == false). So for the normal case, a freshly owner-signed grant that has never been registered onchain, the guard throws signal_grant_not_found and grantRegister never reaches registerGrant. The only grants that pass the guard are ones already registered (where re-registration is a no-op per the contract) or pre-revoked ones (which are then rejected as inactive). The documented lifecycle (docs/signals.md step 3 'Call signal_grant_register ...', docs/pulse-walkthrough.md:156 `call('signal_grant_register',{operationId:grant.id,signature:grantSignature})`) is therefore unreachable through the API at this commit: no new publisher grant can be registered via the service, which blocks every new record (prepareRecord requires an active grant). The unit test in test/signals.test.mjs does not catch this because its mock chain.grant() always returns `{revoked:false}`; test/signal-integration.test.mjs calls the adapter's registerGrant directly and never exercises the API method. Minimal fix: treat signal_grant_not_found as 'not registered, not revoked', e.g. `const g=await chain.grant(s,row.value.grantId).catch(e=>{if(e.code!=='signal_grant_not_found')throw e;return {revoked:false};}); requireThat(!g.revoked,'signal_grant_inactive');` (the pre-revoked case still returns revoked:true from the adapter because the contract keeps revoked=true with a zero publisher).","line":195,"path":"src/signals/index.mjs","reproduction":"State: real Anvil chain id 1, real ClausApprovalExecutor + ClausSignalFactory + ClausSignalStore (forge build output), real adapters/signal-read.mjs, real src/signals/index.mjs; only artifact validation stubs (validateAdmission/validateStoreBuild/assertTrackedStore) are no-ops. Steps: storePrepare -> owner sends -> anvil_mine 96 -> submit => 'confirmed'; grantPrepare({publisher, maximumWrites:3, minimumInterval:1, expiresAt: head+86400, nonce:'14'}) => 'awaiting_signature'; owner signs grant.typedData; api.grantRegister('owner','p',{operationId:grant.id, signature}). Expected (docs/signals.md step 3): state 'prepared' with the publisher's registerGrant transaction. Actual: rejects with code 'signal_grant_not_found'; operation stays 'awaiting_signature'. Control: calling the adapter directly, signalChain.registerGrant(projectSignal, binding, grantRow, signature), succeeds and returns transaction.from === publisher, proving line 195 is the sole blocker. Full test (ran green = defect present; node --test, anvil --chain-id 1 on 127.0.0.1:8545, after `forge build` in contracts/):\n\n```js\nimport test from 'node:test';import assert from 'node:assert/strict';import {readFile} from 'node:fs/promises';\nimport {createPublicClient,createWalletClient,http,keccak256,encodeAbiParameters,stringToHex} from 'viem';\nimport {generatePrivateKey,privateKeyToAccount} from 'viem/accounts';\nimport {createSignalRead} from '../../adapters/signal-read.mjs';import {createChainRead} from '../../adapters/chain-read.mjs';\nimport {createSignals} from '../../src/signals/index.mjs';import {openStore} from '../../src/storage/index.mjs';import {digest,requireThat} from '../../src/shared/index.mjs';\ntest('grantRegister cannot prepare a first registration',{timeout:120000},async t=>{\n const url=new URL('http://127.0.0.1:8545');const client=createPublicClient({transport:http(url.href,{retryCount:0}),cacheTime:0,pollingInterval:50});\n const [owner,claus,publisher]=Array.from({length:3},()=>privateKeyToAccount(generatePrivateKey()));\n for(const a of [owner,publisher])await client.request({method:'anvil_setBalance',params:[a.address,'0x56bc75e2d63100000']});\n const wallet=a=>createWalletClient({account:a,transport:http(url.href)});\n const wait=async hash=>{const r=await client.waitForTransactionReceipt({hash});assert.equal(r.status,'success');return r;};\n const send=async(tx,signer)=>wait(await wallet(signer).sendTransaction({chain:null,to:tx.to,data:tx.data,value:0n}));\n const final=()=>client.request({method:'anvil_mine',params:['0x60','0x1']});\n const out=async name=>JSON.parse(await readFile(new URL(`../../contracts/out/${name}.sol/${name}.json`,import.meta.url)));\n const [native,factoryOut,pulse]=await Promise.all(['ClausApprovalExecutor','ClausSignalFactory','ClausPulseModule'].map(out));\n const deploy=async(abi,bytecode,args)=>(await wait(await wallet(owner).deployContract({chain:null,abi,bytecode,args}))).contractAddress;\n const executor=await deploy(native.abi,native.bytecode.object,[claus.address,owner.address,86400n]);const moduleAddress=await deploy(pulse.abi,pulse.bytecode.object,[]);\n const chainRead=createChainRead({client,chainId:1}),signalChain=createSignalRead({client,chainId:1});const now=()=>client.getBlock({blockTag:'latest'});\n const configData=encodeAbiParameters([{type:'uint256'},{type:'uint256'}],[1000000000n,3600n]);\n const module={id:'fixture',state:'verified',chainId:1,address:moduleAddress,codeHash:keccak256(await client.getCode({address:moduleAddress}))};\n const capability={enabled:true,chainId:1,profile:'claus-readonly-v1',executor,executorCodeHash:keccak256(await client.getCode({address:executor}))};\n const registration=await chainRead.registration({projectId:'prj_signal-local',owner:owner.address,module,configuration:configData,mandateHash:keccak256(stringToHex('local-grant')),minDelaySeconds:0,capability,expiresAt:Number((await now()).timestamp)+86400});\n await wait(await wallet(owner).writeContract({chain:null,address:executor,abi:nati","severity":"high","snippet":"      requireThat(!(await chain.grant(await signal(projectId),row.value.grantId)).revoked,'signal_grant_inactive');","title":"grantRegister can never prepare a first grant registration: the new onchain-revocation guard reads grant(id) of a not-yet-registered grant and the adapter throws signal_grant_not_found"},{"citation":"resolved","description":"Before 7b62bd0 any chain-transactions row for the run's operation kept the run non-terminal (`terminal=!signed&&(...)`), so a confirmed hosted write whose `signal_submit` call to the network API failed kept retrying at 60s until the hash was journaled. The new rule treats confirmed/reverted engine rows as 'resolved' and makes the run terminal on `run.expiresAt<=now()` or any listed code. But the pipeline attaches the engine hash to the API operation only via `call('signal_submit',...)` in reference/pulse-pipeline.mjs:45, which runs after `publisher.step` and can throw an uncoded transport/5xx error. The engine (finalized:true) reports confirmed only after finality (~13 min on mainnet), while `run.expiresAt` is `now+intervalMs` (1h) from slot start, so a write broadcast late in the slot is routinely confirmed after run.expiresAt. Then a single failed signal_submit (or signal_operation) call makes `terminal=true`: the job is finished 'failed' with error 'reference_unavailable', the lane advances to the next slot, and the signal-operations row stays 'prepared' with no transactionHash forever, although the publish executed onchain. This contradicts docs/signals.md ('Any earlier uncertain transaction keeps its original hash and remains independently reconcilable' - here the API never learns the hash) and misreports a successful write as a failed run in worker-health. The same applies to 'reverted' rows (publisher.step returns the hash with error signal_transaction_reverted and the pipeline still calls signal_submit first). Minimal fix: only count a confirmed/reverted engine row as resolved once the API operation carries its hash, e.g. read 'signal-operations' for run.write.operation.id and require `op.transactionHash===signed.value.hash` (or op.state in confirmed/failed) before treating the row as resolved; cancelled rows (no broadcast / replacement proven) may stay as they are.","line":49,"path":"reference/pulse-scheduler.mjs","reproduction":"State: in-memory store, createJobs, createPulseScheduler with config {startsAt:T, expiresAt:T+14400000, intervalMs:3600000, maximumRuns:4}. Pipeline stub: sets run.write={operation:{id:'op-'+run.id}}, checkpoints, writes chain-transactions 'signal-record:op-<run>' = {state:'confirmed', hash:0xab..ab, sender, intent} once, then throws `new TypeError('fetch failed')` (what a failed signal_submit HTTP call produces: no error.code). Tick 1 at T => {state:'unavailable'} (retry). Advance now to T+3600001 (run.expiresAt passed) and tick again. Expected (behaviour at 8b03d35 and per docs): still 'unavailable'/waiting, retried until the hash is journaled. Actual at 7b62bd0: {state:'failed', runId: same}, workflow-jobs row state 'failed' with result.error 'reference_unavailable'; the next tick starts a new runId; no signal_submit ever succeeded for the confirmed hash. Test (passes = defect present):\n\n```js\nimport test from 'node:test';import assert from 'node:assert/strict';\nimport {openStore} from '../../src/storage/index.mjs';import {createJobs} from '../../src/jobs/index.mjs';import {createPulseScheduler} from '../../reference/pulse-scheduler.mjs';\ntest('confirmed engine row + failed signal_submit past run expiry => run failed',async t=>{\n const store=openStore(':memory:');t.after(()=>store.close());let time=1800000000000,submits=0;\n const jobs=createJobs({store,identity:{authorize:async()=>{}},now:()=>time});\n const config={projectId:'lane',startsAt:time,expiresAt:time+14400000,intervalMs:3600000,maximumRuns:4};\n const pipeline={async step(run,checkpoint){run.write={operation:{id:'op-'+run.id}};await checkpoint(run);const key='signal-record:'+run.write.operation.id;\n  if(!await store.read('chain-transactions',key))await store.transaction(tx=>tx.write('chain-transactions',key,{state:'confirmed',hash:'0x'+'ab'.repeat(32),sender:'0x'+'11'.repeat(20),intent:{chainId:1,to:'0x'+'22'.repeat(20),data:'0x01',value:'0'}},0));\n  submits++;throw new TypeError('fetch failed');}};\n const tick=()=>createPulseScheduler({store,jobs,pipeline,config,now:()=>time}).tick();\n const first=await tick();assert.equal(first.state,'unavailable');time+=3600001;\n const second=await tick();assert.equal(second.runId,first.runId);assert.equal(second.state,'failed');\n const job=(await store.list('workflow-jobs')).find(r=>r.value.result?.run?.id===first.runId);assert.equal(job.value.state,'failed');assert.equal(job.value.result.error,'reference_unavailable');\n const third=await tick();assert.notEqual(third.runId,first.runId);\n});\n```","severity":"medium","snippet":"   const unresolved=signed&&!['confirmed','reverted','cancelled'].includes(signed.value.state);","title":"Scheduler finalizes a run as failed and releases the lane when the engine row is confirmed/reverted but the hash was never accepted by the API (signal_submit failed), leaving the operation permanently"},{"citation":"resolved","description":"The catch-all added in 7b62bd0 is meant to isolate RPC outages without exposing RPC details, but it also swallows the adapter's definitive, coded admission failures: adapters/signal-read.mjs factory() throws 'signal_factory_changed' (config.chainId mismatch, factory code hash changed, factory.executor() != config.executor) and 'wrong_chain' (RPC serves a different chain id); adapters/signal-evidence.mjs throws 'signal_evidence_invalid' when the reviewed factory artifact does not reproduce the onchain bytecode; artifact-store throws 'not_found'/'artifact_corrupt'. None of these resolve by retrying, yet the public capabilities endpoint (src/access/index.mjs:93 signalProfiles) now labels the profile 'unavailable' with a reason that says 'dependency unavailable', so operators and clients cannot distinguish a misconfigured or tampered admission from an RPC blip, and the comment's own claim that only 'a chain dependency outage closes this profile' is not what the code does. Writes still fail closed with the precise code, so impact is diagnostic, not a bypass. Fix: whitelist the transient classes instead of blacklisting the three not_enabled codes, e.g. return 'unavailable' only for errors without a code (transport/RPC) or for 'signal_chain_unavailable'/'wrong_chain', and map the coded permanent failures to admission:'not_enabled' with reason:error.code (they already carry no RPC detail).","line":165,"path":"src/signals/index.mjs","reproduction":"State: the rows from test/signals.test.mjs fixture (network-capabilities signal:1 enabled, reviewed case, accepted assessment) and chain.validateAdmission that throws a coded error, e.g. `requireThat(false,'signal_factory_changed')` (what the real adapter throws when config.factoryCodeHash no longer matches the chain). Call signals.discovery(1). Expected: a definitive state such as {admission:'not_enabled', reason:'signal_factory_changed'} (or the error surfacing), since retrying cannot change it. Actual: {chainId:1, profile:'claus-signal-v1', admission:'unavailable', reason:'signal_dependency_unavailable'}. Verified for codes signal_factory_changed, wrong_chain, signal_evidence_invalid and an artifacts.get that throws 'artifact_not_found': all four return the identical outage payload (scratch test with createSignals({chain:{validateAdmission:async()=>requireThat(false,code)}, artifacts:{get:...}}) passed for each).","severity":"low","snippet":"        return {chainId,profile:'claus-signal-v1',admission:'unavailable',reason:'signal_dependency_unavailable'};","title":"discovery reports every non-listed admission failure, including permanent ones (signal_factory_changed, wrong_chain, signal_evidence_invalid, artifact not_found/corrupt), as a transient 'signal_depend"}],"hash":"52d998c8fef17e21761d6bbfe27f7bc2dfae32fed4370884655af505a001657f","nodeId":"22fa8f5e-ca7c-4831-b898-3e1b0df18a44","outcome":"completed","summary":"The Anvil node is stopped (the only match is the shell's own command line), no tracked file changed, and the findings file validates with all three snippets matching the tree.\n\n**Review of 7b62bd0 against 8b03d35 is complete.** Three substantiated defects are in `.imd-findings.json`, ranked by severity.\n\n- **High, grant registration is unreachable through the API.** The new onchain-revocation guard in `grantRegister` calls the adapter's grant reader before registration. The contract's `grant(id)` getter returns a zeroed struct for an unregistered id, and the adapter turns that into `signal_grant_not_found`. So every first registration of a freshly owner-signed grant is rejected, which blocks the documented lifecycle and every later record. Reproduced on real Anvil with the real contracts and adapter: the adapter's own `registerGrant` accepts the signature, the API method throws. The unit test misses it because its mock reader always returns an unrevoked grant.\n- **Medium, scheduler finalizes a confirmed write as a failed run.** Confirmed and reverted engine rows now count as resolved. If the pipeline's hash hand-off to the API fails once past the one-hour run deadline, which is routine since the engine only confirms after finality, the run is marked failed, the lane advances, and the operation never receives its transaction hash. Reproduced with the scheduler and an in-memory store.\n- **Low, discovery mislabels permanent admission failures.** The catch-all maps coded, non-transient errors such as a changed factory code hash, wrong chain id, invalid artifact evidence or a missing artifact to a \"dependency unavailable\" outage. Writes still fail closed with the precise code, so the impact is diagnostic.\n\n**Checked and found sound:** the replacement-evidence match in `reconcile` lines up field by field with what the transaction engine actually writes, cancelled rows without broadcast can never carry an attached hash, the HTTP layer maps uncoded RPC errors to a generic 503 so no RPC detail leaks over the wire, and the grant and record expiry margins are consistent between the adapter, the pipeline clamp and the contract ceiling. Existing targeted suites pass as a baseline. Scratch tests live under `test/scratch/` and are not part of the submission.","treeHash":null,"usage":{"cachedInputTokens":2103301,"inputTokens":610,"model":"claude-fable-5-1","outputTokens":46154,"runtime":"claude","turns":46,"wallClockMs":732419}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"03f15d1296244279","findings":[{"citation":"resolved","description":"The new on-chain revocation guard in grantRegister calls chain.grant() for the grant that is about to be registered. The real adapter (adapters/signal-read.mjs:69-74) implements grant() as `requireThat(value?.terms && (!zero(value.terms.publisher) || value.revoked), 'signal_grant_not_found')`: the zero-publisher sentinel means 'not found' unless the slot is already revoked. In the documented flow (docs/signals.md steps 2-3, docs/pulse-walkthrough.md:149-156) signal_grant_register is called after signal_grant_prepare and BEFORE the publisher sends the registration transaction, so grants[id].terms.publisher is still address(0) and revoked is false. chain.grant() therefore throws signal_grant_not_found (HTTP 404 per interfaces/http.mjs:28) on every genuine registration; the only grants that pass the guard are ones already registered on chain (a no-op) or already revoked (then rejected by the guard). The unit test in test/signals.test.mjs mocks chain.grant to always return a found grant, and the Anvil lifecycle test calls the adapter's registerGrant directly, so neither exercises this path. Boundary: the zero-address sentinel has two meanings in the read path ('absent' in grant()/latest() versus 'not yet registered' in grantRegister). Fix: resolve on-chain state without throwing on absence, e.g. read the raw grant in grantRegister and treat {publisher:0, revoked:false} as unregistered (allowed) and revoked===true as inactive, or add an adapter method such as grantRevoked(signal,id) that returns the revoked flag for an empty slot.","line":195,"path":"src/signals/index.mjs","reproduction":"Real adapter on Anvil (chain id 1), script test/scratch/grant-register-anvil.test.mjs: deploy ClausApprovalExecutor + ClausPulseModule, register a project, deploy ClausSignalFactory, create the store through signalChain.prepareStore/observe, then: (1) terms={publisher,maximumWrites:3,minimumInterval:1,expiresAt:head+86400,nonce:'14'}; grant=await signalChain.prepareGrant(signal,binding,terms); `await signalChain.grant(signal,grant.grantId)` rejects with code signal_grant_not_found, while `signalChain.registerGrant(signal,binding,grant,ownerSignature)` resolves with the registration transaction (adapter alone is fine). (2) Wire createSignals with chain={...createSignalRouter({mainnet:signalChain}),validateAdmission:async()=>{},validateStoreBuild:async()=>{}} and the same store rows the unit fixture uses; op=await api.grantPrepare('owner','p',{...terms,nonce:'15',idempotencyKey:'grant-15'}) -> state awaiting_signature; sig=await owner.signTypedData(op.typedData); api.grantRegister('owner','p',{operationId:op.id,signature:sig}). Expected: {state:'prepared', transaction:{from:publisher,...}}. Actual: throws Error signal_grant_not_found from adapters/signal-read.mjs:72 via src/signals/index.mjs:195. Run: `cd contracts && forge build && cd .. && npm ci && node --test test/scratch/grant-register-anvil.test.mjs` (the test spawns anvil on 127.0.0.1:8599).","severity":"high","snippet":"      requireThat(!(await chain.grant(await signal(projectId),row.value.grantId)).revoked,'signal_grant_inactive');","title":"grantRegister reads the on-chain grant before it exists, so every first-time grant registration is rejected with signal_grant_not_found"},{"citation":"resolved","description":"grantPrepare (src/signals/index.mjs:182) admits expiresAt in the inclusive server-clock range [floor(now/1000)+60, floor(now/1000)+30*86400], which is what docs/signals.md:31 (\"30 days\") and the openapi description advertise. The adapter then re-checks the same value against the LATEST block timestamp with a different comparator and clock: exclusive `> head+60` and `<= head+30d`. Two boundary × invariant seams follow. (a) On any live chain head.timestamp is normally behind the server clock by 0-12s (nothing was mined this second), so the documented maximum now+30d fails `<= head+30d` by the lag. (b) Whenever head.timestamp >= the server second (a block mined this second, or a few seconds of clock skew, both routine on mainnet) the documented minimum now+60 fails `> head+60`. The rejection code signal_grant_expiry is thrown through the HTTP layer but is absent from the errors map in interfaces/http.mjs:2-39; errorStatus() falls back to 503 because the code does not match the /^(invalid_|...)/ 400 regex, and errorBody() then replaces the code with 'service_unavailable', category 'availability', retryable:true. A client that follows the documented range therefore sees a deterministic validation failure as a transient outage and retries it forever; operators monitoring 503s see a false dependency outage, which is exactly what this commit's discovery change set out to isolate. Fix: map signal_grant_expiry to 400 (or 409) in interfaces/http.mjs, and make the two bounds consistent, e.g. have grantPrepare validate against the same head timestamp the adapter uses, or derive the API range as [head+61, head+30d] and document it, or relax the adapter upper bound to head+30d+(now_s-head) so the contract's registration-time check `expiresAt <= block.timestamp + 30 days` remains the binding limit.","line":135,"path":"adapters/signal-read.mjs","reproduction":"Real adapter on Anvil, script test/scratch/grant-expiry-boundaries.test.mjs (same deployment as the first finding; createSignals wired to the real createSignalRead with only artifact validation stubbed). Case 1: mine the finality blocks with 0s spacing so head.timestamp ~= wall clock, wait 3s so the head lags the server by ~3s, then api.grantPrepare('owner','p',{publisher,maximumWrites:3,minimumInterval:1,expiresAt:Math.floor(Date.now()/1000)+30*86400,nonce:'21',idempotencyKey:'max'}). Expected: an awaiting_signature operation (value is inside the documented and API-validated range). Actual: rejects with code 'signal_grant_expiry'; errorStatus(error)===503 and errorBody(error) deep-equals {error:'service_unavailable',category:'availability',retryable:true}. Case 2: evm_setNextBlockTimestamp(floor(Date.now()/1000)+5) then mine one block, then grantPrepare with expiresAt:Math.floor(Date.now()/1000)+60 (documented minimum). Expected: accepted. Actual: 'signal_grant_expiry', status 503. Concrete numbers for case 1: wall=1760400000, head=1760399997, expiresAt=1760400000+2592000=1762992000; adapter bound head+2592000=1762991997 < expiresAt -> rejected. Run: `node --test test/scratch/grant-expiry-boundaries.test.mjs` (spawns anvil on 127.0.0.1:8598).","severity":"medium","snippet":"      requireThat(terms.expiresAt>Number(head.timestamp)+60&&terms.expiresAt<=Number(head.timestamp)+30*86400,'signal_grant_expiry');","title":"Both ends of the documented grant expiresAt range are rejected by the chain-clock bound, and the new signal_grant_expiry code has no HTTP mapping so clients receive a retryable 503 service_unavailable"},{"citation":"resolved","description":"The 60s chain-clock margin added at adapters/signal-read.mjs:135 runs only at signal_grant_prepare. The step that actually emits the transaction the publisher will broadcast, grantRegister -> chain.registerGrant (adapters/signal-read.mjs:143-150), checks owner, authorityGeneration and the signature but never consults chain time; the API-side guard on this line compares the grant's expiresAt only against the server clock with no margin. The grant operation row lives for 24h (expiresAt: now()+86400000 at src/signals/index.mjs:103) while a grant may be prepared with as little as 61s of life, so there is an unbounded window in which a signed-but-expired grant is still turned into a 'prepared' intent. ClausSignalStore.registerGrant reverts with InvalidGrant when `g.expiresAt <= block.timestamp` (contracts/src/ClausSignalStore.sol:107-110), so the intent can only burn the publisher's gas. docs/signals.md:78 states the margin is not an inclusion guarantee; this case is not about the margin but about an already-expired grant being handed out as a valid intent. Fix: in registerGrant (or before calling it in grantRegister) read the latest block and require terms.expiresAt > head.timestamp + 60, returning signal_grant_expiry, mirroring the prepare-time check.","line":192,"path":"src/signals/index.mjs","reproduction":"Case 3 of test/scratch/grant-expiry-boundaries.test.mjs on Anvil: terms={publisher,maximumWrites:3,minimumInterval:1,expiresAt:head+61,nonce:'23'}; grant=await signalChain.prepareGrant(signal,binding,terms) succeeds (head+61 > head+60). Mine 70 blocks at 1s so head >= terms.expiresAt. reg=await signalChain.registerGrant(signal,binding,grant,await owner.signTypedData(grant.typedData)). Expected: rejection (signal_grant_expiry or signal_grant_inactive) because the grant is already expired on chain. Actual: resolves with reg.transaction={from:publisher,to:store,data:registerGrant(...)}. Sending that transaction from the publisher with gas:300000 yields a receipt with status 'reverted'. On the API path the same server-clock-only guard on this line passes whenever the server clock is behind chain time, but src/signals/index.mjs:195 currently throws signal_grant_not_found first (first finding), so the API path was demonstrated through the adapter call grantRegister delegates to.","severity":"low","snippet":"      requireThat(row.value.expiresAt > now() && row.value.input.expiresAt * 1000 > now(), 'signal_operation_expired');","title":"grantRegister prepares a registration intent for a grant whose expiresAt has already passed on chain, which is guaranteed to revert with InvalidGrant and waste the publisher's gas"}],"hash":"6d3495d9c24fc8176831ea01db4971474937fb0040c018f8da00dbc24f420778","nodeId":"a097ee4a-55e2-4144-8c67-11a0e72dcb55","outcome":"completed","summary":"The review is complete. The findings file holds three substantiated defects, all in the changed runtime delta, each reproduced on a real Anvil chain through the real `createSignalRead` adapter. No tracked file was modified.\n\n**Findings written to `.imd-findings.json`**\n\n- **High, `src/signals/index.mjs:195`.** The new on-chain revocation guard in `grantRegister` calls the adapter's `grant()` for a grant that has not been registered yet. That helper treats a zero publisher with `revoked=false` as \"not found\" and throws. In the documented prepare, sign, register flow the grant is always unregistered at this point, so every first-time registration fails with a 404. The unit test mocks `chain.grant` to always succeed and the lifecycle test bypasses the API, which is why 466 passing tests did not catch it. Reproduction: `test/scratch/grant-register-anvil.test.mjs`.\n\n- **Medium, `adapters/signal-read.mjs:135`.** The API validates `expiresAt` against the server clock inclusively, then the adapter re-validates it against the latest block timestamp exclusively. With the head lagging the server by a few seconds the documented maximum of 30 days is rejected, and with the head at or ahead of the server second the documented minimum of 60 seconds is rejected. The new `signal_grant_expiry` code is missing from the HTTP error map, so the client receives a retryable 503 `service_unavailable`, turning a deterministic validation rejection into an apparent outage. Reproduction: cases 1 and 2 of `test/scratch/grant-expiry-boundaries.test.mjs`.\n\n- **Low, `src/signals/index.mjs:192`.** The 60-second chain-clock margin exists only at prepare. Registration checks the server clock with no margin, so a grant already expired on chain is still handed out as a prepared intent that reverts with `InvalidGrant` and costs the publisher gas. Reproduction: case 3 of the same scratch test.\n\n**What was checked and found sound.** The scale handling in the pipeline's grant clamp, the ms and seconds `Math.min` operations, the 900s and 1000ms freshness margins, the record validity bounds against the contract's `publish` checks, the scheduler's unresolved-state set against the engine's actual states, and the replacement-evidence matching in `reconcile` all hold under the inputs I traced. The RPC-outage, mismatched replacement, revoked registration, restart and unknown-broadcast cases in the repo's own tests pass.\n\n**Limits.** Scratch tests stub only artifact validation, since the admission path needs the project's own compiler output. The Anvil lifecycle test in the repo stayed skipped because its environment variables are not provided here. Solidity was not re-audited per the task scope.","treeHash":null,"usage":{"cachedInputTokens":2549295,"inputTokens":706,"model":"claude-fable-5-1","outputTokens":55290,"runtime":"claude","turns":38,"wallClockMs":898925}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"1559912e747bbcd4","findings":[{"citation":"resolved","description":"Commit 7b62bd0 added an onchain revocation pre-check to grantRegister that calls chain.grant() for the grant being registered. In production chain is createSignalRouter -> createSignalRead, whose grant() helper (adapters/signal-read.mjs:72) throws 'signal_grant_not_found' whenever the store returns a GrantState with a zero publisher and revoked == false. ClausSignalStore.grant(id) returns grants[id] verbatim, and a grant that has not been registered yet is exactly that zero struct (contracts/src/ClausSignalStore.sol:125). The pre-check therefore rejects the normal case: a signed, unrevoked, not-yet-registered grant. The only grants that pass are ones already registered (where registerGrant is a no-op repeat) or ones pre-revoked onchain (which then fail the .revoked check). The unit fixture in test/signals.test.mjs masks this because its mock chain.grant always returns {revoked:false}; the Anvil lifecycle test calls signalChain.registerGrant directly and never goes through signals.grantRegister. Net effect: the hosted API flow signal_grant_prepare -> signal_grant_register (docs/pulse-walkthrough.md step 4, docs/signals.md step 3) is broken for every new grant on Sepolia and Mainnet since this commit; no publisher grant can be registered through the API, so no record can be published by the hosted path. This is a liveness regression introduced by this delta, not a permission bypass.","line":195,"path":"src/signals/index.mjs","reproduction":"State: a verified project signal store on chain; owner has called signal_grant_prepare and signed the typed grant; the grant id is not registered and not revoked onchain (ClausSignalStore.grant(id) returns {terms:{publisher:0x0,...},used:0,lastWrite:0,revoked:false}). Input: POST /v1/projects/{projectId}/signal/grants/register {operationId, signature}. Expected: operation returned with state 'prepared' and the registerGrant transaction intent for the publisher to sign. Actual: 404 'signal_grant_not_found' thrown from adapters/signal-read.mjs:72 via src/signals/index.mjs:195 before chain.registerGrant is reached; the operation stays 'awaiting_signature' until it expires. Reproduced by test/scratch/grant-register-fresh.test.mjs (createSignals wired to createSignalRead({client: mock node returning the zero GrantState}).grant): fails on this tree with code 'signal_grant_not_found'. Fix: treat 'signal_grant_not_found' from chain.grant as 'not registered, not revoked' in grantRegister (catch that code and continue), or expose a raw grant-state read in the adapter for this pre-check; keep rejecting only when revoked === true.","severity":"high","snippet":"      requireThat(!(await chain.grant(await signal(projectId),row.value.grantId)).revoked,'signal_grant_inactive');","title":"grantRegister now reads the grant through the real adapter before registration, so every fresh grant fails with signal_grant_not_found"},{"citation":"resolved","description":"Before this commit any signed engine row kept the lane (terminal=!signed&&...). Now only rows outside confirmed/reverted/cancelled do. The engine row reaching 'confirmed' or 'reverted' does not mean the API operation has been reconciled: the pipeline attaches the engine hash to the API operation only through call('signal_submit') on the same step (reference/pulse-pipeline.mjs:45), and a failed signal_submit throws out of step() into this catch. With the row already 'confirmed' and the thrown error carrying no code (network failure -> 'reference_unavailable'), unresolved is false, so once run.expiresAt (at most one interval, 1h) has passed the job is finished as 'failed' with no transactionHash, the next slot is scheduled, and nothing ever retries signal_submit for that run. The signal-operations row for that record stays 'prepared' with no transactionHash until it expires, no signal.record_confirmed event is emitted, signal_operation never shows the confirmed receipt, and worker-health reports a failed run for a write that actually landed. The same applies to a 'reverted' row: the run is marked failed with error 'reference_unavailable' instead of 'signal_transaction_reverted' and the API operation never learns the hash. Recovery requires an operator to find the hash in chain-transactions and call signal_submit by hand. Sequence continuity for later runs is unaffected because the pipeline reads signal_latest from chain.","line":49,"path":"reference/pulse-scheduler.mjs","reproduction":"State: reference-pulse scheduler run R with run.write.operation.id = X; chain-transactions row 'signal-record:X' in state 'confirmed' (engine broadcast and finalized); the API origin is unreachable so client.call('signal_submit') rejects with a plain Error (no code). Steps: tick() once -> 'unavailable' (retry); advance now() past R.expiresAt (intervalMs later); tick() again. Expected (and pre-7b62bd0 behaviour): the lane is still held and the hash keeps being retried against the API ('unavailable'), since the confirmed hash has not been attached to the API operation. Actual: jobs.finish(state:'failed', result.run without transactionHash), tick returns {state:'failed', error:'reference_unavailable'}, a new slot is scheduled, and signal-operations X remains 'prepared' with no transactionHash. Reproduced by test/scratch/scheduler-confirmed-orphan.test.mjs, which fails on this tree with actual 'failed' vs expected 'unavailable'. Fix: treat a confirmed/reverted engine row as resolved only once the API operation carries that hash (e.g. also read signal-operations X and require transactionHash === signed.value.hash before allowing terminal), or have the pipeline persist run.transactionHash from the engine before calling signal_submit and let the scheduler keep retrying while run.transactionHash is unattached.","severity":"low","snippet":"   const unresolved=signed&&!['confirmed','reverted','cancelled'].includes(signed.value.state);","title":"Scheduler releases a confirmed hosted write as a failed run when signal_submit keeps failing past run expiry, leaving the API operation without its hash"},{"citation":"resolved","description":"The new catch-all only special-cases three codes and maps every other error to admission 'unavailable' with a fixed reason. admission() can also fail with deterministic, operator-actionable codes that are not RPC outages: 'signal_factory_changed' (deployed factory bytecode or executor no longer matches network-capabilities signal:<chainId>), 'signal_evidence_invalid' (factory artifact does not reproduce the deployed code), 'artifact_not_found' (factory artifact missing from artifact-store), 'wrong_chain', and 'signal_chain_unavailable' (no mainnet client configured). All of these now look identical to a flaky RPC in the public capabilities payload and worker-health consumers, so a misconfigured or tampered profile is advertised as 'come back later' rather than being surfaced to the operator. Writes still fail closed (admission() is rethrown on the write paths), so this is an observability gap, not a bypass. The intent of not leaking RPC error text is sound; the fix is to keep the generic reason for unknown/transport errors but pass through the project's own coded, non-sensitive admission failures (or at least distinguish 'misconfigured' from 'unavailable').","line":165,"path":"src/signals/index.mjs","reproduction":"State: network-capabilities signal:1 enabled, reviewed and assessed, but factoryCodeHash differs from keccak256 of the code at config.factory (real adapter validateAdmission throws requireThat(...,'signal_factory_changed')). Input: GET capabilities (access.discovery -> signals.discovery(1)). Expected: a distinguishable, non-transient admission state/reason such as 'signal_factory_changed'. Actual: {chainId:1, profile:'claus-signal-v1', admission:'unavailable', reason:'signal_dependency_unavailable'}. Shown by test/scratch/discovery-misconfig.test.mjs, which passes on this tree by asserting the current (collapsed) output.","severity":"info","snippet":"        return {chainId,profile:'claus-signal-v1',admission:'unavailable',reason:'signal_dependency_unavailable'};","title":"discovery reports permanent admission faults (factory code mismatch, missing artifact, evidence invalid) as a transient 'signal_dependency_unavailable'"}],"hash":"b5fd51840929644572d35962e00165544d93089cadf4b71bb049dd7d0579a46c","nodeId":"6ff966a2-91f5-43d7-a9e1-e22cfaedfbef","outcome":"completed","summary":"Review complete. Findings are written to `.imd-findings.json`, with reproductions under `test/scratch/`.\n\n**Scope reviewed:** the runtime delta between 8b03d35 and HEAD only: `src/signals/index.mjs` (discovery isolation, grant registration revocation checks, replacement evidence in reconcile), `adapters/signal-read.mjs` (chain-time grant ceiling and 60s inclusion margins), `reference/pulse-pipeline.mjs` (finalized grant read and expiry clamp), `reference/pulse-scheduler.mjs` (unresolved-row retention), plus the engine code those paths depend on. The existing targeted suites pass locally. Solidity, permissions and intake were not re-audited.\n\n**Findings**\n\n- **High. Grant registration through the API is broken for every fresh grant.** The new onchain pre-check in `grantRegister` calls the real adapter's grant read, which throws `signal_grant_not_found` for any grant id whose store entry is the zero struct. An unregistered, unrevoked grant is exactly that struct, so the walkthrough's `signal_grant_register` step fails before the intent is built. The unit fixture masks it with a mock that always returns `revoked:false`, and the Anvil test bypasses the API method. `test/scratch/grant-register-fresh.test.mjs` fails on this tree with that code.\n- **Low. A confirmed hosted write can be dropped as a failed run.** The scheduler now treats a confirmed engine row as resolved, but the hash only reaches the API operation through `signal_submit` on the same step. If that call keeps failing past run expiry, the job finishes as failed, the lane is released, and the operation stays `prepared` with no hash and no confirmation event. The pre-delta code retained the lane here. `test/scratch/scheduler-confirmed-orphan.test.mjs` shows `failed` where `unavailable` was expected.\n- **Info. Discovery collapses permanent admission faults into a transient reason.** A factory code mismatch, invalid evidence, missing artifact or absent mainnet client all surface as `signal_dependency_unavailable`. Writes still fail closed, so this is observability only.\n\n**Checked and found sound:** replacement evidence is only accepted from the internal engine row and requires exact hash, chain, sender, target, calldata and zero value; `signal_submit` cannot inject it. Revoked grants are rejected at both API and chain level before a registration intent exists. The 60-second margins and the 30-day ceiling are checked against the latest block, and the pipeline clamps publication and record validity to the finalized grant. Unknown and prepared engine rows still hold the lane across expiry and restart.","treeHash":null,"usage":{"cachedInputTokens":1411798,"inputTokens":418,"model":"claude-fable-5-1","outputTokens":32315,"runtime":"claude","turns":40,"wallClockMs":528464}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"8eebc53449bafe7b","findings":[{"citation":"resolved","description":"Added in 7b62bd0 (absent at 8b03d35). The onchain-revocation guard calls chain.grant() for the grant that is about to be registered. In production chain is createSignalRouter -> createSignalRead, whose grant() helper (adapters/signal-read.mjs:69-74, public method :89-94) does `requireThat(value?.terms && (!zero(value.terms.publisher) || value.revoked), 'signal_grant_not_found')` at the finalized block. ClausSignalStore.grant(bytes32) returns grants[id] verbatim (contracts/src/ClausSignalStore.sol:125), and a grant that has not been registered is the zero struct: publisher address(0), revoked false. The guard therefore throws before chain.registerGrant is reached for exactly the documented case (docs/signals.md step 3, docs/pulse-walkthrough.md:156: signal_grant_prepare -> owner signs -> signal_grant_register -> publisher sends). The only ids that pass are grants already registered and finalized (a no-op repeat) or already revoked onchain (then rejected as signal_grant_inactive). Through the HTTP layer the code maps to 404 (interfaces/http.mjs). Net effect: no new publisher grant can be registered through the hosted API on Mainnet or Sepolia since this commit, which blocks every record publication by a new publisher (prepareRecord requires an active grant). This is a liveness regression introduced by this delta, not a permission bypass; writes still fail closed. The unit test added in this commit masks it because the fixture mock chain.grant() unconditionally returns {revoked:false}, and the Anvil lifecycle test calls signalChain.registerGrant directly, never signals.grantRegister. Minimal fix preserving the intended check: treat signal_grant_not_found as \"not registered, not revoked\", e.g. `const g=await chain.grant(s,row.value.grantId).catch(e=>{if(e.code!=='signal_grant_not_found')throw e;return {revoked:false};}); requireThat(!g.revoked,'signal_grant_inactive');` (a pre-revoked slot still returns revoked:true from the adapter because the contract keeps revoked=true with a zero publisher), or expose a raw grant-state read in the adapter for this pre-check. Merged from four specialist reports (economics, permissions, flow, math) of the same defect.","line":195,"path":"src/signals/index.mjs","reproduction":"State: real Anvil (chain id 1) with real ClausApprovalExecutor, ClausPulseModule, ClausSignalFactory and ClausSignalStore from `forge build`; real adapters/signal-read.mjs behind the real createSignalRouter; real src/signals/index.mjs; only artifact-evidence validation stubbed. Steps: signal_store_prepare -> owner sends -> anvil_mine 96 -> signal_submit => confirmed; signal_grant_prepare {publisher, maximumWrites:3, minimumInterval:1, expiresAt: head+86400, nonce:\"14\"} => awaiting_signature; owner signs grant.typedData; onchain grant(grantId) is {publisher:0x0, revoked:false}. Input: grantRegister(\"owner\",\"p\",{operationId:grant.id, signature}). Expected (docs/signals.md step 3): state \"prepared\" with the publisher's registerGrant transaction intent. Actual: rejects with code signal_grant_not_found thrown from adapters/signal-read.mjs:72 via src/signals/index.mjs:195; the operation stays awaiting_signature. Control in the same test: signalChain.registerGrant(signalRow,binding,grant,signature) resolves with transaction.from === publisher and sending it registers the grant onchain, proving line 195 is the sole blocker. Run from the repository root after `cd contracts && forge build && cd .. && npm ci`. Each test asserts the CURRENT (defective) behaviour, so it passes on this tree and must FAIL once the defect is fixed. Command: `node --test test/scratch/grant-register-anvil.test.mjs` (spawns anvil on 127.0.0.1:8599; the grant-registration assertions are the first half of the test, the expiry cases below reuse the fixture). Test source:\n\nimport test from 'node:test';\nimport assert from 'node:assert/strict';\nimport {readFile} from 'node:fs/promises';\nimport {spawn} from 'node:child_process';\nimport {createPublicClient,createWalletClient,http,keccak256,encodeAbiParameters,stringToHex} from 'viem';\nimport {generatePrivateKey,privateKeyToAccount} from 'viem/accounts';\nimport {createSignalRead,createSignalRouter} from '../../adapters/signal-read.mjs';\nimport {createChainRead} from '../../adapters/chain-read.mjs';\nimport {createSignals} from '../../src/signals/index.mjs';\nimport {openStore} from '../../src/storage/index.mjs';\nimport {digest,requireThat} from '../../src/shared/index.mjs';\nimport {errorStatus,errorBody} from '../../interfaces/http.mjs';\n\nconst PORT=8599;\nasync function startAnvil(){\n const child=spawn('anvil',['--chain-id','1','--port',String(PORT),'--silent'],{stdio:'ignore'});\n const client=createPublicClient({transport:http(`http://127.0.0.1:${PORT}`,{retryCount:0}),cacheTime:0,pollingInterval:50});\n for(let i=0;i<100;i++){try{await client.getChainId();return {child,client};}catch{await new Promise(r=>setTimeout(r,100));}}\n throw new Error('anvil did not start');\n}\n\ntest('grantRegister through the API rejects every fresh grant with signal_grant_not_found; adapter alone succeeds',{timeout:180000},async t=>{\n const {child,client}=await startAnvil();t.after(()=>child.kill());\n const url=`http://127.0.0.1:${PORT}`;\n const [owner,claus,publisher]=Array.from({length:3},()=>privateKeyToAccount(generatePrivateKey()));\n for(const a of [owner,publisher])await client.request({method:'anvil_setBalance',params:[a.address,'0x56bc75e2d63100000']});\n const wallet=a=>createWalletClient({account:a,transport:http(url)});\n const wait=async hash=>{const r=await client.waitForTransactionReceipt({hash});assert.equal(r.status,'success');return r;};\n const send=async(tx,signer)=>{assert.equal(tx.from,signer.address.toLowerCase());return wait(await wallet(signer).sendTransaction({chain:null,to:tx.to,data:tx.data,value:0n}));};\n const final=()=>client.request({method:'anvil_mine',params:['0x60','0x1']});\n const out=async name=>JSON.parse(await readFile(new URL(`../../contracts/out/${name}.sol/${name}.json`,import.meta.url)));\n const [native,factoryOut,pulse]=await Promise.all(['ClausApprovalExecutor','ClausSignalFactory','ClausPulseModule'].map(out));\n const deploy=async(abi,bytecode,args)=>(await wait(await wallet(owner).deployContract({chain:n","severity":"high","snippet":"      requireThat(!(await chain.grant(await signal(projectId),row.value.grantId)).revoked,'signal_grant_inactive');","title":"grantRegister reads the grant through chain.grant() before it exists onchain, so every first-time grant registration is rejected with signal_grant_not_found"},{"citation":"resolved","description":"grantPrepare (src/signals/index.mjs:182) validates expiresAt against the SERVER clock with an inclusive range [floor(now/1000)+60, floor(now/1000)+30*86400], which is what docs/signals.md (\"30 days\") and the signal_grant_prepare openapi description advertise. The adapter added in this commit re-checks the same value against the LATEST block timestamp with a different comparator: exclusive `> head+60` and `<= head+30d`. (a) Between blocks head.timestamp normally lags the server clock by 0-12 s, so the documented maximum now+30d exceeds head+30d and is rejected. (b) Whenever head.timestamp >= the server second (a block mined this second, or a few seconds of skew, both routine) the documented minimum now+60 fails `> head+60`. The rejection code signal_grant_expiry is new in this commit and absent from the errors map in interfaces/http.mjs:2-39; errorStatus() falls back to 503 because the code does not match the 400 regex, and errorBody() replaces it with {error:\"service_unavailable\",category:\"availability\",retryable:true}. A client following the documented range thus receives a deterministic validation failure labelled as a transient outage and will retry forever; operators see false dependency outages, the very thing this commit's discovery change set out to isolate. Fix: map signal_grant_expiry to 400/409 in interfaces/http.mjs, and make the two bounds consistent (validate grantPrepare against the same head timestamp the adapter uses, or document/derive the API range as (head+60, head+30d]).","line":135,"path":"adapters/signal-read.mjs","reproduction":"Same Anvil fixture as the first finding, API wired to the real adapter, with the API server clock set relative to the chain head. Case 1: server clock = head+3 s (head lags, the normal case); signal_grant_prepare {publisher, maximumWrites:3, minimumInterval:1, expiresAt: floor(serverNow/1000)+30*86400, nonce:\"21\"}; this passes the API range check at src/signals/index.mjs:182. Expected: awaiting_signature operation. Actual: rejects with code signal_grant_expiry; errorStatus(error)===503 and errorBody(error) deep-equals {error:\"service_unavailable\",category:\"availability\",retryable:true}. Concrete numbers: head=H, serverNow=H+3, expiresAt=H+3+2592000 > adapter bound H+2592000. Case 2: server clock = head (block mined this second); expiresAt: floor(serverNow/1000)+60 (documented minimum). Expected: accepted. Actual: signal_grant_expiry (H+60 is not > H+60). The pure HTTP-mapping part is also shown standalone by the first test in test/scratch/grant-register-margin.test.mjs. Run from the repository root after `cd contracts && forge build && cd .. && npm ci`. Each test asserts the CURRENT (defective) behaviour, so it passes on this tree and must FAIL once the defect is fixed. Command: `node --test test/scratch/grant-register-anvil.test.mjs` (cases 1 and 2 are the \"Expiry boundary cases\" section; source is in the first finding) and `node --test test/scratch/grant-register-margin.test.mjs`. Source of the latter:\n\nimport test from 'node:test';\nimport assert from 'node:assert/strict';\nimport {openStore} from '../../src/storage/index.mjs';\nimport {createSignals} from '../../src/signals/index.mjs';\nimport {digest,requireThat} from '../../src/shared/index.mjs';\nimport {errorStatus,errorBody} from '../../interfaces/http.mjs';\nconst address=c=>'0x'+c.repeat(40),word=c=>'0x'+c.repeat(64);\n\ntest('signal_grant_expiry has no HTTP mapping and is returned as a retryable 503',()=>{\n const error=Object.assign(Error('signal_grant_expiry'),{code:'signal_grant_expiry'});\n assert.equal(errorStatus(error),503);\n assert.deepEqual(errorBody(error),{error:'service_unavailable',category:'availability',retryable:true});\n});\n\ntest('grantRegister hands out a registration intent for a grant with 1 second of life left (no 60s margin)',async t=>{\n const store=openStore(':memory:');t.after(()=>store.close());\n const f={now:1800000000000};\n const binding={chainId:1,profile:'claus-readonly-v1',owner:address('1'),executor:address('2'),target:address('3'),projectKey:word('a')};\n const config={enabled:true,profile:'claus-signal-v1',chainId:1,factory:address('4'),factoryCodeHash:word('b'),executor:binding.executor,\n  artifactProjectId:'review-project',factoryArtifactHash:digest('factory'),storeArtifactHash:digest('store'),reviewCaseId:'review',\n  reviewArtifactHash:digest('report'),feedId:word('c'),schemaHash:word('d'),expiresAt:f.now+86400000};\n const signal={chainId:1,address:address('5'),codeHash:word('e'),factory:config.factory,factoryCodeHash:config.factoryCodeHash,\n  evaluator:binding.target,executor:binding.executor,projectKey:binding.projectKey,feedId:config.feedId,schemaHash:config.schemaHash};\n const transaction={chainId:1,from:address('6'),to:address('5'),data:'0x1234',value:'0'};\n const chain={validateAdmission:async()=>{},validateStoreBuild:async()=>{},assertTrackedStore:async()=>{},\n  prepareStore:async()=>({recovered:true,signal,blockHash:word('f'),blockNumber:'50',finalized:true}),\n  prepareGrant:async(_s,_b,terms)=>({grantId:word('1'),typedData:{message:{publisher:address('6'),expiresAt:terms.expiresAt}},owner:binding.owner}),\n  registerGrant:async()=>({transaction}), // the real adapter also never consults chain time here\n  grant:async()=>({id:word('1'),terms:{publisher:address('0')},revoked:false})};\n await store.transaction(async tx=>{\n  await tx.write('chain-bindings','p',binding,0);await tx.write('network-capabilities','signal:1',config,0);\n  await tx.write('review-cases','review',{state:'reviewed',projectId:config.artifactProjectId,artifactHash:con","severity":"medium","snippet":"      requireThat(terms.expiresAt>Number(head.timestamp)+60&&terms.expiresAt<=Number(head.timestamp)+30*86400,'signal_grant_expiry');","title":"Both documented ends of the grant expiresAt range are rejected by the new chain-clock bound, and signal_grant_expiry has no HTTP mapping so the client gets a retryable 503 service_unavailable"},{"citation":"resolved","description":"Before 7b62bd0 any chain-transactions row for the run's operation kept the run non-terminal (`terminal=!signed&&(...)`), so a hosted write whose signal_submit call to the network API failed kept retrying at 60 s until the hash was journaled. The new rule treats confirmed/reverted/cancelled engine rows as resolved and makes the run terminal on `run.expiresAt<=now()` or a listed code. But the engine hash reaches the API operation only through call(\"signal_submit\") in reference/pulse-pipeline.mjs:45, which runs after publisher.step and throws an uncoded error on transport/5xx failure. The engine reports confirmed only after finality (~13 min on mainnet) while run.expiresAt can be as little as seconds away when the write is prepared (the pipeline checks run.expiresAt>now() just before signal_record_prepare), so a late write is routinely confirmed after run.expiresAt. If every signal_submit attempt from broadcast through that point failed (API origin or its RPC unavailable), one more failure after run.expiresAt makes `terminal=true`: the job finishes \"failed\" with error \"reference_unavailable\" and no transactionHash, the lane advances, and nothing ever retries signal_submit for that run. The signal-operations row stays \"prepared\" without a hash until it expires, no signal.record_confirmed event is emitted, and worker-health reports a failed run for a write that executed onchain; a reverted row is likewise reported as reference_unavailable instead of signal_transaction_reverted. Sequence continuity is unaffected because later runs read signal_latest from chain, and the hash remains in chain-transactions for manual recovery, hence low. Fix: count a confirmed/reverted engine row as resolved only once the API operation carries its hash (read signal-operations for run.write.operation.id and require transactionHash===signed.value.hash, or have the pipeline persist run.transactionHash from the engine before calling signal_submit and keep retrying while it is unattached). Merged from the permissions (low) and flow (medium) reports.","line":49,"path":"reference/pulse-scheduler.mjs","reproduction":"State: in-memory store, createJobs, createPulseScheduler with {startsAt:T, expiresAt:T+14400000, intervalMs:3600000, maximumRuns:4}. Pipeline stub: sets run.write={operation:{id:\"op-\"+run.id}}, checkpoints, writes chain-transactions \"signal-record:op-<run>\" = {state:\"confirmed\", hash:0xab..ab, sender, intent} once, then throws new TypeError(\"fetch failed\") (a failed signal_submit HTTP call: no error.code). Tick 1 at T => {state:\"unavailable\"} (retry). Advance now() to T+3600001 (run.expiresAt passed) and tick again. Expected (behaviour at 8b03d35 and per docs/signals.md \"remains independently reconcilable\"): still \"unavailable\", retried until the hash is journaled. Actual at 7b62bd0: {state:\"failed\", runId: same}, workflow-jobs row state \"failed\", result.error \"reference_unavailable\", result.run.transactionHash undefined; the next tick starts a new runId; signal_submit never succeeded for the confirmed hash. Run from the repository root after `cd contracts && forge build && cd .. && npm ci`. Each test asserts the CURRENT (defective) behaviour, so it passes on this tree and must FAIL once the defect is fixed. Command: `node --test test/scratch/scheduler-confirmed-orphan.test.mjs`. Source:\n\nimport test from 'node:test';\nimport assert from 'node:assert/strict';\nimport {openStore} from '../../src/storage/index.mjs';\nimport {createJobs} from '../../src/jobs/index.mjs';\nimport {createPulseScheduler} from '../../reference/pulse-scheduler.mjs';\n\ntest('confirmed engine row + failing signal_submit past run expiry => run finished failed, hash never journaled',async t=>{\n const store=openStore(':memory:');t.after(()=>store.close());let time=1800000000000,submits=0;\n const jobs=createJobs({store,identity:{authorize:async()=>{}},now:()=>time});\n const config={projectId:'lane',startsAt:time,expiresAt:time+14400000,intervalMs:3600000,maximumRuns:4};\n const pipeline={async step(run,checkpoint){\n  run.write={operation:{id:'op-'+run.id}};await checkpoint(run);\n  const key='signal-record:'+run.write.operation.id;\n  if(!await store.read('chain-transactions',key))await store.transaction(tx=>tx.write('chain-transactions',key,\n   {state:'confirmed',hash:'0x'+'ab'.repeat(32),sender:'0x'+'11'.repeat(20),intent:{chainId:1,to:'0x'+'22'.repeat(20),data:'0x01',value:'0'}},0));\n  submits++;throw new TypeError('fetch failed'); // what a failed signal_submit HTTP call produces: no error.code\n }};\n const tick=()=>createPulseScheduler({store,jobs,pipeline,config,now:()=>time}).tick();\n const first=await tick();assert.equal(first.state,'unavailable');\n time+=3600001; // run.expiresAt passed\n const second=await tick();\n assert.equal(second.runId,first.runId);\n // Expected (pre-7b62bd0 and per docs): still 'unavailable', retried until the hash is journaled.\n // Actual at 7b62bd0:\n assert.equal(second.state,'failed');\n const job=(await store.list('workflow-jobs')).find(r=>r.value.result?.run?.id===first.runId);\n assert.equal(job.value.state,'failed');assert.equal(job.value.result.error,'reference_unavailable');\n assert.equal(job.value.result.run.transactionHash,undefined);\n const third=await tick();assert.notEqual(third.runId,first.runId);\n assert.equal(submits,3);\n});\n","severity":"low","snippet":"   const unresolved=signed&&!['confirmed','reverted','cancelled'].includes(signed.value.state);","title":"Scheduler finishes a run as failed and releases the lane once the engine row is confirmed/reverted even though the hash was never attached to the API operation (signal_submit kept failing)"},{"citation":"resolved","description":"The catch-all added in 7b62bd0 special-cases three not_enabled codes and maps every other error to admission \"unavailable\" with a fixed reason. admission() can also fail with deterministic, operator-actionable, non-sensitive codes that no retry resolves: signal_factory_changed (adapters/signal-read.mjs:42-44: chainId mismatch, factory code hash changed, factory.executor() != config.executor), wrong_chain (:35), signal_evidence_invalid (adapters/signal-evidence.mjs: reviewed factory artifact does not reproduce the deployed code), artifact not_found/corrupt from artifacts.get, and signal_chain_unavailable (router has no client for the chain). All now look identical to an RPC blip in the public capabilities payload (src/access/index.mjs signalProfiles) and to worker-health consumers, so a misconfigured or tampered profile is advertised as \"come back later\" instead of being surfaced; the comment's claim that only \"a chain dependency outage closes this profile\" is not what the code does. Writes still fail closed with the precise code on the write paths, so this is a diagnostic gap, not a bypass. Fix: keep the generic reason for errors without a code (transport/RPC) and for signal_chain_unavailable/wrong_chain, and pass through the project's own coded admission failures (e.g. admission:\"not_enabled\", reason:error.code), which carry no RPC detail. Merged from the permissions (info) and flow (low) reports.","line":165,"path":"src/signals/index.mjs","reproduction":"State: network-capabilities signal:1 enabled, review-cases reviewed, signal-admission-assessments accepted (the unit fixture rows), and chain.validateAdmission that throws requireThat(false, code) for code in [signal_factory_changed, wrong_chain, signal_evidence_invalid, signal_chain_unavailable], or artifacts.get that throws artifact_not_found. Input: signals.discovery(1). Expected: a distinguishable definitive state such as {admission:\"not_enabled\", reason:\"signal_factory_changed\"}. Actual for all five: {chainId:1, profile:\"claus-signal-v1\", admission:\"unavailable\", reason:\"signal_dependency_unavailable\"}. Run from the repository root after `cd contracts && forge build && cd .. && npm ci`. Each test asserts the CURRENT (defective) behaviour, so it passes on this tree and must FAIL once the defect is fixed. Command: `node --test test/scratch/discovery-misconfig.test.mjs`. Source:\n\nimport test from 'node:test';\nimport assert from 'node:assert/strict';\nimport {openStore} from '../../src/storage/index.mjs';\nimport {createSignals} from '../../src/signals/index.mjs';\nimport {digest,requireThat} from '../../src/shared/index.mjs';\nconst address=c=>'0x'+c.repeat(40),word=c=>'0x'+c.repeat(64);\n\nasync function make(t,code,artifactCode){\n const store=openStore(':memory:');t.after(()=>store.close());const now=1800000000000;\n const config={enabled:true,profile:'claus-signal-v1',chainId:1,factory:address('4'),factoryCodeHash:word('b'),executor:address('2'),\n  artifactProjectId:'review-project',factoryArtifactHash:digest('factory'),storeArtifactHash:digest('store'),reviewCaseId:'review',\n  reviewArtifactHash:digest('report'),feedId:word('c'),schemaHash:word('d'),expiresAt:now+86400000};\n await store.transaction(async tx=>{\n  await tx.write('network-capabilities','signal:1',config,0);\n  await tx.write('review-cases','review',{state:'reviewed',projectId:config.artifactProjectId,artifactHash:config.reviewArtifactHash},0);\n  await tx.write('signal-admission-assessments',digest(config),{accepted:true,profileHash:digest(config),reviewArtifactHash:config.reviewArtifactHash,\n   factoryArtifactHash:config.factoryArtifactHash,storeArtifactHash:config.storeArtifactHash},0);\n });\n return createSignals({store,identity:{authorize:async()=>{}},chain:{validateAdmission:async()=>requireThat(false,code)},\n  artifacts:{get:async()=>{if(artifactCode)requireThat(false,artifactCode);return {};}},publicResult:async()=>({}),now:()=>now});\n}\nfor(const code of ['signal_factory_changed','wrong_chain','signal_evidence_invalid','signal_chain_unavailable']){\n test(`permanent admission failure ${code} is reported as a transient outage`,async t=>{\n  const api=await make(t,code);\n  assert.deepEqual(await api.discovery(1),{chainId:1,profile:'claus-signal-v1',admission:'unavailable',reason:'signal_dependency_unavailable'});\n });\n}\ntest('missing factory artifact is reported as a transient outage',async t=>{\n const api=await make(t,'unused','artifact_not_found');\n assert.deepEqual(await api.discovery(1),{chainId:1,profile:'claus-signal-v1',admission:'unavailable',reason:'signal_dependency_unavailable'});\n});\n","severity":"low","snippet":"        return {chainId,profile:'claus-signal-v1',admission:'unavailable',reason:'signal_dependency_unavailable'};","title":"discovery reports permanent admission faults (signal_factory_changed, wrong_chain, signal_evidence_invalid, missing artifact, no chain client) as the transient signal_dependency_unavailable"},{"citation":"resolved","description":"This commit adds a 60 s chain-clock margin at signal_grant_prepare (adapters/signal-read.mjs:135) and at record preparation (:158), and the delta description says it \"requires 60s inclusion margin on grant/record validity\". The step that actually emits the transaction the publisher broadcasts, grantRegister -> chain.registerGrant (adapters/signal-read.mjs:143-150), checks only owner, authorityGeneration and the signature; the API-side guard on this line compares the grant expiresAt against the server clock with zero margin. The grant operation row lives 24 h (src/signals/index.mjs:103) while a grant may have been prepared with 61 s of life, so an intent can be handed out with one second left, or already expired on chain whenever the server clock is behind block time. ClausSignalStore.registerGrant reverts with InvalidGrant when g.expiresAt <= block.timestamp (contracts/src/ClausSignalStore.sol:107-110), so the intent can only burn the publisher's gas and then surfaces as signal_transaction_reverted. Fix: in grantRegister (or registerGrant) read the latest block and require terms.expiresAt > head.timestamp+60, returning signal_grant_expiry, mirroring the prepare-time check. (On this tree the API path is reached only after the first finding is fixed, because line 195 throws first; the chain-time part is shown through the adapter call grantRegister delegates to.)","line":192,"path":"src/signals/index.mjs","reproduction":"API path (mock chain, unit fixture rows, chain.grant returning {revoked:false}): grantPrepare with expiresAt = floor(now/1000)+3600, then set now() = expiresAt*1000-1000 (one second of life left) and call grantRegister(\"owner\",\"p\",{operationId, signature}). Expected: rejection with signal_grant_expiry (margin) like prepare. Actual: returns state \"prepared\" with the registration transaction. Chain path (Anvil, case 3 of test/scratch/grant-register-anvil.test.mjs): terms.expiresAt = head+61; signalChain.prepareGrant succeeds; mine 70 blocks at 1 s so head >= expiresAt; signalChain.registerGrant(signal,binding,grant,ownerSignature) resolves with transaction.from === publisher instead of rejecting; sending that transaction from the publisher with gas 300000 yields a receipt with status \"reverted\". Run from the repository root after `cd contracts && forge build && cd .. && npm ci`. Each test asserts the CURRENT (defective) behaviour, so it passes on this tree and must FAIL once the defect is fixed. Commands: `node --test test/scratch/grant-register-margin.test.mjs` (second test; source in the second finding) and `node --test test/scratch/grant-register-anvil.test.mjs` (case 3; source in the first finding).","severity":"low","snippet":"      requireThat(row.value.expiresAt > now() && row.value.input.expiresAt * 1000 > now(), 'signal_operation_expired');","title":"grantRegister applies no inclusion margin and never consults chain time, so it prepares a registration intent for a grant that is seconds from (or already past) expiry and is guaranteed to revert"}],"hash":"d4ab5c6d919b576224ac57bea616c67683c4ef2be20c6cbd19c7be042b373cdb","nodeId":"b4f4b47f-efc3-40bf-922f-efc1404aed61","outcome":"completed","summary":"The review is complete. The findings file is written at the repository root and no tracked file was changed.\n\n**Outcome.** Five findings survived reproduction, merged from the ten specialist reports. Every one was reproduced with a scratch test that runs green against this tree, meaning the defect is present. Full test sources are embedded in each finding's reproduction so they stand alone.\n\n- **High, `src/signals/index.mjs:195`.** The new onchain-revocation guard in grantRegister calls the adapter's grant read for a grant that has not been registered yet. The adapter throws signal_grant_not_found for a zero publisher slot, so every fresh grant registration through the API is rejected before the registration transaction is prepared. Reproduced on real Anvil with the real adapter and router; the adapter's own registerGrant succeeds in the same test, isolating the line. Four specialists reported this and I merged them into one finding.\n- **Medium, `adapters/signal-read.mjs:135`.** The API validates grant expiry against the server clock with an inclusive 60 s to 30 day range, while the adapter rechecks against the latest block with an exclusive lower bound. Both documented ends get rejected depending on head lag. The new signal_grant_expiry code has no HTTP mapping, so clients receive a retryable 503 service_unavailable for a deterministic validation failure. Reproduced on Anvil plus a direct errorStatus and errorBody check.\n- **Low, `reference/pulse-scheduler.mjs:49`.** A confirmed or reverted engine row now counts as resolved even if signal_submit never attached the hash to the API operation. After run expiry one more failed call finishes the run as failed with no hash. The permissions and flow specialists reported this at low and medium. I kept low because the hash stays in chain-transactions and sequence continuity is unaffected.\n- **Low, `src/signals/index.mjs:165`.** Discovery collapses permanent admission faults such as signal_factory_changed, wrong_chain, signal_evidence_invalid and missing artifacts into the transient signal_dependency_unavailable reason. Writes still fail closed. Merged from two reports.\n- **Low, `src/signals/index.mjs:192`.** grantRegister applies no 60 s inclusion margin and never consults chain time, so it hands out a registration intent for a grant seconds from or past expiry that reverts onchain. Shown through the API path with a mock chain and through the adapter on Anvil with a reverted receipt.\n\n**Dropped or adjusted.** Nothing was dropped outright. The specialists' own scratch files were not present in the tree, so I rewrote each reproduction and ran it myself. No Foundry proof is attached because none of the defects is in Solidity code, which the task confirms is unchanged.\n\n**Not covered.** I did not repeat the full audit or the project's test suite, as the task instructed. The Solidity contracts, permissions model and Mainnet intake were outside the delta and were not re-reviewed.","treeHash":null,"usage":{"cachedInputTokens":1644412,"inputTokens":514,"model":"claude-fable-5-1","outputTokens":33078,"runtime":"claude","turns":37,"wallClockMs":437635}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"357c46e3781993d4","findings":[{"citation":"resolved","description":"This commit adds a pre-registration guard in grantRegister that reads the grant through chain.grant(). With the production adapter (adapters/signal-read.mjs:69-74, exported at :89-94) that call does `requireThat(value?.terms && (!zero(value.terms.publisher) || value.revoked), 'signal_grant_not_found')`. ClausSignalStore.grant(bytes32) (contracts/src/ClausSignalStore.sol:125) returns `grants[id]`, which for any id that has not yet been registered is the zero struct: publisher 0x0, revoked false. A grant being registered through signal_grant_register is by definition not yet registered, so the adapter throws signal_grant_not_found before registerGrant is reached. The only ids that pass the guard are grants that are already registered (idempotent repeat) or that the owner already revoked onchain (which then correctly fails with signal_grant_inactive). The intended case, a fresh signed grant, can never be prepared. The unit fixture masks this because its mock chain.grant unconditionally returns {revoked:false}, and the Anvil lifecycle test calls the adapter's registerGrant directly, bypassing src/signals/index.mjs. Documented step 3 of docs/signals.md and line 156 of docs/pulse-walkthrough.md fail on Mainnet and Sepolia, so no new publisher can be onboarded through the API and the whole signal lifecycle after store creation is blocked. Minimal fix: treat signal_grant_not_found as not revoked here (catch that code, or read the raw GrantState and check only `.revoked`), keeping the signal_grant_inactive rejection for revoked ids.","line":195,"path":"src/signals/index.mjs","reproduction":"State: store registered for project p on chain 1; signal_grant_prepare succeeded (operation in awaiting_signature). Input: signal_grant_register {projectId:'p', operationId:<grant op id>, signature:<any 65-byte hex>} while chain.grant is the real createSignalRead adapter and the RPC returns the zero GrantState for the grant id (what ClausSignalStore returns for an unregistered id). Expected: operation moves to state 'prepared' with the registerGrant transaction (docs/signals.md step 3). Actual: the call rejects with code signal_grant_not_found thrown from adapters/signal-read.mjs:72 via src/signals/index.mjs:195, and the operation stays awaiting_signature forever. Reproduced with test/scratch/grant-register-not-found.test.mjs (node --test): `Error: signal_grant_not_found at grant (adapters/signal-read.mjs:72) ... at async Object.grantRegister (src/signals/index.mjs:195:21)`.","severity":"high","snippet":"      requireThat(!(await chain.grant(await signal(projectId),row.value.grantId)).revoked,'signal_grant_inactive');","title":"grantRegister's new onchain revocation check rejects every first-time grant registration with signal_grant_not_found"}],"hash":"fbb4354e3c57fd77baa71a9a5d814d5e513efa27a347a85b8a8488111c1ae495","nodeId":"4a8840c9-37a1-416d-9d3b-2addb91e6e68","outcome":"completed","summary":"Review complete. The findings file holds one substantiated defect.\n\n**Finding (high, liveness regression in this commit):** the new onchain-revocation guard in `grantRegister` at `src/signals/index.mjs:195` breaks every first-time grant registration on a real chain. It reads the grant through `chain.grant`, and the production adapter throws `signal_grant_not_found` whenever the onchain publisher is zero and the grant is not revoked. That is exactly what the store returns for an id that has not been registered yet, which is the only case `signal_grant_register` exists for. Only already-registered or pre-revoked ids get past the guard. The unit fixture hides it because its mock returns `{revoked:false}` unconditionally, and the Anvil lifecycle calls the adapter's `registerGrant` directly instead of the API. Reproduced with a scratch node test that drives `createSignals` with the real `createSignalRead` over a stub RPC returning the zero struct:\n\n```\nError: signal_grant_not_found\n  at grant (adapters/signal-read.mjs:72)\n  at async Object.grantRegister (src/signals/index.mjs:195:21)\n```\n\nThe minimal fix is to treat `signal_grant_not_found` as \"not revoked\" at that line while keeping the `signal_grant_inactive` rejection for revoked ids.\n\n**Checked and not defective** in the changed scope:\n- Replacement evidence match in `reconcile` lines up with the real engine's cancelled row: same key, preserved original hash, lowercase sender/to/data, chainId and value `'0'`. The engine only writes `replacement` from finalized history, and users cannot write `chain-transactions`.\n- Scheduler now releases the lane on confirmed, reverted and cancelled rows, matching when the engine frees the wallet slot. Unknown and prepared rows still hold it.\n- Pipeline reads the finalized grant once, clamps service expiry, and the terminal `pulse_result_expiring` only fires where nothing has been broadcast.\n- Discovery swallowing RPC errors no longer breaks the whole capabilities endpoint when the Mainnet client is absent, and leaks no RPC detail.\n- The 60 second grant and record margins are consistent with the contract's `expiresAt` and `validUntil` checks.\n\nThe four existing changed test files pass on this commit. Dependencies were installed into the ignored `node_modules` only, and the reproduction lives under `test/scratch/`, which is not kept.","treeHash":null,"usage":{"cachedInputTokens":1487400,"inputTokens":482,"model":"claude-fable-5-1","outputTokens":29547,"runtime":"claude","turns":30,"wallClockMs":443927}}],"verification":[]}