{"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":"d3229638-a2a2-4dd7-97f0-8e2552c31e96","kind":"audit","nodes":[{"acceptedSubmissionHash":"6c5b34d9749ab734e69588bb0611ca1a85b5123b4b0d05ee9381cfbd9a6f0925","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":"402f85ad4e656b974700e6dd54143bdbb53505193cff71df975e440b166f6bfb","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":"8b97a77035e7dc97173b9ff374f442bfc90c6787381964ca02cef5a423bfeef3","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":"10e60fe799934202dda566297d9d8d4feb917aac51b3c3b5f1984a9404747295","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":"3c3cfa833024149117aa954bee8ef4d51b2306e20127db3dff6ef0177d2674bf","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 the changed-source correction at this exact commit relative to eb6f568c6b70682b6555f17128beb1d028f10150 (runtime) and 41683cf02aa2039b14a99f179496a4ca9c91e477 (signal kernel). Original independent jobs 4bfe5f40-24e0-4429-b930-397b3927ff5e and 5184c276-7378-44a6-9d86-a25e7916ccf7 remain intact. This is a focused follow-up of material fixes, not a replacement purchase; do not spawn paid jobs. Inspect git diff and confirm fixes: constructor EOA bypass prevented by msg.sender==tx.origin plus existing grant and code checks; pre-registration digest revocation survives later register, consumed nonce cancellation rejects; canonical finalized factory store recovery leaves old unknown tx untouched; proposals and execution reject untracked canonical stores; exact reviewed factory/store bytecode now checked before registration; current/withdrawn/superseded feed status and finalized grants; vault/signal consumer claim exclusion; definitive wrong hash rejected before attachment, historical mismatch terminal; byte calldata normalized; separately bounded revocation rows and operations. Reference scheduler bases uncertain-effect retention on durable signed tx row, ends unsigned denied/expired runs, original unknown broadcasts remain; provider bad order cannot abort later orders and attempts bounded; stale sequence pre-acceptance resets only unsigned request; run deadline vs observation/publication expiry split. Existing transaction engine now releases an expired unknown only with exact competing sender+nonce transaction and receipt in canonical finalized history, never merely nonce or missing original receipt, never sends a cancellation. Review end-to-end permission, gas/accounting, finality and race boundaries; no funds/custody/inference/launch expansion. PulseModule byte-identical: publisher-attested public chain data not a truth oracle, one canonical feed per project. Direct noncanonical downstream stores never receive approval. Scope remains finite owner grants; upgraded evaluator only via genuine Claus decision. Contract/UI observations are immutable history, status does not erase records. Agent patch hashing helpers exist in exact deployed baseline and tsc + real consumer fixture validation passed; no helper substitution. Read new regression tests; existing relevant invariants retain success. Return reproducible findings and distinguish limited-scope design from defects. Do not edit source, publish messages, deploy, sign, or spend. Also verify the public contract-request-client accepts chain 1 and 11155111 only, exact chain-bound pins and identical finality; its real local mainnet-chain prepare/receipt recovery test passed.","parentJobId":null,"planHash":"ba1b1df516f440af158b4f85bceb21e33413c34c4c20a0458156af511924d14c","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"d3229638-a2a2-4dd7-97f0-8e2552c31e96","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":"52641","feedbackHash":"f8d834ee4b8bc570c03cea1101c1d3d465f580af36b02b351a33b0d8d4b9fb6c","nodeKey":"audit_economics","submissionHash":"6c5b34d9749ab734e69588bb0611ca1a85b5123b4b0d05ee9381cfbd9a6f0925","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"50959","feedbackHash":"bd0acd258b845985aaaa7fafe97570a7c18cf27e32fbdb231316468e4a11fc09","nodeKey":"audit_flow","submissionHash":"402f85ad4e656b974700e6dd54143bdbb53505193cff71df975e440b166f6bfb","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52180","feedbackHash":"7ce41ae1a6b79eba01a6c149d054c63d8d821e342335587183dea6145e24b635","nodeKey":"audit_judge","submissionHash":"8b97a77035e7dc97173b9ff374f442bfc90c6787381964ca02cef5a423bfeef3","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51298","feedbackHash":"4e8bcbaa968c1070c5342c95ba773c2fe2173056588191cd47f24d5da7a02f2d","nodeKey":"audit_math","submissionHash":"10e60fe799934202dda566297d9d8d4feb917aac51b3c3b5f1984a9404747295","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"50939","feedbackHash":"f2b6c8aeb1d24c6af09def6e6033b96583257cd7cf3f57023e87d1c33601b552","nodeKey":"audit_permissions","submissionHash":"3c3cfa833024149117aa954bee8ef4d51b2306e20127db3dff6ef0177d2674bf","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"2ba084615062415ad283a34846d197144409cf81480a0ecc6f4e0a98a6c2c05b","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"2d027bc56749d95c","findings":[{"citation":"resolved","description":"Boundary x invariant seam. The API accepts expiresAt up to floor(now/1000)+30*86400 inclusive, measured against the API host's wall clock. ClausSignalStore.registerGrant (contracts/src/ClausSignalStore.sol:102) rejects `g.expiresAt > block.timestamp + MAX_GRANT_DURATION` (30 days) measured against the chain's block timestamp. Block timestamps are slot starts and trail the wall clock by up to ~12 s on mainnet/Sepolia, so a grant prepared at the API cap is valid for the API and for the owner's EIP-712 signature, but the publisher's registerGrant transaction reverts if it is included in a block whose timestamp is below the wall-clock second at which the API computed the cap. registerGrant in adapters/signal-read.mjs builds the intent without simulating it, so nothing catches this before broadcast; the publisher pays for a reverted transaction and the owner-signed grant has to be re-prepared with a lower expiresAt. This is a numeric gap between two clocks, not a contract defect; no funds or authority are affected.","line":167,"path":"src/signals/index.mjs","reproduction":"API host wall clock = 1800000005 s. grantPrepare with expiresAt = 1800000005 + 2592000 = 1802592005 passes `integer(...)` at line 167 (equal to max). Owner signs; registerGrant prepares the exact call. Block including it has block.timestamp = 1800000000 (slot start 5 s earlier). Contract check: 1802592005 > 1800000000 + 2592000 = 1802592000 -> revert InvalidGrant. Expected: an expiresAt the API accepts registers on chain. Actual: reverted registration; the same signed terms register once block.timestamp >= 1800000005. Confirmed with Foundry (Grant(publisher,1,2,60,1802592005,7) at block.timestamp 1800000000 reverts InvalidGrant; passes after vm.warp(1800000005)). Fix: cap the API maximum at floor(now/1000)+30*86400 minus a slot margin (e.g. 60 s), or simulate registerGrant before returning the intent.","severity":"low","snippet":"      integer(input.expiresAt,Math.floor(now()/1000)+60,Math.floor(now()/1000)+30*86400);","title":"grantPrepare upper bound equals the contract's maximum; a grant prepared at the API cap reverts InvalidGrant when the registering block's timestamp lags the wall clock"},{"citation":"resolved","description":"Boundary x invariant seam. The API-side guard compares validUntil with the *latest* block's timestamp (`> head.timestamp`), and the subsequent `client.call` at line 165 also executes in the latest block context, so both pass for validUntil = head.timestamp + 1. The store (contracts/src/ClausSignalStore.sol:141) requires `validUntil > block.timestamp` of the *including* block, which is at least one slot (12 s) later. Any validUntil in (head.timestamp, head.timestamp + 12] therefore passes preparation and simulation yet is guaranteed to revert when mined. For the hosted reference publisher the reverted transaction is still settled as `spent` against the finite budget (adapters/signal-publisher.mjs finalize) and the operation ends `signal_transaction_reverted`. The reference pipeline avoids the window through its own `validUntil > now+900` check, so this only affects direct API users of signal_record_prepare, and no data is written. The same single-block lag also applies to the ClausPulseModule staleness check `block.timestamp - timestamp > maximumAge`, which prepareRecord does not pre-check.","line":156,"path":"adapters/signal-read.mjs","reproduction":"Latest block timestamp head = 1800000000. recordPrepare input: observedAt = 1800000000, validUntil = 1800000001, sequence 1, expectedVersion 1, grant active with expiresAt 1800086400. src/signals/index.mjs:213 accepts (min floor(now/1000)+1 = 1800000001). Line 156: 1800000001 > 1800000000 passes. eth_call at head: publish succeeds (sequence becomes 1 in simulation). Transaction mined in the next block, block.timestamp = 1800000012: `validUntil <= block.timestamp` -> revert InvalidRecord; latest().sequence stays 0, gas paid. Confirmed with Foundry (publish(id,1,1,head,head+1,...) succeeds at timestamp head and reverts InvalidRecord at head+12). Expected: a record the API prepares and simulates can execute; actual: deterministic revert. Fix: require validUntil > head.timestamp + a slot margin (e.g. 60 s) in prepareRecord, and mirror the module's maximumAge window.","severity":"low","snippet":"      requireThat(input.validUntil > Number(head.timestamp) && input.observedAt <= Number(head.timestamp) &&\n        input.validUntil <= Number(g.terms.expiresAt) && input.validUntil <= input.observedAt+86400, 'signal_data_expired');","title":"prepareRecord accepts validUntil one second after the latest block and the eth_call simulation passes, but inclusion in the next block reverts InvalidRecord and consumes publisher gas/budget"},{"citation":"resolved","description":"Numerical gap between three bounds. The store (contracts/src/ClausSignalStore.sol:141) requires validUntil <= grant.expiresAt and prepareRecord mirrors it as `input.validUntil <= Number(g.terms.expiresAt)` (adapters/signal-read.mjs:157, error signal_data_expired). The pipeline clamps validUntil to observedAt+maximumAge and to the schedule's serviceExpiresAt, but the grant's expiresAt is a third, independent bound that runtime/pulse-host.mjs never ties to config.schedule.expiresAt. Once observedAt + maximumAge exceeds grant.expiresAt, signal_record_prepare throws signal_data_expired, which reference/pulse-scheduler.mjs:50-53 classifies as terminal, so the slot is marked failed. The schedule then advances to the next slot, which fails the same way until the grant expires and the error becomes signal_grant_inactive. The grant still has unused writes and a shorter but valid validUntil (grant.expiresAt) would have been accepted by both the contract and the API, so the reference silently loses its last maximumAge of publication capacity per grant. This is a reference-scope gap, not a contract or API defect, and does not affect custody or authority.","line":67,"path":"reference/pulse-pipeline.mjs","reproduction":"Rule maximumAge = 7200 (on-chain configuration), grant.expiresAt = 1800003600, schedule expiresAt = 1800086400000, intervalMs = 3600000. Run at slot with observedAt = 1800000000: validUntil = min(1800000000+7200, 1800086400) = 1800007200 > grant.expiresAt 1800003600 -> prepareRecord throws signal_data_expired -> scheduler finish(state:'failed'). Next slot (observedAt ~1800003600) fails identically. Expected: validUntil = min(observedAt+maximumAge, serviceExpiresAt/1000, grant.expiresAt) = 1800003600, which passes the store check `validUntil > block.timestamp` for any inclusion before 1800003600 and the pipeline's own 900 s margin for inclusion before 1800002700. Actual: no record is published in the final 2 h of the grant although 'maximumWrites' is not exhausted. Fix: have the pipeline read the grant (signal_grant) and include Number(terms.expiresAt) in the Math.min, or have pulse-host bound config.schedule.expiresAt by grant.expiresAt*1000.","severity":"low","snippet":"    observedAt:consumed.observation.observedAt,validUntil:Math.min(consumed.observation.observedAt+run.rule.maximumAge,\n      Math.floor(run.serviceExpiresAt/1000)),sourceResult:evaluator.ref,data:consumed.onchainData,idempotencyKey:run.id};","title":"Reference pipeline derives validUntil from maximumAge and the schedule expiry only, never from the grant's expiresAt, so every slot in the final maximumAge window before grant expiry fails terminally "},{"citation":"resolved","description":"Boundary x precision seam. The publication expiry is derived in seconds from the observation and converted to ms, while src/services/public-results.mjs:87 enforces `integer(expiresAt, now() + 1000, manifest.validUntil)` in ms against the API host's clock. reference/pulse-providers.mjs:28 only requires observedAt > now-3600 at provider execution time, so with an on-chain maximumAge of 3600 (the value used by the project's own local fixture) an observation accepted near that limit yields step.expiresAt <= now()+1000 by the time the consumer pipeline publishes it, and service_result_publish throws invalid_integer. That code is not in the scheduler's terminal list (reference/pulse-scheduler.mjs:50-53) and is not pulse_result_expiring (line 69 is never reached), so the job is re-queued with retryAt now+60000 and re-fails identically until run.expiresAt passes, when `run.expiresAt<=now()` finally makes it terminal. Bounded by one interval and wastes only reference API calls; no state is corrupted.","line":34,"path":"reference/pulse-pipeline.mjs","reproduction":"maximumAge = 3600, observer executes at t0 = 1800000000000 ms with finalized block timestamp 1799996401 (3599 s old; passes `observedAt > floor(now/1000)-3600`). Consumer pipeline reaches line 34 at t0+30000: step.expiresAt = min((1799996401+3600)*1000, serviceExpiresAt) = 1800001000 ms. service_result_publish: integer(1800001000, 1800030000+1000, ...) -> invalid_integer. Scheduler: not terminal, retryAt = now+60000; each retry recomputes nothing (step.expiresAt is checkpointed) and fails again until run.expiresAt (<= 1 h). Expected: a definitive 'pulse_result_expiring' style terminal failure (or a maximumAge-aware freshness check before accepting the delivered order). Actual: a 60 s retry loop for up to intervalMs.","severity":"info","snippet":"   step.expiresAt=Math.min((observedAt+run.rule.maximumAge)*1000,run.serviceExpiresAt);await checkpoint(run);","title":"Observation publication expiry can fall below the public-result service's now+1000 ms floor, raising a generic invalid_integer that the scheduler cannot classify, so the run retries every 60 s until i"}],"hash":"10e60fe799934202dda566297d9d8d4feb917aac51b3c3b5f1984a9404747295","nodeId":"91df495c-958a-4e0e-b59b-cdd78f91ae7c","outcome":"completed","summary":"The review is complete. The findings file holds four substantiated findings, none above low severity, and no source file was modified.\n\n**What I verified**\n\n- The full Foundry suite passes at this commit, including the two new regression tests for constructor-time publishing via tx.origin and pre-registration revocation.\n- The changed Node suites pass with dependencies installed. The two tests that need a local chain are skipped by default. I ran the public request-client test against a local anvil with chain id 1 and it passed, confirming the mainnet-chain prepare and receipt recovery path and the chain 1 / 11155111 pin check.\n- The numeric constants agree across layers: 2048/512 byte limits, 10000 writes, 7 day interval, 30 day grant, 1 day data lifetime, 200k evaluation gas, and the executor tuple indices the adapter reads.\n- The finalized-replacement binary search is correct at its edges, releases the wallet slot with nonce plus one, and only cancels on an exact competing sender and nonce with a receipt in finalized history. Budget reservation and settlement arithmetic is consistent, with actual cost bounded by the reserved maximum.\n\n**Findings written to `.imd-findings.json`**\n\n- **Low.** The API accepts a grant expiry equal to the contract's 30 day maximum measured on the wall clock. A block whose timestamp trails the wall clock rejects it with InvalidGrant, and the registration intent is never simulated. Confirmed in Foundry with concrete timestamps.\n- **Low.** Record preparation and its eth_call simulation accept a validUntil one second after the latest block. The including block is at least one slot later, so the publish reverts InvalidRecord and the publisher pays gas. Confirmed in Foundry. The reference pipeline's 900 second margin protects it, so this affects direct API users only.\n- **Low.** The reference pipeline clamps validUntil to maximumAge and the schedule expiry but never to the grant's expiry. In the last maximumAge window before a grant expires every slot fails terminally with signal_data_expired although a shorter validUntil would have been accepted. This is a reference-scope gap rather than a contract defect.\n- **Info.** A near-stale observation can produce a publication expiry below the public-result service's floor, raising a generic invalid_integer the scheduler cannot classify, so the run retries each minute until its deadline.\n\n**Limited-scope design, not defects**\n\nThe owner's ability to revoke any grant digest, the EOA-only publisher rule that also excludes EIP-7702 delegated accounts, the fixed reference budget ceilings, and the requirement for an archive-capable RPC for replacement recovery are all intentional boundaries and are not reported as findings.\n\nThe scratch Foundry test used to confirm the two contract boundaries was removed, so the working tree holds only the findings file.","treeHash":null,"usage":{"cachedInputTokens":2428859,"inputTokens":610,"model":"claude-fable-5-1","outputTokens":39402,"runtime":"claude","turns":47,"wallClockMs":637821}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"72b617d4b615473a","findings":[{"citation":"resolved","description":"This correction added `await chain.validateAdmission(config, await artifacts.get(...))` to admission() (src/signals/index.mjs:37). admission() is what discovery() calls, and discovery() still maps only the three pre-existing, storage-only failure codes to `admission:'not_enabled'`; any other error is rethrown. validateAdmission() now reaches the chain router and RPC on every call: createSignalRouter.selected() throws `signal_chain_unavailable` when no client is configured for the admitted chain; factory() throws `wrong_chain` or `signal_factory_changed`, or a viem transport error (HttpRequestError/TimeoutError, no `code`) when getChainId/getCode/executor() fail; validateSignalFactory() throws `signal_evidence_invalid`; artifacts.get() can throw when the factory artifact is missing. Before this commit admission() performed no network I/O (git show eb6f568:src/signals/index.mjs has no validateAdmission reference), so discovery() could not fail this way. access.discovery() (src/access/index.mjs:93) awaits signalDiscovery() for both chain 11155111 and chain 1 on every invocation; it backs the public `capabilities` service call (interfaces/service.mjs:5) and GET /health (api/gateway.mjs:44), whose catch answers 503 `{\"error\":\"service_unavailable\"}`. Side effect: every /health and capabilities request now issues getChainId + getCode(factory) + executor() per enabled signal chain against the public RPC configured with retryCount 0 and a 15 s timeout (runtime/server.mjs:72), so load on the health route translates directly into RPC load and RPC hiccups into health failures. This is an availability regression of public read endpoints, not a permission or custody issue; the fail-closed behaviour of writeAllowed()/consumer() is unaffected and correct.","line":150,"path":"src/signals/index.mjs","reproduction":"State: network-capabilities 'signal:1' = enabled claus-signal-v1 config whose assessment is accepted and whose review case is reviewed (the same rows test/signals.test.mjs writes in fixture()). Input: the chain adapter's validateAdmission rejects with (a) signal_factory_changed because getCode(config.factory) at latest != config.factoryCodeHash, or (b) signal_chain_unavailable because the deployment has no chainPorts.mainnetClient while signal:1 is enabled, or (c) a viem HttpRequestError from the RPC. Call signals.discovery(1). Expected: a resolved value such as {chainId:1, profile:'claus-signal-v1', admission:'not_enabled'|'unavailable', reason:<code>} exactly as the other three codes are reported, so capabilities and /health keep answering. Actual: discovery(1) rejects with that error; access.discovery() rejects; GET /health responds 503 service_unavailable and the capabilities call fails for every caller until the RPC/config recovers. Scratch reproduction test/scratch/discovery-rpc.test.mjs (createSignals with a validateAdmission that throws each of the three errors) fails 3/3 on this tree: `node --test test/scratch/discovery-rpc.test.mjs` reports code 'signal_factory_changed', 'signal_chain_unavailable' and the transport error. Minimal fix: in discovery() catch the additional codes (signal_chain_unavailable, wrong_chain, signal_factory_changed, signal_evidence_invalid) and code-less transport errors, returning a distinct degraded state (e.g. admission:'unavailable', reason) instead of rethrowing; optionally keep validateAdmission out of the discovery read path and leave it in writeAllowed()/consumer() where fail-closed is required.","severity":"low","snippet":"      catch (error) { if (!['signal_not_enabled','signal_admission_changed','signal_scope_not_reviewed'].includes(error.code)) throw error;","title":"signal discovery now propagates chain-dependent admission failures, taking down public /health and capabilities"},{"citation":"resolved","description":"Verified the correction: `msg.sender == tx.origin` together with `msg.sender.code.length == 0` admits only a plain externally owned account, so a counterfactual contract calling publish() from its constructor (code still empty, but msg.sender != tx.origin) is rejected; test testCounterfactualContractCannotPublishWhileInConstructor covers it and passes. The same pair of checks has a consequence worth documenting on the chains this ships to (mainnet and Sepolia are post-Pectra): an EOA that has set an EIP-7702 delegation carries a 23-byte designator, so `code.length != 0` holds even when it sends an ordinary transaction with msg.sender == tx.origin. Such a publisher cannot publish, such an owner cannot registerGrant() (line 99, `owner.code.length != 0`) nor ClausSignalFactory.create() (line 23). The asymmetry runs in the safe direction: revokeGrant() and cancelGrantNonce() have no code check, so a delegated owner can always withdraw authority, and the API's prepareGrant()/prepareStore() eoa() checks surface the condition before a transaction is prepared. No permission bypass exists; report as a documented trust/compatibility boundary.","line":136,"path":"contracts/src/ClausSignalStore.sol","reproduction":"State: grant G registered for EOA P (P.code.length == 0 at registration). P then sets an EIP-7702 delegation (type-4 transaction) for unrelated wallet features. Input: P sends publish(G, 1, 1, observedAt, validUntil, sourceHash, data) directly (msg.sender == tx.origin == P). Expected per the stated design (owner/publisher must be EOAs): reverted. Actual: revert PublisherOnly, because P.code.length == 23; the grant is unusable until P clears the delegation (delegating to address(0)), after which publish succeeds again. Same for owner O: registerGrant and factory.create revert InvalidGrant/InvalidProject while O is delegated, while O's revokeGrant succeeds. Suggested action: state the 7702 exclusion in docs/signals.md next to 'owner/publisher must be EOAs'; no code change is implied by the design.","severity":"info","snippet":"        if (msg.sender != g.publisher || msg.sender != tx.origin || msg.sender.code.length != 0) revert PublisherOnly();","title":"Design limit, not a bypass: EIP-7702 delegated EOAs cannot publish, register grants or create stores; revocation stays available"},{"citation":"resolved","description":"The vault/signal exclusion added here is correct for the race it closes: both paths read and write project-consumer-claims inside the same optimistic transaction (src/signals/index.mjs:85-87 writing 'claus-signal-v1', src/vaults/index.mjs:40-42 writing 'claus-native-principal-v1'), so two concurrent first attempts cannot both succeed. Two consequences are worth an explicit decision because they bind a project to a profile by its first attempt rather than by a verified consumer: (1) no code path deletes or overrides a project-consumer-claims row (grep shows only the two writers), so an unsigned signal_store_prepare operation that expires after 24 h, or a vault deployment that ends in the terminal states 'failed'/'cancelled'/'blocked' that vaults.create itself treats as retryable, permanently closes the other profile; (2) the signal path additionally rejects on the mere presence of any project-vaults row regardless of its state (this line), whereas the vault path only rejects on a project-signals row, which is only written once a store is finalized/recovered. docs/signals.md says 'A project can select one consumer profile, signal or vault'; it does not say the selection is made irrevocably by an attempt that produced nothing on chain.","line":84,"path":"src/signals/index.mjs","reproduction":"State: project P with a claus-readonly-v1 binding, no store (factory.stores[projectKey] == 0) and no vault. Input A: owner calls signal_store_prepare {idempotencyKey:'k1'} and never signs; after 24 h the operation is expired. Owner later calls vault.create. Expected (if selection is meant to follow a verified consumer): vault creation proceeds since no store exists. Actual: signal_review_context_changed from src/vaults/index.mjs:41, permanently. Input B (mirror): owner calls vault.create; the vault-deploy job ends 'failed' before any contract exists; owner calls signal_store_prepare. Expected: allowed, since vaults.create would itself allow a retry and no vault exists. Actual: signal_review_context_changed from this line (project-vaults row present) and from the claim check, permanently. If the irrevocable-at-first-attempt rule is intended, document it as such; otherwise release the claim (and ignore terminal non-verified vault rows) when an attempt ends without a verified consumer.","severity":"info","snippet":"        requireThat(!await tx.read('project-vaults',projectId),'signal_review_context_changed');","title":"Design observation: consumer-profile claim is taken at the first prepare attempt and never released, including after failed or expired attempts"}],"hash":"3c3cfa833024149117aa954bee8ef4d51b2306e20127db3dff6ef0177d2674bf","nodeId":"ed2ff65f-7ae5-498f-9990-10e4060f2528","outcome":"completed","summary":"The review is complete. The findings file is in place at the repository root, no tracked file was modified, and the only other artefact is my scratch reproduction under `test/scratch/`, which the harness excludes.\n\n**Outcome**\n\nThe correction does what the brief says it does. I found no permission bypass, accounting, finality, or race defect in the Solidity kernel or the changed runtime. I recorded one low-severity availability regression in the runtime and two design observations. `.imd-findings.json` holds three entries, all with path, line and snippet verified against the tree.\n\n**What I verified, and how**\n\n- **Foundry**: 72 tests pass, including the two new regression tests for the constructor-phase publisher and pre-registration revocation.\n- **Node**: 463 tests pass, 11 skipped. The skipped ones are the anvil-gated local-chain suites, including the mainnet-chain prepare/receipt recovery test and the signal lifecycle test. Anvil is not installed on this machine, so I could not re-run them. I traced the request client by reading instead: it accepts chain 1 and 11155111 only, binds the pin to the binding's chain and the live chain id, and uses the same finalized-block code checks and receipt canonicality on both chains.\n- **Manifest**: all 66 SOURCE_MANIFEST hashes match the files, so PulseModule is byte-identical and the updated store hash is correct. The contracts did not change between the two baseline commits.\n- **Signal kernel**: the `tx.origin` check plus the existing code-length and grant checks admit only a plain EOA. Pre-registration `revokeGrant` sets a flag that `registerGrant` never clears. `cancelGrantNonce` rejects a consumed nonce. Revocation keeps working without the active-project requirement, which is the right asymmetry.\n- **Runtime**: the replacement finder only releases a slot after locating the exact same-sender, same-nonce transaction with a receipt in finalized history, and never broadcasts. Recovery adopts only the finalized canonical factory store and leaves the older pending operation and its hash untouched. Proposals and execution fail closed on an untracked canonical store. Wrong hashes are rejected before attachment. Revocation rows and operations have separate daily caps. The scheduler ends unsigned denied or expired runs and retains runs with a durable signed row.\n- **Agent patch**: the hashing helpers it imports live in the agent baseline, which is not in this repository, so I could not confirm that claim or the tsc run.\n\n**Findings**\n\n1. **Low, `src/signals/index.mjs:150`.** `admission()` now calls `validateAdmission`, which performs RPC reads. `discovery()` still maps only the three old storage-only error codes to `not_enabled` and rethrows everything else. `access.discovery()` runs it for both chains and backs the public `capabilities` call and `GET /health`, whose catch returns 503. A factory-code mismatch, a missing mainnet client, or a transport error on the public RPC now takes both endpoints down. My scratch test reproduces all three cases against the real module.\n2. **Info, design.** The EOA checks also exclude EIP-7702 delegated accounts from publishing, registering grants and creating stores. Revocation is unaffected. Worth a line in the docs.\n3. **Info, design.** The consumer-profile claim is written on the first prepare attempt and never released, so an unsigned expired store operation or a failed vault deployment permanently closes the other profile.","treeHash":null,"usage":{"cachedInputTokens":2663454,"inputTokens":482,"model":"claude-fable-5-1","outputTokens":61076,"runtime":"claude","turns":50,"wallClockMs":946986}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"468e82a89b9bfe18","findings":[{"citation":"resolved","description":"This commit added `await chain.validateAdmission(config, await artifacts.get(...))` to `admission()` (src/signals/index.mjs:37). `admission()` is called by `discovery(chainId)`, which runtime/server.mjs:45 wires into `signalDiscovery` for both chains, and access.discovery() awaits it for the unauthenticated `GET /v1/public/capabilities` route (interfaces/contract.mjs:55). Before this commit `admission()` was a pure database check and `discovery()` only needed to swallow the three admission codes it lists. Now `validateAdmission` issues `getChainId`, `getCode(factory)` and `factory.executor()` RPC calls (adapters/signal-read.mjs:40-45,77-82, via a public Sepolia RPC with retryCount 0 and a 15 s timeout) and can throw `wrong_chain`, `signal_factory_changed`, `signal_evidence_invalid`, `signal_chain_unavailable` (createSignalRouter when no mainnet client is configured) or an uncoded viem HttpRequestError. None of these are in the catch list, so the whole public capabilities document fails instead of reporting the signal profile as unavailable. The regression couples every public capabilities read to several live RPC round-trips with no caching, and a transient Sepolia RPC outage or a stale factory pin takes down discovery for every unrelated feature (chain profiles, reviews, collaboration). Limited-scope availability defect, not a permission or funds issue.","line":150,"path":"src/signals/index.mjs","reproduction":"State: `network-capabilities/signal:1` enabled with a reviewed assessment (same rows as test/signals.test.mjs fixture()). Inject a chain port whose validateAdmission rejects with (a) code `signal_chain_unavailable` (what createSignalRouter throws when `chainPorts.mainnetClient` is absent), (b) a viem `HttpRequestError` (RPC down), (c) code `signal_factory_changed`. Call `signals.discovery(1)`. Expected (pre-commit behaviour): resolves, so `/v1/public/capabilities` still renders with the signal profile marked not_enabled/unavailable and a reason. Actual, verified with a Node script against the committed tree: `discovery(1) REJECTED with signal_chain_unavailable`, `... REJECTED with HttpRequestError`, `... REJECTED with signal_factory_changed`; access.discovery() therefore rejects and the public route fails. Fix: in discovery() also catch the chain-evidence codes (signal_chain_unavailable, wrong_chain, signal_factory_changed, signal_evidence_invalid) and uncoded transport errors and report `admission:'unavailable'`, or keep the bytecode check in writeAllowed()/publisher authorize and out of the public discovery path.","severity":"medium","snippet":"      catch (error) { if (!['signal_not_enabled','signal_admission_changed','signal_scope_not_reviewed'].includes(error.code)) throw error;","title":"Public capabilities discovery now fails on any signal-chain RPC error because admission() performs a live bytecode check that discovery() does not catch"},{"citation":"resolved","description":"The fix intends that only an uncertain broadcast retains the lane (\"Only a durable signed transaction may own the lane indefinitely\"). But `signed` is truthy for a row in any state. The engine writes rows in state `prepared` (signed, not yet broadcast) and `cancelled` (submit() denied authorization before any broadcast and released the slot and budget, adapters/sepolia-transactions.mjs:104-109), neither of which is an uncertain effect. If publisher.step then throws persistently for that run, for example `signal_operation_mismatch` after the consumer budget/grant configuration is rotated (adapters/signal-publisher.mjs:47-51 runs before the transaction row is consulted) or `transaction_not_found` after the publisher signer is rotated, the catch marks the run `waiting` forever, schedule() never enqueues another slot because the active job is not terminal, and the reference feed stops publishing until an operator edits the database. The same codes are terminal for an unsigned intent, so the only difference is the existence of a row whose state already proves nothing was broadcast.","line":48,"path":"reference/pulse-scheduler.mjs","reproduction":"Using createPulseScheduler with an in-memory store and createJobs (as in test/pulse-reference.test.mjs): pipeline.step sets run.write.operation={id:'op-'+run.id}, checkpoints, writes chain-transactions 'signal-record:op-<run>' with {state:'cancelled'} (what submit() leaves after an authorization denial, no broadcast) and throws code `signal_operation_mismatch`. Config intervalMs 3600000, maximumRuns 4. Expected: the run ends `failed` (as it does when no row exists) and the next slot is scheduled an hour later. Actual, verified on the committed tree: tick1 `unavailable lane:0`; after +1h, +2h, +3h every tick returns `unavailable lane:0` and `workflow-jobs` stays at 1, so no later slot ever runs. Fix: `const row=...; const signed=row&&['broadcasting','submitted','unknown'].includes(row.value.state)` (confirmed/reverted rows are resolved by the try path on the next tick).","severity":"low","snippet":"   const signed=run.write?.operation&&await store.read('chain-transactions','signal-record:'+run.write.operation.id);","title":"Scheduler treats any chain-transactions row, including a cancelled never-broadcast intent, as a durable signed transaction and holds the lane forever"},{"citation":"resolved","description":"The new mutual-exclusion claim is written inside prepare() for every `kind==='store'` operation, including the ordinary unsigned path whose result is only an intent the owner may never sign (state `prepared`, expiresAt now+24h). The vault side writes its claim only when it actually starts a deployment attempt (src/vaults/index.mjs:42) and there is no API that releases either claim. So an owner who calls signal_store_prepare once to inspect the transaction, lets it expire and later wants a native vault for the same project is refused forever with `signal_review_context_changed` (src/vaults/index.mjs:41), a code that describes a reviewed-context change rather than the real cause. docs/signals.md says a project selects one profile, so exclusivity itself is design; the defect is that the selection is made by an unsigned, expirable preparation rather than by a finalized store or an explicit choice, and it cannot be undone.","line":87,"path":"src/signals/index.mjs","reproduction":"Project p with a verified claus-readonly-v1 binding and signal admission enabled. 1) owner calls `signal_store_prepare` {idempotencyKey:'x'}: response state `prepared`; `project-consumer-claims/p` = {profile:'claus-signal-v1'}; nothing is signed or broadcast and no `project-signals` row exists. 2) After the operation's expiresAt (24h) passes, owner calls vault creation for p with a valid policy. Expected: vault creation proceeds (no store exists on chain or in `project-signals`), or at least a code naming the conflicting profile claim. Actual: vaults.create rejects with `signal_review_context_changed` from the claim check at src/vaults/index.mjs:41, permanently. Fix: write the signal claim only when a store becomes `verified` (recovery or confirmed receipt) and clear/skip it for expired unsigned operations, or expose an owner call to release an unused claim; use a dedicated code such as `consumer_profile_conflict`.","severity":"low","snippet":"        if(!claim)await tx.write('project-consumer-claims',projectId,{profile:'claus-signal-v1'},0);","title":"An unsigned, never-broadcast signal_store_prepare permanently claims the signal consumer profile and blocks vault creation with a misleading error"},{"citation":"resolved","description":"The contract now lets the owner revoke a signed grant ID before registration and registerGrant deliberately still succeeds (storing revoked terms, consuming the nonce, emitting PublisherGranted). The API mirrors neither state when preparing the registration call: grantRegister does not consult `signal-revocations` (which revoke() writes for any 32-byte ID, line 203) nor the on-chain `grant(id).revoked` flag that chain.grant() now returns for unregistered IDs. The registration transaction is therefore handed to the publisher, succeeds on chain, and every later publish reverts GrantInactive while recordPrepare rejects with `signal_api_revoked`. No authority is gained, so this is an efficiency/UX gap rather than a security defect; distinguishing it from design: the docs describe pre-registration revocation but not that registration preparation ignores it.","line":178,"path":"src/signals/index.mjs","reproduction":"1) owner: signal_grant_prepare → operation G with grantId D (awaiting_signature). 2) owner: signal_grant_revoke {grantId:D} → `signal-revocations` row written, on-chain revokeGrant(D) prepared and sent. 3) owner: signal_grant_register {operationId:G, signature:<valid owner EIP-712 signature>}. Expected: rejected (e.g. `signal_api_revoked`/`signal_grant_inactive`) before a transaction is prepared. Actual: returns state `prepared` with the registerGrant calldata; broadcasting it succeeds on chain (contracts/test/ClausSignalStore.t.sol testOwnerCanRevokeSignedGrantBeforeAndAfterRegistration shows registerGrant succeeds on a pre-revoked ID) and the grant is unusable. Fix: in grantRegister, requireThat no `signal-revocations` row exists for D and that `chain.grant(signal,D)` is not revoked.","severity":"info","snippet":"      const prepared = await chain.registerGrant(await signal(projectId), current.binding, row.value, signature);","title":"grantRegister prepares a registration for a grant ID the owner has already revoked, so the publisher pays gas to register a dead grant"},{"citation":"resolved","description":"The constructor-bypass fix (`msg.sender != tx.origin || msg.sender.code.length != 0` at line 136) together with the existing `code.length` checks here and in ClausSignalFactory.create (line 23) and the API's eoa() check (adapters/signal-read.mjs:36-39) correctly close the counterfactual-contract path: a contract calling from its constructor has empty code but is never tx.origin, and the new regression test covers it. The same checks mean that on Ethereum mainnet (Pectra, EIP-7702 live) an owner or publisher EOA that has installed a delegation designator (23 bytes of code, 0xef0100‖address) is rejected with InvalidProject/InvalidGrant/PublisherOnly on chain and `signal_eoa_required` in the API; such an owner can still revokeGrant/cancelGrantNonce (no code check). This matches the documented rule that owner/publisher must be EOAs and fails closed, so it is recorded as a trust/scope assumption rather than a finding requiring change.","line":99,"path":"contracts/src/ClausSignalStore.sol","reproduction":"Mainnet owner address O with an active EIP-7702 delegation (eth_getCode(O) = 0xef0100<20-byte address>). Expected per docs: rejected. Actual: ClausSignalFactory.create reverts InvalidProject (owner.code.length != 0), ClausSignalStore.registerGrant reverts InvalidGrant, and signal-read prepareStore/prepareGrant reject with `signal_eoa_required`; a delegated publisher calling publish reverts PublisherOnly even though msg.sender == tx.origin. Behaviour is consistent across contract and API; no change required unless 7702 accounts are to be supported, which would require accepting code equal to a delegation designator and a different anti-constructor guard.","severity":"info","snippet":"        if (owner.code.length != 0 || g.publisher == address(0) || g.publisher.code.length != 0 ||","title":"Design limit, not a defect: owners and publishers must be plain EOAs; an EIP-7702 delegated account cannot create a store, register a grant or publish"}],"hash":"402f85ad4e656b974700e6dd54143bdbb53505193cff71df975e440b166f6bfb","nodeId":"6ccc8f08-9f9a-43a7-9624-b5fee20c27e5","outcome":"completed","summary":"The review is complete. All five findings validate against the tree, the working tree holds only the findings file plus pre-existing sandbox device nodes, and no anvil processes remain.\n\n## Summary\n\nThe follow-up commit at 8b03d35 implements every correction the task listed, and I could not break any of the permission, finality or accounting boundaries. Two reproducible defects and one lesser issue were introduced alongside the fixes, both outside the funds and custody path. Findings are in `.imd-findings.json`.\n\n**What I verified**\n\n- Contracts build with Foundry 1.8.3 and all 10 ClausSignalStore tests pass, including the two new regression tests. ClausPulseModule is byte-identical to the signal-kernel commit and every SOURCE_MANIFEST hash matches the tree.\n- The full JavaScript suite on an exported copy of the committed tree: 463 pass, 11 skipped for missing environment, 0 fail.\n- Both anvil-backed integration tests ran for real on local chain id 1: the public request client (prepare, chain-bound pin, receipt recovery) and the signal lifecycle, for which I compiled fixtures with the project's own compiler adapter. The lifecycle test exercises factory admission bytecode, finalized store recovery, runtime template validation, authorizationActive and finalized grant reads.\n- By reading: tx.origin plus code check closes the constructor path; pre-registration revocation survives registerGrant; consumed-nonce cancellation reverts; recovery adopts only the canonical finalized store and leaves the original unknown operation pending; approvals and execution fail closed on untracked canonical stores; submit rejects a definitively wrong hash before attaching and a historical mismatch is terminal; calldata is lowercased on both sides; revocation rows and operations have separate daily caps; the expired-unknown release requires the exact competing sender and nonce with a receipt in finalized history and never broadcasts.\n\n**Findings**\n\n- **Medium.** Public capabilities discovery now rejects on any signal-chain RPC error. The new bytecode check inside admission() throws codes and transport errors that discovery() does not catch, so one Sepolia RPC hiccup takes down the unauthenticated capabilities route for every feature. Reproduced with three injected failures.\n- **Low.** The scheduler treats any chain-transactions row as a durable signed transaction. A cancelled, never-broadcast row plus a persistent publisher error such as a rotated budget holds the lane forever. Reproduced: four ticks over three hours, one job, no new slot.\n- **Low.** An unsigned signal_store_prepare permanently claims the signal consumer profile, so a later vault request is refused with a misleading code. Verified by code path, not executed.\n- **Info.** grantRegister ignores a prior revocation of the same grant ID, so the publisher pays gas to register a dead grant.\n- **Info.** Owners and publishers must be plain EOAs. An EIP-7702 delegated account is rejected consistently on chain and in the API. Recorded as documented design, not a defect.\n\nNothing in the repository was edited. Dependencies, fixtures and reproduction scripts live only under the temp directory.","treeHash":null,"usage":{"cachedInputTokens":3696133,"inputTokens":834,"model":"claude-fable-5-1","outputTokens":61464,"runtime":"claude","turns":44,"wallClockMs":1178704}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"803eeae3d8b63de3","findings":[{"citation":"resolved","description":"The uncertain-effect retention rule is implemented as 'a chain-transactions row exists for this operation', not 'a row exists whose outcome is still uncertain'. A row in a terminal state (cancelled before broadcast by a denied authorization, confirmed, or reverted) has no uncertain effect, but it still disables both the expiry cutoff (run.expiresAt<=now()) and the terminal error list. If the pipeline then throws persistently before publisher.step() can return its own terminal outcome, the run is retried every 60 s forever: schedule() sees activeJobId in state 'waiting', returns it, and never computes a new slot. The realistic persistent error is the publisher budget check at adapters/signal-publisher.mjs:48-53 ('signal_operation_mismatch'): after an authority-generation bump every grant is dead by design, the operator admits a new budget with a new grantId and restarts the reference worker, and every step() of the old run now throws before reconcile. The same gate also means a rotated budget can never reconcile an old 'unknown' row, because the mismatch is thrown before transactions.reconcile() runs. This is a liveness defect of the reference run loop only; no funds, grants or on-chain state are affected and the API operation rows remain intact. Fix: treat only non-terminal rows as retaining (e.g. signed && !['cancelled','confirmed','reverted'].includes(signed.value.state)), or let run.expiresAt end a run whose only signed row is terminal.","line":48,"path":"reference/pulse-scheduler.mjs","reproduction":"Node test (run from a scratch dir with the repository copied to ../repo): store=openStore(':memory:'); jobs=createJobs({store,identity:{authorize:async()=>{}},now}); config={projectId:'lane',startsAt:t0,expiresAt:t0+14400000,intervalMs:3600000,maximumRuns:4}; pipeline.step sets run.write={operation:{id:'op-'+run.id}}, checkpoints, writes chain-transactions 'signal-record:op-lane:0' = {state:'cancelled'} once, then throws {code:'signal_operation_mismatch'} every call. tick() #1 at t0 -> state 'unavailable', runId 'lane:0'. Advance now by 7200001 ms (two intervals, past run.expiresAt=t0+3600000). tick() #2 -> expected: run 'lane:0' ends 'failed' (its only signed row is terminal) and a new slot 'lane:2' is scheduled; actual: returns runId 'lane:0' state 'unavailable' again, workflow-jobs count stays 1, and no later slot is ever created. The existing test 'an unsigned failed intent releases the next scheduler slot but an unknown signed transaction retains it' only covers state 'unknown'.","severity":"low","snippet":"   const signed=run.write?.operation&&await store.read('chain-transactions','signal-record:'+run.write.operation.id);\n   const terminal=!signed&&(","title":"Reference scheduler pins the lane on any existing signed row, including a terminal one, so a persistent pre-broadcast error blocks every later slot"},{"citation":"resolved","description":"revokeGrant(id) before registration sets grants[id].revoked=true with terms.publisher==0, so registerGrant() does not take the early return at line 97, runs the full validation, marks grantNonces[owner][nonce]=true, stores the terms and emits PublisherGranted even though the grant can never publish (publish() reverts GrantInactive). The security outcome is correct and is covered by testOwnerCanRevokeSignedGrantBeforeAndAfterRegistration, but an indexer that treats PublisherGranted as 'active grant' without also reading grant(id).revoked will show a live publisher, and the owner can no longer cancelGrantNonce(nonce) for that nonce (it reverts InvalidGrant because the nonce is consumed). Limited-scope design rather than a defect; a one-line guard (revert or skip the event when grants[id].revoked) would make the event authoritative.","line":106,"path":"contracts/src/ClausSignalStore.sol","reproduction":"Foundry (test/ClausSignalStore.t.sol fixture): g=grantTerms(); g.nonce=88; id=sink.grantDigest(g); vm.prank(owner); sink.revokeGrant(id); vm.expectEmit -> PublisherGranted(id, publisher, 1, 2, expiresAt) is emitted by sink.registerGrant(g, sign(OWNER,id)); afterwards sink.grant(id).revoked==true, sink.grantNonces(owner,88)==true, and vm.prank(owner); sink.cancelGrantNonce(88) reverts InvalidGrant. Expected by a naive event consumer: no grant event for a dead grant; actual: event emitted.","severity":"info","snippet":"        emit PublisherGranted(id, g.publisher, generation, g.maximumWrites, g.expiresAt);","title":"Registering a grant whose digest was revoked before registration still consumes the owner nonce and emits PublisherGranted"},{"citation":"resolved","description":"adapters/sepolia-transactions.mjs:133-144 correctly releases an expired unknown transaction only when finalizedReplacement() finds the exact competing sender+nonce transaction with a canonical finalized receipt, and records {state:'cancelled', replacement}. But by then the original hash was already attached to the signal operation by the pipeline's signal_submit call while the row was 'unknown'/'submitted'. chain.observe() for that hash keeps returning {state:'pending'} (TransactionNotFoundError), so signal_operation and signal_latest consumers see a permanently pending operation while the host ledger holds definitive evidence that it can never execute. This matches the documented 'pending does not mean failure; hashes are never replaced' rule and no state is lost, so it is a reporting gap, not a defect: the engine's replacement evidence (competing hash, finalized block) could be propagated to the operation as a terminal 'replaced' outcome without ever inventing or replacing a hash.","line":115,"path":"src/signals/index.mjs","reproduction":"State: signal operation op1 (kind record) with transactionHash H attached; chain-transactions row 'signal-record:op1' in state 'unknown', intent.expiresAt passed; finalized chain contains a different transaction H2 from the same publisher with the same nonce. transactions.reconcile('signal-record:op1') -> row becomes {state:'cancelled', replacement:{transactionHash:H2,...}} and the pipeline run becomes 'failed' with error 'signal_transaction_cancelled'. signals.operation(actor,projectId,op1) afterwards -> chain.observe(action,H) throws TransactionNotFoundError -> returns {state:'pending'} forever. Expected from a consumer's point of view: a terminal state carrying the replacement evidence; actual: 'pending' with no path to any terminal state.","severity":"info","snippet":"    if (observed.state === 'pending') return view(row);","title":"A finalized replacement proven by the transaction engine is never surfaced to the API signal operation, which stays 'pending' indefinitely"}],"hash":"6c5b34d9749ab734e69588bb0611ca1a85b5123b4b0d05ee9381cfbd9a6f0925","nodeId":"95a26874-779c-4878-9ad5-1ed5c9323016","outcome":"completed","summary":"Both reproductions hold. The review is complete and the findings file is in place; here is the recap.\n\n**Outcome.** No critical, high or medium defects found in the changed scope. The findings file at the repository root holds one low and two info findings, each with a concrete failing state. No source files were changed.\n\n**Verification environment.** The base-commit blobs are not in this blob-less partial clone and GitHub egress is denied, so the diff could not be rendered. The review covered the full current tree of every file the task names. Solc 0.8.27 download is also denied, so I ran the Foundry suite from a scratch copy outside the repository with the pragma relaxed to the cached 0.8.26.\n\n| Check | Result |\n|---|---|\n| Foundry suite, scratch 0.8.26 copy | 72 passed, 0 failed |\n| Node suite in scratch copy | 463 passed, 0 failed, 11 skipped (need a local chain) |\n| Request-client local test on anvil chain id 1 | passed |\n| Scheduler lane-pinning repro (node) | fails on current code, confirming the finding |\n| Revoked-grant event repro (Foundry) | confirms the info finding |\n\n**Claimed fixes confirmed in the tree.** The publish path requires sender equal to origin plus grant and code checks. Pre-registration revocation persists through register, and cancelling a consumed nonce reverts. Finalized factory recovery writes the binding with a null hash and leaves the old pending operation untouched. Proposals and execution go through the tracked-store assertion. Store and factory runtime bytecode are validated before registration. Feed status distinguishes current, stale, withdrawn and superseded. Vault and signal consumer claims are mutually exclusive. A definitively wrong hash is rejected before attachment and a historical mismatch is terminal. Calldata is lowercased on both sides. Revocation rows and operations are capacity-bounded separately. The engine releases an expired unknown only on an exact finalized competing sender-and-nonce transaction with a verified receipt and never broadcasts a cancellation. The request client accepts only chains 1 and 11155111, binds pins per chain, and uses the same finality rule as the signal reader.\n\n**Findings.**\n- **Low, reference scheduler.** The retention gate checks whether a signed row exists, not whether it is non-terminal. A cancelled, confirmed or reverted row plus a persistent pre-broadcast error, such as a rotated publisher budget after a grant reissue, keeps one run waiting forever and no later slot is scheduled. Liveness only, no funds or on-chain state involved.\n- **Info, signal store.** Registering a pre-revoked digest still consumes the owner nonce and emits PublisherGranted. Security outcome is correct, but the event is not authoritative for indexers.\n- **Info, signals API.** A finalized replacement proven by the engine is never surfaced to the API operation, which stays pending indefinitely. Consistent with the documented rule, so reported as a reporting gap rather than a defect.\n\n**Trust assumptions, not defects.** Owner and publisher must be plain EOAs, which excludes contract and EIP-7702-delegated accounts by design. Every authority change kills all grants. A project owner can lock their project out of the admitted feed by calling the factory directly with a different feed or schema. The pulse data remains publisher-attested public chain data, not a truth oracle.","treeHash":null,"usage":{"cachedInputTokens":2352961,"inputTokens":706,"model":"claude-fable-5-1","outputTokens":41581,"runtime":"claude","turns":58,"wallClockMs":760541}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"c722c2e9ac9aa084","findings":[{"citation":"resolved","description":"This correction added `await chain.validateAdmission(config, await artifacts.get(...))` to admission() (src/signals/index.mjs:37). admission() is also what discovery(chainId) calls, and discovery() still only maps the three pre-existing storage-only codes to admission:'not_enabled'; everything else is rethrown. validateAdmission() (adapters/signal-read.mjs:77-82) now issues getChainId, getCode(factory), factory.executor() and a second getCode on every call and can throw `wrong_chain`, `signal_factory_changed`, `signal_evidence_invalid`, `signal_chain_unavailable` (createSignalRouter when no client is configured for the admitted chain) or an uncoded viem transport error; artifacts.get() can throw `not_found`. runtime/server.mjs:45 wires discovery for chains 11155111 and 1 into access.discovery(), which backs the unauthenticated `capabilities` service call (interfaces/service.mjs:5, GET /v1/public/capabilities) and GET /health (api/gateway.mjs:44, whose catch answers 503 service_unavailable). Before this commit admission() did no network I/O (git show eb6f568:src/signals/index.mjs), so these public reads could not fail this way. Consequences: (1) a transient public-RPC failure (retryCount 0, 15 s timeout) or a stale factory pin takes down the whole capabilities document and /health for every unrelated feature; (2) every unauthenticated capabilities or health request now performs four uncached RPC round-trips per enabled signal chain, which turns load on a public route into RPC load. The fail-closed behaviour of writeAllowed()/consumer() is correct and unaffected; the defect is confined to the public discovery path. Minimal fix: in discovery() also catch the chain-evidence codes (signal_chain_unavailable, wrong_chain, signal_factory_changed, signal_evidence_invalid, not_found) and code-less transport errors and return a degraded state such as {admission:'unavailable', reason}, or keep validateAdmission out of the discovery path and leave it in writeAllowed()/consumer() where fail-closed is required. Merged from audit_permissions and audit_flow, which reported the same mechanism.","line":150,"path":"src/signals/index.mjs","reproduction":"State: network-capabilities 'signal:1' enabled with an accepted assessment and a reviewed review case (the rows test/signals.test.mjs fixture() writes). Inject a chain port whose validateAdmission rejects with (a) code signal_chain_unavailable, (b) code signal_factory_changed, (c) code signal_evidence_invalid, (d) a viem HttpRequestError without a code. Call signals.discovery(1). Expected (behaviour at eb6f568): resolves with a not_enabled/unavailable record so capabilities and /health keep answering. Actual, verified with a scratch Node test against this tree (createSignals with those four validateAdmission stubs): discovery(1) rejects with exactly the injected error in all four cases, while a control stub throwing signal_scope_not_reviewed still resolves to admission:'not_enabled'. Through runtime/server.mjs:45 and src/access/index.mjs:93 the rejection propagates to the public capabilities call and GET /health.","severity":"medium","snippet":"      catch (error) { if (!['signal_not_enabled','signal_admission_changed','signal_scope_not_reviewed'].includes(error.code)) throw error;","title":"Public capabilities discovery and /health now fail on any signal-chain RPC or evidence error because admission() performs a live bytecode check that discovery() does not catch"},{"citation":"resolved","description":"The fix intends that only a durable signed transaction with an uncertain effect may own the lane indefinitely (comment on lines 46-47). The implementation tests only whether a row exists; `signed` is truthy for rows in state 'prepared' (signed, never broadcast), 'cancelled' (submit() denied authorization before any broadcast and already released the slot and budget), 'confirmed' and 'reverted'. None of those has an uncertain effect, yet their presence disables both the run.expiresAt cutoff and the terminal error list. If publisher.step() then throws before it can return its own outcome, the run is retried every 60 s forever: schedule() sees the active job in state 'waiting' and never computes a new slot. Persistent pre-reconcile throws exist in adapters/signal-publisher.mjs: `signal_operation_mismatch` at line 48/52 once the operator admits a budget with a different grantId (an authority bump kills every old grant by design and config.budget is independent of config.schedule, so reference-pulse-schedules' configHash still matches), and `transaction_not_found` from transactions.status() at adapters/sepolia-transactions.mjs:196 after the publisher signer is rotated. With such a row the old run can only be ended by editing the database. Liveness defect of the reference loop only; no funds, grants or on-chain state are affected. Fix: treat only uncertain rows as retaining, e.g. `const row=...; const signed=row&&['broadcasting','submitted','unknown'].includes(row.value.state)` (confirmed/reverted/cancelled rows are already resolved by the normal try path on the next tick). Merged from audit_economics and audit_flow.","line":48,"path":"reference/pulse-scheduler.mjs","reproduction":"Scratch Node test with openStore(':memory:'), createJobs and createPulseScheduler as in test/pulse-reference.test.mjs. config={projectId:'lane',startsAt:t0,expiresAt:t0+14400000,intervalMs:3600000,maximumRuns:4}. pipeline.step sets run.write={operation:{id:'op-'+run.id}}, checkpoints, writes chain-transactions 'signal-record:op-lane:0' = {state:'cancelled'} once (what submit() leaves after an authorization denial), then throws {code:'signal_operation_mismatch'} on every call. tick() #1 at t0 -> {state:'unavailable', runId:'lane:0'}. Advance now by 7200001 ms (past run.expiresAt = t0+3600000) and tick() #2, then +3600000 ms and tick() #3. Expected: run 'lane:0' ends 'failed' and slot 'lane:2'/'lane:3' is scheduled. Actual: both ticks return runId 'lane:0' state 'unavailable', workflow-jobs count stays 1, and no later slot is ever created. The same result was verified for row states 'confirmed', 'reverted' and 'prepared'. Control in the same scratch test: with no row, the identical error ends the run 'failed' and the next tick schedules 'lane:1'. The existing test 'an unsigned failed intent releases the next scheduler slot but an unknown signed transaction retains it' only covers state 'unknown'.","severity":"low","snippet":"   const signed=run.write?.operation&&await store.read('chain-transactions','signal-record:'+run.write.operation.id);","title":"Reference scheduler pins the lane on any chain-transactions row, including cancelled, reverted, confirmed or never-broadcast prepared rows, so a persistent pre-reconcile error blocks every later slot"},{"citation":"resolved","description":"The vault/signal mutual exclusion itself is documented design ('A project can select one consumer profile, signal or vault') and the race it closes is handled correctly: both writers read and write project-consumer-claims inside the same optimistic transaction. The defect is when the selection becomes binding. The signal claim is written inside prepare() for every kind==='store' operation, including the ordinary unsigned path whose only result is an intent the owner may never sign (state 'prepared', expiresAt now+24h). The vault side writes its claim when a deployment attempt is queued (src/vaults/index.mjs:42). No code path deletes or overrides a project-consumer-claims row (grep: only these two writers), and the signal path additionally rejects on the mere presence of any project-vaults row regardless of state (line 84), whereas vaults.create itself treats 'cancelled'/'blocked'/'failed' attempts as retryable. So an owner who prepares a store transaction once to inspect it and lets it expire can never deploy a native vault for that project, and a vault attempt that ended 'failed' before any contract existed can never be followed by a signal store, although in both cases nothing exists on chain and no verified consumer was recorded. Both refusals use the code `signal_review_context_changed`, which describes a reviewed-context change rather than a profile conflict. Fix options that keep the agreed exclusivity: write the signal claim only when a store becomes 'verified' (confirmed receipt or recovery) and ignore terminal non-verified project-vaults rows; or expose an owner call that releases an unused claim once the operation has expired; and use a dedicated code such as consumer_profile_conflict. Merged from audit_permissions and audit_flow.","line":87,"path":"src/signals/index.mjs","reproduction":"Scratch Node test using createApplication from runtime/server.mjs with chainPorts.signal stubbed (validateAdmission/validateStoreBuild/assertTrackedStore no-ops, prepareStore returning a transaction) and the vault fixture rows from test/vaults.test.mjs (network-capabilities 'vaults', review-pilots, a verified claus-readonly-v1 chain binding on 11155111, cap.policies[p]) plus an enabled, reviewed 'signal:11155111' admission whose factory artifact is stored through app.artifacts.put. Case A: owner calls signals.storePrepare(p,{idempotencyKey:'look'}) -> state 'prepared', no transactionHash, no project-signals row; advance the clock 2 days (operation expired), re-authenticate, call vaults.create(owner,p). Expected: a vault job is queued (nothing exists on chain). Actual: rejects with code signal_review_context_changed and project-consumer-claims/p is still {profile:'claus-signal-v1'}. Case B (mirror): vaults.create -> 'queued'; set project-vaults/p state to 'failed'; signals.storePrepare(owner,p,{idempotencyKey:'store'}) rejects with signal_review_context_changed, while vaults.create(owner,p) immediately afterwards queues a new attempt.","severity":"low","snippet":"        if(!claim)await tx.write('project-consumer-claims',projectId,{profile:'claus-signal-v1'},0);","title":"The consumer-profile claim is taken by the first prepare attempt and never released, so an unsigned expired signal_store_prepare (or a failed vault attempt) permanently closes the other profile with a"},{"citation":"resolved","description":"The contract deliberately lets the owner revoke a signed grant ID before registration and registerGrant still succeeds for that ID (contracts/src/ClausSignalStore.sol:117-123 and :94-107, covered by testOwnerCanRevokeSignedGrantBeforeAndAfterRegistration). The API mirrors neither state when preparing the registration call: grantRegister checks scope, operation kind, signature shape, expiry and the owner signature, but does not consult `signal-revocations` (which revoke() writes for any 32-byte ID at line 203) nor the on-chain grant(id).revoked flag that chain.grant() now returns for unregistered IDs. recordPrepare, by contrast, does reject `signal_api_revoked` (lines 95 and 217). The registration transaction is therefore handed to the publisher, succeeds on chain, consumes the owner's grant nonce permanently (cancelGrantNonce then reverts InvalidGrant), emits PublisherGranted, and every later publish reverts GrantInactive while recordPrepare rejects signal_api_revoked. No authority is gained, so this is a gas/UX and nonce-hygiene gap rather than a security defect. Fix: in grantRegister requireThat no signal-revocations row exists for row.value.grantId and that chain.grant(signal, grantId) is not revoked, rejecting with signal_api_revoked / signal_grant_inactive before any transaction is prepared.","line":178,"path":"src/signals/index.mjs","reproduction":"Scratch Node test with createSignals and the test/signals.test.mjs fixture rows, chain.grant stubbed to return {terms:{publisher:0x0},revoked:true} (what adapters/signal-read.mjs:72 now returns for a revoked unregistered ID). Steps: storePrepare + submit (confirmed) -> grantPrepare(owner,p,{publisher,maximumWrites:24,minimumInterval:3600,expiresAt:now+86400,nonce:'88',idempotencyKey:'grant'}) returns operation G with grantId D, state awaiting_signature -> revoke(owner,p,{grantId:D,idempotencyKey:'revoke'}) returns 'prepared' and grant(owner,p,D).apiRevoked === true -> grantRegister(owner,p,{operationId:G.id,signature:<65 bytes>}). Expected: rejected before a transaction is prepared. Actual: returns state 'prepared' with a registerGrant transaction and chain.registerGrant was invoked once. On chain, contracts/test/scratch/Boundaries.t.sol testPreRevokedRegistrationConsumesNonceAndEmits shows that broadcasting it succeeds, sets grantNonces[owner][88]=true, emits PublisherGranted and makes cancelGrantNonce(88) revert InvalidGrant.","severity":"low","snippet":"      const prepared = await chain.registerGrant(await signal(projectId), current.binding, row.value, signature);","title":"grantRegister prepares the registration transaction for a grant ID the owner has already revoked through the API, so the publisher pays gas to register a dead grant and the nonce is consumed"},{"citation":"resolved","description":"The store requires validUntil <= grant.expiresAt (contracts/src/ClausSignalStore.sol:141) and prepareRecord mirrors it as `input.validUntil <= Number(g.terms.expiresAt)` with error signal_data_expired (adapters/signal-read.mjs:157). The pipeline clamps validUntil to observedAt+maximumAge and to floor(serviceExpiresAt/1000) only (lines 67-68, changed by this commit to drop the run deadline); the grant's expiresAt is a third, independent bound that runtime/pulse-host.mjs never ties to config.schedule.expiresAt (it only checks budget.grantId === config.grantId). Once observedAt + maximumAge exceeds grant.expiresAt, signal_record_prepare throws signal_data_expired, which reference/pulse-scheduler.mjs:53 now lists as terminal, so the slot ends 'failed'; the next slot fails identically until the grant expires and the error becomes signal_grant_inactive. A shorter validUntil equal to grant.expiresAt would have been accepted by both the contract and the API (and by the pipeline's own 900 s margin for any slot more than 15 minutes before expiry), so the reference silently loses up to one maximumAge of publication capacity per grant although maximumWrites is not exhausted; the docs' advice for signal_data_expired ('Obtain a fresh observation') does not help here. Reference-scope gap, not a contract or API defect. Fix: read the grant (signal_grant) and include Number(terms.expiresAt) in the Math.min, or have pulse-host bound config.schedule.expiresAt by grant.expiresAt*1000.","line":67,"path":"reference/pulse-pipeline.mjs","reproduction":"Scratch Node test wiring createPulsePipeline's signal_record_prepare to the real adapters/signal-read.mjs prepareRecord over a mock viem client (projects/pendingProjectOwners/version/grant/latest readContract stubs, getBlock timestamp head=1800000000, call resolving). Rule maximumAge=7200, grant.expiresAt=head+3600, run.serviceExpiresAt=head*1000+86400000, observation observedAt=head. pipeline.step(run) rejects with code signal_data_expired; the prepared body carried validUntil = head+7200 > grant.expiresAt. Calling prepareRecord with the same body but validUntil = grant.expiresAt (head+3600) returns a signable transaction. With createPulseScheduler, signal_data_expired is in the terminal list at reference/pulse-scheduler.mjs:53, so the run ends 'failed' and the next hourly slot repeats the failure.","severity":"low","snippet":"    observedAt:consumed.observation.observedAt,validUntil:Math.min(consumed.observation.observedAt+run.rule.maximumAge,","title":"Reference pipeline never bounds validUntil by the grant's expiresAt, so every slot in the final maximumAge window before grant expiry fails terminally with signal_data_expired"},{"citation":"resolved","description":"The API-side guard compares validUntil with the latest block's timestamp and the subsequent eth_call at line 165 executes in that same latest-block context, so both pass for validUntil = head.timestamp + 1. The store (contracts/src/ClausSignalStore.sol:141) requires `validUntil > block.timestamp` of the including block, which on mainnet and Sepolia is at least one slot (12 s) later than head. Any validUntil in (head.timestamp, head.timestamp+12] therefore passes preparation and simulation but cannot execute. src/signals/index.mjs:213 only requires validUntil >= floor(now/1000)+1. For the hosted reference publisher the reverted transaction is still settled as spent against the finite budget (adapters/signal-publisher.mjs finalize) and the operation ends signal_transaction_reverted; the reference pipeline avoids the window with its own 900 s margin, so this affects direct API users of signal_record_prepare. The same single-block lag applies to the ClausPulseModule staleness check, which prepareRecord does not pre-check. No data is written and no authority is affected. Fix: require validUntil > head.timestamp + a slot margin (e.g. 60 s) in prepareRecord and in recordPrepare's integer bound.","line":156,"path":"adapters/signal-read.mjs","reproduction":"Scratch Node test: createSignalRead({client,chainId:1}) with a mock client whose latest block has timestamp 1800000000 and whose call() resolves. prepareRecord(signal,binding,{grantId,sequence:1,expectedVersion:1,observedAt:1800000000,validUntil:1800000001,sourceHash,data}) returns a signable transaction and performed exactly one eth_call. Foundry (contracts/test/scratch/Boundaries.t.sol testValidUntilOneSecondAfterHeadRevertsInNextBlock): publish(grantId,1,1,head,head+1,...) succeeds when executed at block.timestamp == head (the simulation context) and reverts InvalidRecord at block.timestamp == head+12 (the earliest mainnet inclusion). Expected: a record the API prepares and simulates can execute; actual: deterministic revert and spent gas.","severity":"low","snippet":"      requireThat(input.validUntil > Number(head.timestamp) && input.observedAt <= Number(head.timestamp) &&","title":"prepareRecord accepts and simulates validUntil values inside the next slot (head.timestamp+1 .. head.timestamp+12) that are guaranteed to revert InvalidRecord when mined, charging the publisher for a "},{"citation":"resolved","description":"The API accepts expiresAt up to floor(now/1000)+30*86400 inclusive against the API host's wall clock, while ClausSignalStore.registerGrant (contracts/src/ClausSignalStore.sol:102) rejects `g.expiresAt > block.timestamp + MAX_GRANT_DURATION` against the including block's timestamp. Block timestamps are slot starts and trail the wall clock by up to ~12 s, and the API host's clock may be ahead of chain time, so a grant prepared at the cap is valid for the API and for the owner's EIP-712 signature but reverts if it is included in a block whose timestamp is below the second at which the API computed the cap. registerGrant in adapters/signal-read.mjs builds the intent without simulating it, so nothing catches this before broadcast. In practice preparation, owner signature and broadcast rarely complete within one slot, so the window is narrow; the signed terms register normally once block.timestamp reaches the API's second. Numeric seam between two clocks, no funds or authority affected. Fix: cap the API maximum at floor(now/1000)+30*86400 minus a slot margin (e.g. 60 s), or simulate registerGrant before returning the intent.","line":167,"path":"src/signals/index.mjs","reproduction":"API wall clock 1800000005 s: grantPrepare accepts expiresAt = 1800000005+2592000 = 1802592005 (equal to the maximum). Foundry (contracts/test/scratch/Boundaries.t.sol testGrantAtApiCapRevertsWhenBlockTimestampLagsWallClock): with block.timestamp = 1800000000, registerGrant(Grant(publisher,1,2,60,1802592005,7), ownerSignature) reverts InvalidGrant because 1802592005 > 1800000000+2592000; after vm.warp(1800000005) the same grant and signature register successfully.","severity":"info","snippet":"      integer(input.expiresAt,Math.floor(now()/1000)+60,Math.floor(now()/1000)+30*86400);","title":"grantPrepare's upper bound equals the contract maximum exactly, so a grant prepared at the API cap reverts InvalidGrant if the registering block's timestamp is below the API host's wall-clock second"},{"citation":"resolved","description":"revokeGrant(id) before registration sets grants[id].revoked = true with terms.publisher == 0, so registerGrant() does not take the early return at line 97, runs the full validation, marks grantNonces[owner][nonce] = true, stores the terms and emits PublisherGranted even though publish() will always revert GrantInactive. The security outcome is correct and documented in docs/signals.md ('registering it later cannot undo the revocation'), and it is covered by testOwnerCanRevokeSignedGrantBeforeAndAfterRegistration. Two side effects are worth an explicit decision: an indexer that treats PublisherGranted as 'active grant' without also reading grant(id).revoked shows a live publisher, and the owner can no longer cancelGrantNonce(nonce) for that nonce. Recorded as limited-scope design rather than a defect; a one-line guard (`if (grants[id].revoked) revert InvalidGrant();` or skipping the event) would make the event authoritative, at the cost of a small bytecode change to an already reviewed contract.","line":106,"path":"contracts/src/ClausSignalStore.sol","reproduction":"Foundry (contracts/test/scratch/Boundaries.t.sol testPreRevokedRegistrationConsumesNonceAndEmits): g = Grant(publisher,1,2,60,now+1 days,88); id = sink.grantDigest(g); vm.prank(owner); sink.revokeGrant(id); then sink.registerGrant(g, sign(OWNER,id)) emits PublisherGranted(id, publisher, 1, 2, expiresAt) (vm.expectEmit passes); afterwards sink.grant(id).revoked == true, sink.grantNonces(owner,88) == true, and vm.prank(owner); sink.cancelGrantNonce(88) reverts InvalidGrant.","severity":"info","snippet":"        emit PublisherGranted(id, g.publisher, generation, g.maximumWrites, g.expiresAt);","title":"Design limit, documented: registering a grant whose digest was revoked before registration still consumes the owner nonce and emits PublisherGranted for a grant that can never publish"},{"citation":"resolved","description":"adapters/sepolia-transactions.mjs:132-144 correctly releases an expired unknown transaction only when finalizedReplacement() (adapters/transaction-replacement.mjs) finds the exact competing sender+nonce transaction with a canonical finalized receipt, records {state:'cancelled', replacement} and never sends a cancellation; verified by test/transaction-replacement.test.mjs and by reading the binary search and receipt/block-hash checks. By then the original hash was already attached to the signal operation by the pipeline's signal_submit call while the row was 'submitted'/'unknown'. chain.observe() for that hash keeps returning {state:'pending'} (TransactionNotFoundError), so signal_operation and signal_latest consumers see a permanently pending operation while the host ledger holds definitive evidence that it can never execute. This matches the documented rule ('pending does not mean failure; hashes are never replaced') and no state is lost, so it is a reporting gap, not a defect: the engine's replacement evidence (competing hash, finalized block) could be propagated to the operation as a terminal 'replaced' outcome without inventing or replacing a hash.","line":115,"path":"src/signals/index.mjs","reproduction":"State: signal operation op1 (kind record) with transactionHash H attached; chain-transactions row 'signal-record:op1' in state 'unknown' with intent.expiresAt passed; finalized chain contains a different transaction H2 from the same publisher with the same nonce. transactions.reconcile('signal-record:op1') -> row becomes {state:'cancelled', replacement:{transactionHash:H2,...}} and the pipeline run ends 'failed' with error 'signal_transaction_cancelled' (adapters/signal-publisher.mjs:81-83). signals.operation(actor, projectId, op1) afterwards -> reconcile() -> chain.observe(action,H) returns {state:'pending'} (adapters/signal-read.mjs:176) -> line 115 returns the row unchanged, forever. Expected from a consumer's point of view: a terminal state carrying the replacement evidence; actual: 'pending' with no path to any terminal state.","severity":"info","snippet":"    if (observed.state === 'pending') return view(row);","title":"Design limit: a finalized replacement proven by the transaction engine is never surfaced to the API signal operation, which reports 'pending' indefinitely"},{"citation":"resolved","description":"The publication expiry is derived from the observation plus the on-chain maximumAge, while src/services/public-results.mjs:87 enforces `integer(expiresAt, now() + 1000, manifest.validUntil)` against the API host's clock at publication time. reference/pulse-providers.mjs:28 only requires observedAt > floor(now/1000)-3600 at provider execution time, so with an on-chain maximumAge of 3600 (the value used by the project's own local fixture; the documented two-hour window cannot hit this) an observation accepted near that limit yields step.expiresAt <= now()+1000 by the time the consumer pipeline publishes it, and service_result_publish throws invalid_integer. That code is not in the scheduler's terminal list (reference/pulse-scheduler.mjs:50-53) and line 69's pulse_result_expiring check is never reached, so the job is re-queued with retryAt now+60000 and re-fails identically (step.expiresAt is checkpointed) until run.expiresAt passes and `run.expiresAt<=now()` makes it terminal. Needs a finality stall of roughly 45 minutes or more; bounded by one interval and wastes only reference API calls; no state is corrupted. Fix: treat invalid_integer from service_result_publish as terminal (or compute step.expiresAt with the same margin as line 69 and fail pulse_result_expiring), or reject observations whose observedAt+maximumAge is within a margin of now before accepting the delivered order.","line":34,"path":"reference/pulse-pipeline.mjs","reproduction":"Code trace with concrete values: maximumAge = 3600, observer executes at t0 = 1800000000000 ms with finalized block timestamp 1799996401 (3599 s old; passes `observedAt > floor(now/1000)-3600` at reference/pulse-providers.mjs:28). Consumer pipeline reaches line 34 at t0+30000: step.expiresAt = min((1799996401+3600)*1000, serviceExpiresAt) = 1800001000 ms. publicResult() -> service_result_publish -> src/services/public-results.mjs:87 integer(1800001000, 1800030000+1000, ...) -> invalid_integer. Scheduler: not in the terminal list and run.expiresAt > now, so finish(state:'waiting', retryAt: now+60000); each retry reuses the checkpointed step.expiresAt and fails again until run.expiresAt (<= intervalMs) passes.","severity":"info","snippet":"   step.expiresAt=Math.min((observedAt+run.rule.maximumAge)*1000,run.serviceExpiresAt);await checkpoint(run);","title":"Observation publication expiry can fall below the public-result service's now+1000 ms floor, raising an unclassified invalid_integer that the scheduler retries every 60 s until the run deadline"}],"hash":"8b97a77035e7dc97173b9ff374f442bfc90c6787381964ca02cef5a423bfeef3","nodeId":"0cdc6ae4-f348-4e7d-942e-09b8870789ce","outcome":"completed","summary":"The review is complete and `.imd-findings.json` holds 10 substantiated findings. No source files were changed; the only additions are the findings file and a scratch Foundry test under `contracts/test/scratch/`.\n\n**What I verified about the correction itself**\n\n- All 10 signal-store Foundry tests and the 34 relevant Node suites pass on this tree, including the new regression tests for the constructor bypass, pre-registration revocation, consumed-nonce cancellation, finalized factory recovery, untracked-store rejection, wrong-hash rejection, status split and scheduler retention.\n- The request client accepts chains 1 and 11155111 only, and its real local-chain prepare and receipt-recovery test passed against an anvil on chain id 1.\n- PulseModule, SignalTypes and SignalFactory are byte-identical to the signal-kernel commit. The replacement engine only cancels on an exact sender plus nonce competitor with a canonical finalized receipt and never broadcasts.\n- Proposal creation, later approval checks and execution all route through the tracked-store assertion, so untracked canonical stores fail closed.\n\n**Findings kept, all reproduced with scratch tests or a Foundry run**\n\n- **Medium.** Public capabilities and `/health` now reject on any signal-chain RPC or evidence error, because the live bytecode check was added to the shared admission path and discovery only catches the three old storage codes. Reproduced for four error kinds. Merged from two specialists.\n- **Low.** The reference scheduler treats any chain-transactions row as lane-retaining, including cancelled, reverted, confirmed and never-broadcast prepared rows. With a persistent pre-reconcile error, the lane is blocked forever. Merged from two specialists.\n- **Low.** The consumer-profile claim is written on the first unsigned prepare and is never released, so an expired, never-signed store preparation permanently blocks vault creation, and a failed vault attempt permanently blocks a signal store. Reproduced both directions through the real application wiring. Merged from two specialists.\n- **Low.** Grant registration ignores the API's own revocation row and the on-chain revoked flag, so the publisher is handed a transaction that consumes the nonce and registers a dead grant.\n- **Low.** The reference pipeline never bounds validity by grant expiry, so every slot in the final maximum-age window fails terminally.\n- **Low.** Record preparation accepts and simulates validity timestamps inside the next slot that are guaranteed to revert when mined.\n- **Info.** Grant cap equal to the contract maximum can revert when the block timestamp trails the API clock. Pre-revoked registration consumes the nonce and emits the grant event, a documented design limit. A proven replacement is never surfaced to the API operation, also a design limit. An observation near the provider staleness limit can trip the publication floor and retry for one interval.\n\n**Dropped**\n\n- The EIP-7702 observations from two specialists. The behaviour is already documented in the signals guide as EOA-only with 7702 excluded, and it fails closed, so it is a trust assumption rather than a defect.\n\nNo finding reached high or critical, so no Foundry proof files were attached.","treeHash":null,"usage":{"cachedInputTokens":3431385,"inputTokens":738,"model":"claude-fable-5-1","outputTokens":52334,"runtime":"claude","turns":59,"wallClockMs":732817}}],"verification":[]}