{"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":"817fa54d-443f-4f62-8557-9eade7ffa612","kind":"audit","nodes":[{"acceptedSubmissionHash":"3109ac31a00b3ba462b01aae05049c9cd37ea65a91c110a6911aeac00d6c827d","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":"c6922b39dd980cf381468a3ab8fc33988eff58228bd41c2e9a84a9b126cbedce","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":"fafb18a72a8ce92b3c0382992d9f5a4ffd8f936489f4eb8df466ddb48e1df08e","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":"1cfa9a9efb4009d11e090e44882afe412feffc12d9df9a614fc3c01d095becd4","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":"7b9a8d542a0845b67e3a6aaa3e3304ec0d4ff6fb067d4eb66c8c549d9aa1027f","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 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.\nThree 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.\nreference/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.\nMeaningful 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.\nAssess 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.\n\nThe 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.\n","parentJobId":null,"planHash":"686293b9c90312a2139d49555802f0ab2cb47c6a4b681e3ef1d7cce2cefa746b","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"817fa54d-443f-4f62-8557-9eade7ffa612","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":"50955","feedbackHash":"4243388e2c010ea2e7e531294efee5ccb89c4dacf202d974985085eb9d9fcb2b","nodeKey":"audit_economics","submissionHash":"3109ac31a00b3ba462b01aae05049c9cd37ea65a91c110a6911aeac00d6c827d","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52200","feedbackHash":"d7b6a095c7ff5e889e510a7b019a6dc0223712d16f4c7d89ee6ec5ad3932c48f","nodeKey":"audit_flow","submissionHash":"c6922b39dd980cf381468a3ab8fc33988eff58228bd41c2e9a84a9b126cbedce","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51419","feedbackHash":"d29c23b6661e5ae28f261b2cddbb3e196e849e390e1aab7fc328621e19f919ea","nodeKey":"audit_judge","submissionHash":"fafb18a72a8ce92b3c0382992d9f5a4ffd8f936489f4eb8df466ddb48e1df08e","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51243","feedbackHash":"cf286c1b94ac44d57ee1dde418b664a0ced555ae523b0fd716207f81fd8d37d1","nodeKey":"audit_math","submissionHash":"1cfa9a9efb4009d11e090e44882afe412feffc12d9df9a614fc3c01d095becd4","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51445","feedbackHash":"9b8869bd140e7a639a3aceb72515ff7b9e31b3e717cc63c8a206391881188d98","nodeKey":"audit_permissions","submissionHash":"7b9a8d542a0845b67e3a6aaa3e3304ec0d4ff6fb067d4eb66c8c549d9aa1027f","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"82e6a50ad3139467a5c35eb90d82d6da473f3b548bdf13cf995fc262511a6822","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"76e5f9ed417094cc","findings":[{"citation":"resolved","description":"The new not-found path in grantRegister relies on chain.grant, which reads the grant slot only at the `finalized` block (adapters/signal-read.mjs:89-94), while the expiry recheck two lines later and the registration intent use the `latest` head. For an unregistered digest the finalized slot is all-zero until revocation finality (about 13 minutes on mainnet, 96 blocks in the local fixture), so chain.grant throws signal_grant_not_found and the catch lets registration continue even though `grant(id).revoked` is already true at `latest`. The API then returns a registerGrant transaction. ClausSignalStore.registerGrant only short-circuits when `terms.publisher != 0`, so the chain accepts it: the revoked slot gets terms written, the owner's nonce is burned, and the publisher pays the gas, for a grant that can never publish. Afterwards the operation row carries signatureHash, so repeated grantRegister calls return the prepared transaction even once finality shows revoked:true. Safety is preserved (the grant stays revoked; publish reverts GrantInactive) and API-driven revocations are covered by the signal-revocations marker, so this is a finalized-read design boundary newly reachable through the correction rather than an authority leak. The repository test only exercises the finalized case (it calls final() before grantRegister). Minimal fix preserving the finalized-evidence design: in grantRegister also read the slot at `latest` (or have the adapter's grant() report `revokedAtLatest`) and deny with signal_grant_inactive when either view is revoked; optionally check revocation again before the idempotent replay of a still-unbroadcast intent.","line":202,"path":"src/signals/index.mjs","reproduction":"Local Anvil chain-id 1 with the reviewed factory/store bytes (test/scratch/grant-bounds.test.mjs, probe 3): grantPrepare terms {publisher, maximumWrites:3, minimumInterval:1, expiresAt: head+86400, nonce:'3'} -> grantId G. Owner sends revokeGrant(G) directly (prepareRevoke intent) and the block is mined but NOT finalized (observed: latest 1189, finalized 1125, grant(G).revoked at latest = true). Expected: grantRegister(operationId, ownerSignature) rejects with signal_grant_inactive, as it does once finalized. Actual: it returns {transaction:{to:store,...}}; broadcasting it succeeds (status success, gasUsed 143469), grant(G) now has terms.publisher set, revoked=true, grantNonces[owner][3]=true. After anvil_mine 96 blocks, grantRegister on the same operation still returns the prepared transaction (idempotent replay) instead of signal_grant_inactive.","severity":"low","snippet":"      try { requireThat(!(await chain.grant(await signal(projectId),row.value.grantId)).revoked,'signal_grant_inactive'); }\n      catch(error) { if(error.code!=='signal_grant_not_found')throw error; }","title":"Fresh-grant registration checks revocation at the finalized block but issues the intent against the latest head, so a mined-but-unfinalized onchain revocation is bypassed"},{"citation":"resolved","description":"The clock-bound correction was applied to grants only. recordPrepare (src/signals/index.mjs:240-241) validates observedAt and validUntil against Math.floor(now()/1000) on the API host, then the adapter re-validates 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, category state, retryable:false) although the data is not expired and the contract's own check (observedAt > block.timestamp at the inclusion block) would pass. Not part of the three reviewed changes and fail-closed, so no safety impact; reported as the remaining instance of the inconsistency the previous math pass flagged. 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 surface a distinct non-retryable code for a future-stamped observation.","line":160,"path":"adapters/signal-read.mjs","reproduction":"API now() = 1800000000000 ms; latest head.timestamp = 1799999989 (11 s lag); input observedAt = 1800000000, validUntil = 1800003600. recordPrepare: integer(validUntil, 1800000001, 1800086400) passes and observedAt <= 1800000000 passes. Adapter prepareRecord: input.observedAt (1800000000) <= head.timestamp (1799999989) is false -> throws signal_data_expired. Expected (per docs 'Obtain a fresh observation'): a fresh observation is accepted or rejected with an input-class reason; actual: a fresh observation is rejected as expired.","severity":"info","snippet":"      requireThat(input.validUntil > Number(head.timestamp)+60 && input.observedAt <= Number(head.timestamp) &&","title":"Record preparation still mixes the API host clock and the chain clock, so an observation stamped at wall-clock time fails with signal_data_expired whenever the RPC head lags"},{"citation":"resolved","description":"The new unreportedReceipt guard correctly holds the lane until the API row carries the original hash and a terminal state. Once that is true, unresolved is false, and if the pipeline step threw for any reason (for example the trailing signal_operation call failing during a partial outage) with run.expiresAt <= now(), the run finishes as state 'failed' with error reference_unavailable although the record was published onchain and the API already shows it confirmed with the same hash. The lane is released correctly and the next slot proceeds, so this is observability only (worker-health and the job result record a failed run that actually completed). The repository test encodes this ordering: after the API row is set to confirmed with the original hash, the final tick asserts 'failed'. Pre-existing ordering, unchanged by the correction. Minimal fix: when receiptState and the API row is confirmed with the matching hash, finish the job as complete (recording run.transactionHash) instead of failed.","line":55,"path":"reference/pulse-scheduler.mjs","reproduction":"State: chain-transactions 'signal-record:op' = {state:'confirmed', hash:H}; signal-operations 'op' = {state:'confirmed', transactionHash:H}; run.expiresAt <= now(); pipeline.step throws Error('API unavailable') (e.g. signal_operation read fails). tick() -> unreportedReceipt=false, unresolved=false, terminal=true -> jobs.finish state 'failed', return {state:'failed', error:'reference_unavailable'}. Expected: a run whose write is confirmed onchain and acknowledged by the API is not reported as failed. Exactly the final assertion of test/pulse-reference.test.mjs 'a finalized hosted receipt retains its API handoff across outage and run expiry'.","severity":"info","snippet":"   const unresolved=unreportedReceipt||signed&&!['confirmed','reverted','cancelled'].includes(signed.value.state);","title":"A fully handed-off hosted write is journaled as failed when the final signal_operation read throws after run expiry"}],"hash":"1cfa9a9efb4009d11e090e44882afe412feffc12d9df9a614fc3c01d095becd4","nodeId":"5d141e42-a854-44cd-bfed-fdca6f33292b","outcome":"completed","summary":"Review complete. The findings file holds three substantiated items, all verified against the tree; no source files were changed.\n\n**Outcome: no critical or high regression in the three corrections.** The grant bounds, the not-found path, the HTTP mapping and the scheduler handoff guard behave as described, and I confirmed them on a real local Anvil deployment with the reviewed bytes rather than from the diff alone.\n\nWhat I verified:\n- Unit suites touched by the change pass. The focused signal, pulse, interface, publisher and replacement tests ran 26 of 26 green, and the boundary script passes.\n- The real lifecycle test passes on Anvil chain id 1 with artifacts compiled by the project's own compiler adapter.\n- My own probes show the exact bounds are consistent with the contract. A grant at head plus 30 days and one at head plus 61 seconds both register successfully on-chain, and the arithmetic has no overflow or unit mismatch. The 30-day constant, the strict 60-second margin and the uint64 encoding all line up with `ClausSignalStore`.\n- The scheduler's new guard uses the same shared Postgres store the hosted publisher already reads, so the cross-table read is valid. Precedence of the new boolean expression is correct.\n\nFindings recorded in `.imd-findings.json`:\n- **Low.** The new not-found path checks revocation only at the finalized block while the intent is built against the latest head. I reproduced it: an owner revocation that is mined but not finalized is ignored, the API hands out a registration transaction, and the chain accepts it, burning the owner nonce and the publisher's gas for a grant that stays revoked. Safety holds, so this is a finalized-read design boundary made newly reachable, with a one-line fix suggested.\n- **Info.** The record path still mixes the API host clock and chain clock, the same pattern the grant fix removed. A fresh observation fails with `signal_data_expired` whenever the RPC head lags. Pre-existing and fail-closed.\n- **Info.** A fully handed-off hosted write is journaled as failed if the trailing status read throws after run expiry. The lane is released correctly, so this is observability only, and it is the ordering the new test itself asserts.\n\nScratch material lives under `test/scratch/` and `/tmp/imd-signal-builds`; the local Anvil instance is stopped.","treeHash":null,"usage":{"cachedInputTokens":2397407,"inputTokens":706,"model":"claude-fable-5-1","outputTokens":42621,"runtime":"claude","turns":47,"wallClockMs":695687}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"3c7630b22a73c1fb","findings":[{"citation":"resolved","description":"This is NOT a new defect in the correction; it is the documented finalized-read boundary (docs/signals.md: 'Read finalized grant state', 'API revocation is separate from finalized onchain revocation') that the correction makes reachable for the first time, because registration of an unregistered digest was previously blocked entirely by signal_grant_not_found. chain.grant (adapters/signal-read.mjs:89-94) reads the grant at the 'finalized' block. If the owner revokes the digest directly onchain (bypassing the API revoke route, which would set the signal-revocations marker immediately), the revocation is visible at 'latest' but not yet at 'finalized' for roughly two epochs on mainnet. During that window chain.grant still returns signal_grant_not_found, the new catch lets registration continue, and grantRegister returns a registerGrant intent. ClausSignalStore.registerGrant (contracts/src/ClausSignalStore.sol:94-107) only short-circuits when terms.publisher is already set, so it accepts the call, consumes the owner's grant nonce and stores terms on a digest whose revoked flag is already true; every later publish reverts GrantInactive. Only the owner can trigger this (a privileged action against the documented route), the only loser is the publisher's gas for one transaction (~143k gas) plus the burned owner nonce, and the same owner could achieve the identical outcome by revoking right after registration, so no new economic exposure is created. Recorded so the requester can decide whether grantRegister should additionally consult the latest-block revoked flag (a cheap extra read of grant(id) at blockTag latest, denying when revoked is true) before issuing the intent; no ceiling or authority change is implied.","line":202,"path":"src/signals/index.mjs","reproduction":"Local Anvil (chain-id 1), exact reviewed factory/store bytes compiled through adapters/compiler.mjs; same fixture wiring as test/signal-integration.test.mjs. Steps: (1) api.grantPrepare(owner, project, {publisher, maximumWrites:3, minimumInterval:1, expiresAt: head+86400, nonce:'1', idempotencyKey:'g'}) -> grantId G. (2) Owner sends signalChain.prepareRevoke(signal,binding,G).transaction and it is mined, but no further blocks are mined (store.grant(G).revoked === true at latest; finalized block still predates it). (3) api.grantRegister(owner, project, {operationId, signature: owner.signTypedData(grant.typedData)}). Expected (if revocation were honoured): rejection with signal_grant_inactive. Actual: returns state 'prepared' with a registerGrant transaction intent. (4) Publisher broadcasts the intent: receipt status 'success', gasUsed 143469; afterwards store.grant(G) shows terms.publisher = publisher, revoked = true, grantNonces(owner,1) = true. The same sequence with 96 blocks mined between steps 2 and 3 (finalized) is correctly denied with signal_grant_inactive, as the shipped test asserts.","severity":"info","snippet":"      try { requireThat(!(await chain.grant(await signal(projectId),row.value.grantId)).revoked,'signal_grant_inactive'); }","title":"Existing boundary, newly reachable: onchain revocation visible only at latest (not finalized) does not block grantRegister; publisher pays gas to register an already-revoked grant"}],"hash":"3109ac31a00b3ba462b01aae05049c9cd37ea65a91c110a6911aeac00d6c827d","nodeId":"242970b3-dad3-4dcf-a141-017cf1968d6e","outcome":"completed","summary":"The review is complete and `.imd-findings.json` is written. No source files were changed, the baseline worktree was removed, and my scratch probes were deleted.\n\n**Outcome:** no new defect in the three runtime changes. One info-level note records an existing boundary the correction makes reachable.\n\n**What I verified**\n\n- **grantRegister fresh-grant path.** The catch is scoped to exactly `signal_grant_not_found` from the finalized adapter. Transport errors, `evidence_block_changed`, and the router's `signal_chain_unavailable` still propagate and fail closed. A finalized pre-revoked digest returns `revoked:true` and is denied. API revocation markers are checked before preparation and again inside the save transaction.\n- **Chain-clock bounds.** Both prepareGrant and registerGrant enforce `head.timestamp+61 .. head.timestamp+30 days` against the latest block, which is stricter than the contract's own check so a bounded intent cannot revert on expiry. `signal_grant_expiry` maps to HTTP 400, non-retryable. Route description and guide match the code.\n- **Scheduler receipt handoff.** The engine never rewrites `hash` on rebroadcast, so the equality check against the API row is sound. Reverted rows now also retain the lane until journaled. Cancelled rows release it. After config expiry the retained job still reaches the pipeline because `beginEffect` only gates the first claim. The host shares one Postgres store with the API, so the local read of signal-operations is coherent.\n- **Discovery whitelist.** Only coded internal reasons are exposed; uncoded RPC errors still collapse to `signal_dependency_unavailable`.\n- **Tests run.** The 18 focused runtime cases and the 3 interface tests pass. The real Anvil lifecycle test passes on the pinned tree with artifacts compiled through the project's own compiler, and the same test file fails on the baseline commit.\n\n**The one recorded note (info)**\n\nAn owner who revokes a digest directly onchain, bypassing the API revoke route, is not blocked during the finality window. grantRegister still hands out the intent, and the publisher spends roughly 143k gas registering a grant that is already dead. This is the documented finalized-read boundary, owner-triggered only, with no new economic exposure. The findings entry suggests an optional extra latest-block revoked read if the requester wants to close the window.","treeHash":null,"usage":{"cachedInputTokens":2474610,"inputTokens":706,"model":"claude-fable-5-1","outputTokens":41763,"runtime":"claude","turns":53,"wallClockMs":664240}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"fa8fc4653a9e883d","findings":[{"citation":"resolved","description":"New in this correction (regression versus 7b62bd0). unreportedReceipt treats any hash inequality between the hosted chain-transactions row and the signal-operations row as 'handoff still pending' and therefore unresolved. But the API attaches a transaction hash permanently: src/signals/index.mjs submit() lets the operation's original actor or a project owner attach any well-formed hash that chain.observe cannot find yet (TransactionNotFoundError -> state pending), and afterwards every submit with a different hash fails with signal_transaction_conflict and reconcile() stays pending forever because the attached hash never gets a receipt. Once the hosted engine's row for the same operation is 'confirmed' or 'reverted' under its real hash (engine hashes are fixed at signing, adapters/sepolia-transactions.mjs:86, and never change), the handoff can never complete: the pipeline's signal_submit throws signal_transaction_conflict on every step, the catch marks the job 'waiting' with retryAt=now+60s, schedule() returns the active waiting job, and no later slot is ever created, even after run.expiresAt and config.expiresAt. The job state stays 'waiting' indefinitely; the stated escape (rotating the pinned host config) is documented as unsupported while a signed run is unresolved. At the base commit the same state released the lane once the run expired (job 'failed'). Precondition is a privileged self-inflicted action (owner/actor attaching a wrong unknown hash before the hosted hash was reported), so impact is loss of liveness of that project's own reference lane, not funds or authority. Minimal fix: when apiOperation.value.transactionHash is set and differs from signed.value.hash, treat the row as a permanent handoff conflict (terminal with a distinct error such as signal_transaction_conflict) instead of unresolved, or bound retention of a finalized receipt by config.expiresAt plus a grace window.","line":53,"path":"reference/pulse-scheduler.mjs","reproduction":"State: store rows 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). Pipeline.step throws Object.assign(Error('conflict'),{code:'signal_transaction_conflict'}) exactly as the API does for a conflicting signal_submit. Scheduler config {startsAt:T,expiresAt:T+14400000,intervalMs:3600000,maximumRuns:4}. Steps: tick() at T -> {state:'unavailable'}; advance time by 14400001 ms (past run.expiresAt and config.expiresAt); tick() five more times at 60001 ms spacing. Expected (stated correction and base behaviour): the job becomes 'failed' and the lane is released. Actual on this commit: every tick returns {state:'unavailable',error:'signal_transaction_conflict',runId:'lane:0'} and store.list('workflow-jobs')[0].value.state === 'waiting' forever. Verified with a node:test script against reference/pulse-scheduler.mjs at HEAD (job 'waiting') and against the base-commit file (first tick after expiry returns 'failed'):\n\nimport test from 'node:test';import assert from 'node:assert/strict';\nimport {openStore} from '<repo>/src/storage/index.mjs';import {createJobs} from '<repo>/src/jobs/index.mjs';import {createPulseScheduler} from '<repo>/reference/pulse-scheduler.mjs';\ntest('mismatched attached hash locks lane',async t=>{const store=openStore(':memory:');t.after(()=>store.close());let time=1800000000000;\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 id=run.write.operation.id;\n  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);});\n  throw Object.assign(Error('conflict'),{code:'signal_transaction_conflict'});}};\n const tick=()=>createPulseScheduler({store,jobs,pipeline,config,now:()=>time}).tick();\n await tick();time+=14400001;for(let i=0;i<5;i++){time+=60001;assert.equal((await tick()).state,'unavailable');}\n assert.equal((await store.list('workflow-jobs'))[0].value.state,'failed'); /* fails: actual 'waiting' */});","severity":"low","snippet":"   const unreportedReceipt=receiptState&&signed.value.hash&&\n    (apiOperation?.value.transactionHash!==signed.value.hash||!['confirmed','failed'].includes(apiOperation?.value.state));","title":"Scheduler holds the reference lane forever when the API operation carries a different attached hash than the confirmed hosted receipt"},{"citation":"resolved","description":"Boundary note for the stated invariant, not a new defect: the correction preserves the API handoff only for 'confirmed'/'reverted' rows. adapters/sepolia-transactions.mjs reconcile() 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 (reconcile() in src/signals/index.mjs maps 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, the publisher returns state 'failed' without transactionHash (cancelled rows omit it), the pipeline never calls signal_submit, the scheduler treats the row as resolved and ends the run 'failed' once expired, 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 documented here only so the claim '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 too, extend receiptState to cancelled rows that carry a replacement and let the pipeline submit the original hash so the API records signal_transaction_replaced before the lane is released.","line":55,"path":"reference/pulse-scheduler.mjs","reproduction":"State: chain-transactions['signal-record:op-lane:0']={state:'cancelled',hash:'0xoriginal',replacement:{transactionHash:'0xcompeting'}} and signal-operations['op-lane:0']={state:'prepared'} (no transactionHash). Pipeline.step throws Error('API unavailable') (no code). Same scheduler config as above. tick() at T -> 'unavailable'; advance 3600001 ms; tick() -> {state:'failed',error:'reference_unavailable',runId:'lane:0'} 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 recorded. Expected under a strict reading of 'preserve the API handoff until the original hash and terminal outcome land': the run stays 'unavailable'/waiting until the API records signal_transaction_replaced for 0xoriginal. Observed identically on the base-commit scheduler file.","severity":"info","snippet":"   const unresolved=unreportedReceipt||signed&&!['confirmed','reverted','cancelled'].includes(signed.value.state);","title":"Cancelled-with-replacement hosted rows (original hash was broadcast) release the lane while the API handoff is still orphaned; pre-existing, not a regression"}],"hash":"7b9a8d542a0845b67e3a6aaa3e3304ec0d4ff6fb067d4eb66c8c549d9aa1027f","nodeId":"806c649b-dc0a-4a29-a613-3c72739b09ed","outcome":"completed","summary":"The review is complete. Findings are written to `.imd-findings.json` (two entries, snippets verified against the tree), no source files were changed, and the scratch Anvil node is stopped. The working tree is clean apart from the gitignored `node_modules` and `contracts/out`.\n\n**What I verified**\n\n- The three described code changes are present and match the description: `grantRegister` catches only `signal_grant_not_found`, both grant time bounds use the chain head in the adapter, HTTP maps `signal_grant_expiry` to 400 non-retryable, and the scheduler keeps confirmed/reverted hosted rows unresolved until the API row carries the same hash and a terminal state.\n- Focused unit suites pass (22 tests across signals, pulse-reference, interfaces, signal-publisher).\n- The real API-over-chain-adapter lifecycle test passes on a local Anvil (chain id 1) with artifacts compiled through the project's own compiler adapter: fresh grant, wrong signer rejected, on-chain pre-revoked digest denied, lower/upper chain-clock bounds, expired-after-signing rejection, HTTP 400 mapping, publish/upgrade/revoke lifecycle.\n- The store contract confirms the API's reasoning: a repeated `registerGrant` is a no-op, and `revokeGrant` on an unregistered digest leaves `publisher == 0` with `revoked == true`, which the adapter reports as inactive rather than not-found.\n\n**Findings**\n\n1. **Low, regression in the scheduler.** If the API operation already carries a permanently attached hash that differs from the hosted engine's confirmed hash, the new `unreportedReceipt` rule holds the lane forever. The attachment can only come from the operation's actor or a project owner submitting an unknown hash, so it is self-inflicted. On the base commit the lane was released after run expiry. Reproduced with a scratch scheduler test on both HEAD and the base file.\n2. **Info, pre-existing boundary.** A cancelled row with replacement evidence (original bytes were broadcast) still releases the lane while the API operation stays `prepared` with no hash, so the stated \"cancelled never-broadcast rows release the lane\" also covers broadcast-then-replaced rows. Identical on the base commit; noted only so the invariant is read precisely.\n\n**Coverage limits**\n\n- No Solidity changed in this diff, so no Foundry proof file was produced.\n- Full unrelated suites were not run, per the brief. The discovery whitelist, API revocation ordering, and finalized-read semantics were traced by hand and in the unit tests; I found no regression there.","treeHash":null,"usage":{"cachedInputTokens":2150153,"inputTokens":578,"model":"claude-fable-5-1","outputTokens":42477,"runtime":"claude","turns":43,"wallClockMs":651531}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"fdbedee33322a6ef","findings":[{"citation":"resolved","description":"The new handoff guard treats a hosted 'confirmed'/'reverted' chain-transactions row as unresolved until signal-operations holds exactly that hash in a terminal state. No path can reach that state if the API operation's first attached hash differs. signals.submit (src/signals/index.mjs:253-265) lets the operation actor or the project owner attach the first hash. chain.observe returns {state:'pending'} for a hash the node does not know (TransactionNotFoundError), so an unknown hash is stored permanently. Later submits of the real hash fail with signal_transaction_conflict, and the hosted engine still broadcasts its own transaction, because the authorize callback in adapters/signal-publisher.mjs never compares against the API's transactionHash. After that, the pipeline's signal_submit throws signal_transaction_conflict on every tick. The scheduler's catch block finds receiptState true and apiOperation.transactionHash !== signed.hash, so unresolved stays true forever. terminal never becomes true, even after run.expiresAt or config.expiresAt, so the active job never completes and schedule() never enqueues another slot. At the parent commit 7b62bd0 the same state released the lane once the run expired. The API row also never reaches a terminal state: it reconciles a hash that never mines and stays 'pending'. So the only way to recover is a manual database edit. This contradicts the comment that only a durable signed transaction may own the lane indefinitely. Here the lane is held by a receipt that is already final. Fix: when the API row carries a different, non-terminal hash and the hosted receipt is final, treat the conflict as resolved for lane purposes. One way is to add signal_transaction_conflict to the terminal codes when receiptState is true and apiOperation.transactionHash differs. Another is to check unreportedReceipt only against a row that either has no hash or has the same hash.","line":53,"path":"reference/pulse-scheduler.mjs","reproduction":"node --test with this scenario, using the real openStore, createJobs and createPulseScheduler at HEAD. config={projectId:'wedge',startsAt:T,expiresAt:T+4h,intervalMs:1h,maximumRuns:4}. On its first step the pipeline sets run.write.operation.id='op-wedge:0', writes signal-operations 'op-wedge:0'={state:'pending',transactionHash:'0xbogus'} (an owner's earlier signal_submit of an unknown hash), and writes chain-transactions 'signal-record:op-wedge:0'={state:'confirmed',hash:'0xoriginal'}. Every step then throws {code:'signal_transaction_conflict'}, which is what signal_submit returns. Tick every 60.001 s for 300 ticks (about 5 h, past config.expiresAt). Expected, as at parent 7b62bd0 with the same harness: run wedge:0 fails after expiry and slots wedge:1..3 run and fail. Actual at HEAD: every tick returns {runId:'wedge:0',state:'unavailable'}, so no later slot is scheduled.","severity":"low","snippet":"   const unreportedReceipt=receiptState&&signed.value.hash&&\n    (apiOperation?.value.transactionHash!==signed.value.hash||!['confirmed','failed'].includes(apiOperation?.value.state));","title":"Pulse lane is wedged permanently when the API operation already carries a different hash than the hosted confirmed receipt"}],"hash":"c6922b39dd980cf381468a3ab8fc33988eff58228bd41c2e9a84a9b126cbedce","nodeId":"7b63338b-f2d8-4157-aa28-672d6fd6326d","outcome":"completed","summary":"I found one defect in the reviewed correction: the pulse scheduler can lock its publishing lane forever. The other changes held up under source tracing, but I could not run the live Anvil lifecycle test.\n\nThe finding is in `.imd-findings.json` (one entry, rated low).\n\n## Finding — the lane locks forever when the API operation holds a different hash (`reference/pulse-scheduler.mjs:53`)\n\n- **What triggers it:** the first transaction hash attached to a record operation does not match the hosted transaction's hash. The operation's own actor or the project owner can attach any hash through `signal_submit` (`src/signals/index.mjs:253-265`). If the node does not know that hash, it is treated as pending and stored for good. Later submits of the real hash fail with `signal_transaction_conflict`. The hosted engine broadcasts and confirms its own transaction anyway, because it never compares against the API's hash.\n- **What happens:** every scheduler tick then throws `signal_transaction_conflict`. The new check sees a finalized receipt whose hash differs from the API row's hash, so the run counts as unresolved. It never becomes terminal, even after the run and the whole schedule expire, and no later slot is ever scheduled. The API row stays `pending` too, because it keeps checking a hash that never mines. Only a manual database edit clears it.\n- **Regression proof:** I built the same scenario with the real store, jobs and scheduler code and ran 300 ticks a minute apart (about 5 hours, past the schedule's expiry).\n  - At this commit, every tick returned `{runId:'wedge:0', state:'unavailable'}`.\n  - At the parent commit `7b62bd0`, run 0 failed after expiry and slots 1–3 were scheduled and ran.\n- **Severity:** low, because it needs the owner or the pulse host's own credential to attach the wrong hash. Still, it goes against the code's own rule that only a signed transaction whose outcome is still unknown may hold the lane indefinitely.\n- **Suggested fix:** when the hosted receipt is final and the API row holds a different hash, release the lane. For example, make `signal_transaction_conflict` terminal in that state.\n\n## Reviewed without findings\n\n- **Fresh-grant registration (`src/signals/index.mjs:202-203`):** only the exact `signal_grant_not_found` error is let through. Other chain or evidence errors still fail closed, an onchain pre-revoked digest still returns `revoked:true` and is denied, and the API revocation marker is checked before preparation and again inside the save.\n- **Chain-clock bounds:** grant preparation and registration both use the latest chain timestamp with the same range (+61 seconds to +30 days). `signal_grant_expiry` maps to HTTP 400, non-retryable. The route description and the guide state the same bounds.\n- **Discovery whitelist:** the listed codes come from configuration or artifact checks, not from raw RPC messages. Unknown errors still show only `signal_dependency_unavailable`.\n- **Already-signed replay (existing behaviour, not reported):** a repeat call with the same signature returns the stored registration transaction without re-checking chain time. This was also true before this change.\n\n## Testing\n\n- `test/signals.test.mjs` passed.\n- `test/pulse-reference.test.mjs` failed to load because `viem` is not installed here (there is no `node_modules`), so I ran my reproduction against the scheduler directly instead.\n- I did not run the Anvil integration test: it needs the `CLAUS_TEST_CHAIN` and `CLAUS_SIGNAL_BUILDS` fixtures and installed dependencies. The grant-registration conclusions above come from reading the code.\n\nNo repository files were changed. The scratch tests are in `/tmp/imdrepro`.","treeHash":null,"usage":{"cachedInputTokens":1152568,"inputTokens":36,"model":"claude-opus-5-5","outputTokens":16030,"runtime":"claude","turns":18,"wallClockMs":190670}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"fea57d3e9d0ca7bf","findings":[{"citation":"resolved","description":"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).","line":53,"path":"reference/pulse-scheduler.mjs","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 */});","severity":"low","snippet":"   const unreportedReceipt=receiptState&&signed.value.hash&&\n    (apiOperation?.value.transactionHash!==signed.value.hash||!['confirmed','failed'].includes(apiOperation?.value.state));\n   const unresolved=unreportedReceipt||signed&&!['confirmed','reverted','cancelled'].includes(signed.value.state);","title":"Reference lane is held forever when the API operation carries a different attached hash than the confirmed hosted receipt (regression versus 7b62bd0)"},{"citation":"resolved","description":"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.","line":202,"path":"src/signals/index.mjs","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'.","severity":"low","snippet":"      try { requireThat(!(await chain.grant(await signal(projectId),row.value.grantId)).revoked,'signal_grant_inactive'); }\n      catch(error) { if(error.code!=='signal_grant_not_found')throw error; }","title":"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 "},{"citation":"resolved","description":"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'.","line":56,"path":"reference/pulse-scheduler.mjs","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'.","severity":"info","snippet":"   const terminal=!unresolved&&(\n    run.expiresAt<=now()||['pulse_service_failed','pulse_publication_withdrawn','pulse_rule_changed',","title":"A fully handed-off hosted write is journaled as failed when the pipeline step throws after run expiry"},{"citation":"resolved","description":"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.","line":240,"path":"src/signals/index.mjs","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.","severity":"info","snippet":"      integer(input.validUntil,Math.floor(now()/1000)+1,input.observedAt+86400);\n      requireThat(input.observedAt <= Math.floor(now()/1000), 'invalid_signal_record');","title":"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"},{"citation":"resolved","description":"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.","line":49,"path":"reference/pulse-scheduler.mjs","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.","severity":"info","snippet":"   const receiptState=signed&&['confirmed','reverted'].includes(signed.value.state);","title":"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"}],"hash":"fafb18a72a8ce92b3c0382992d9f5a4ffd8f936489f4eb8df466ddb48e1df08e","nodeId":"abdaa168-40cf-4712-a53c-317efd1b8c6a","outcome":"completed","summary":"Review complete. The findings file at `.imd-findings.json` holds five reproduced findings, two low and three info, and no source file was changed.\n\n**What I verified**\n\n- All seven specialist findings were re-run against this commit. Scheduler findings used the real in-memory store, job queue and scheduler. Grant findings used a local Anvil node with the store, factory and pulse module compiled from the repository sources through the project's own compiler adapter, plus the Foundry-built executor.\n- The shipped focused suites still pass in a scratch copy: 18 scheduler and signal unit cases, and the Anvil lifecycle integration test.\n- The hosted consumer shares the API's Postgres store, so the new local read of operation rows in the scheduler is sound. The discovery whitelist contains only internal codes.\n\n**Kept, merged into one each**\n\n- **Low, scheduler lane wedge (regression).** When the API row carries a different attached hash than the confirmed hosted receipt, the new guard keeps the run unresolved forever. Every tick after expiry returns `unavailable` and the job stays `waiting`. The same harness against the parent commit's scheduler file fails the run and schedules later slots. Precondition is an owner or actor attaching an unknown wrong hash first, so this is a liveness loss on the project's own lane. The two specialist reports on this were duplicates and are one finding.\n- **Low, unfinalized onchain revocation bypass (newly reachable).** Revoking directly onchain and calling grantRegister before finality returns a registration intent. On Anvil the publisher's broadcast succeeded, burned the owner's nonce and wrote terms onto a revoked slot. Only the owner can trigger it and the cost is one transaction's gas, so I kept the lower of the two specialist severities' intent and merged them.\n\n**Kept as info, all pre-existing**\n\n- A fully handed-off write is journaled as failed if the step throws after run expiry. The shipped test itself asserts this ordering.\n- Record preparation still mixes the API host clock with the chain head clock. Reproduced with a mock head lagging by 11 seconds.\n- Cancelled rows with a replacement release the lane while the API row never receives the original hash. Identical at the parent commit.\n\n**Dropped or merged**\n\nNothing failed to reproduce. The two low scheduler reports and the two grant-revocation reports were each merged into one finding.\n\nNo Foundry proof files were written since no finding is critical or high and none is in Solidity. The Anvil node is stopped and the scratch copy lives outside the repository.","treeHash":null,"usage":{"cachedInputTokens":1932201,"inputTokens":610,"model":"claude-fable-5-1","outputTokens":33648,"runtime":"claude","turns":44,"wallClockMs":636801}}],"verification":[]}