{"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":"4bfe5f40-24e0-4429-b930-397b3927ff5e","kind":"audit","nodes":[{"acceptedSubmissionHash":"9d34dee3ccb76686b2b3b679c66399f4c19627590ac90e9cf824352025658d42","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":"385049ae21a550f6c99c5473f8bfb2fcd96bf0b8eeb79a2ebd292bf29ec1b4f0","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":"0997ca0bc4ab253a18b7cb0e7cebd6abe47b00d38d6849ec60658e6420df9f82","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":"ccd10833d7538730deb40693942b5062998302b7f3d76e93b20a0859837dff29","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":"1ebe5e1ff30abd34a6fcdd8702b6402fd4e7b55ab18e449285e605a01a2f71a3","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":"Independently review the new bounded public signal contracts in this exact commit. Scope: contracts/src/ClausSignalTypes.sol, ClausSignalStore.sol, ClausSignalFactory.sol, ClausPulseModule.sol and their integration with the existing immutable ClausApprovalExecutor and ClausReadonlyProject. Compare against b265c21f9fb79e4e3af7d344f647eb50406eb955. No new release is active. Existing Ethereum readonly projects and token/LP/NFT contracts remain unchanged. Inspect EIP712 owner-signed finite grants, chain/store/project/feed/schema/publisher/generation/nonce binding, replay/idempotency/revocation, pending and completed ownership changes, generation changes, limits, expiration, monotonic sequences, expected evaluator versions, authentic evaluator context, STATICCALL and bounded return bytes, project target verification and fixed factory deployment. The owner and publisher are EOAs only; EIP7702 is intentionally excluded. Max 2KiB input,512-byte output,200k gas evaluation. Grant max10000 writes and30days; multiple separately owner-approved grants are intentional, not a global lifetime limit. Zero mandatory upgrade delay is owner-selected. New module/config changes still require the existing independent review and genuine Claus signed approval. Publishers attest source data; hashes and the review are not proof of data truth, economic guarantees or approval of unknown downstream financial use. Store immutable, evaluator upgradeable only via existing executor; no custody, Ether, tokens, arbitrary calls or delegatecall introduced. Read new tests and existing relevant executor invariants; produce concrete reproducible findings with severity and exploitability, distinguish deliberate limited profile behavior from defects. Do not change code, deploy, send transactions, use paid subjobs or publish anything. This review is of the contract kernel; the new runtime, host policy and client integration receive separate review before activation.","parentJobId":null,"planHash":"3c3aef2c9555f6ef83d6110bd92f7237c9508415b4e5b6d31164a4e81296900b","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"4bfe5f40-24e0-4429-b930-397b3927ff5e","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":"52178","feedbackHash":"ec1de56e30f44dae60a4ebaf36a8b39db26fefddf7d016fe4c8819336a2519a6","nodeKey":"audit_economics","submissionHash":"9d34dee3ccb76686b2b3b679c66399f4c19627590ac90e9cf824352025658d42","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52174","feedbackHash":"c79b8c0b8873642395451ad8b341858ca3a95b62a2e0e07213e053caad3509b6","nodeKey":"audit_flow","submissionHash":"385049ae21a550f6c99c5473f8bfb2fcd96bf0b8eeb79a2ebd292bf29ec1b4f0","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51510","feedbackHash":"11a80a50e3e04eafc09c6ba24534069dc817f7c4be131101e186cda644aec188","nodeKey":"audit_judge","submissionHash":"0997ca0bc4ab253a18b7cb0e7cebd6abe47b00d38d6849ec60658e6420df9f82","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51347","feedbackHash":"29e221d2d4d5b7e022a8208d7fa60bde00e6a83b1a5eef17cdca9685d2331572","nodeKey":"audit_math","submissionHash":"ccd10833d7538730deb40693942b5062998302b7f3d76e93b20a0859837dff29","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52176","feedbackHash":"4af9584ee5f27b19d9c9422056c0f35ca83642be03a32cc03b216b964f054076","nodeKey":"audit_permissions","submissionHash":"1ebe5e1ff30abd34a6fcdd8702b6402fd4e7b55ab18e449285e605a01a2f71a3","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"fd9b6a307a79d75751ff5ee8336a4ae578dfc71a06806712094876123283ee40","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"8d428b115b0ebd64","findings":[{"citation":"resolved","description":"The profile states owner and publisher are EOAs only (EIP-7702 excluded). For the publisher the store enforces this with `g.publisher.code.length != 0` in registerGrant (line 99) and `msg.sender.code.length != 0` in publish (line 133). EXTCODESIZE is zero for a counterfactual CREATE/CREATE2 address and for a contract still running its constructor, so a publisher can hand the owner a code-less CREATE2 address (indistinguishable from an EOA at signing time; registerGrant accepts it) and then call publish from the constructor of a contract deployed at that address. The write succeeds, the record and grant write counter advance. With Cancun/EIP-6780 a selfdestruct in the creation transaction empties the account again, so the identical address publishes the next sequence too (verified: sequence 2 lands after a second deploy). The owner's EOA check is sound only because the owner must produce an ECDSA signature; the publisher proves nothing but msg.sender. Impact is bounded: the actor is the owner-chosen publisher, data still passes the module, writes stay within maximumWrites/minimumInterval/expiresAt and the store holds no assets. What a contract publisher gains is exactly what EOA-only was meant to exclude: multi-key or governance control of the publisher identity and atomic composition of publish with other calls in one transaction. Merged from audit_permissions (medium) and audit_economics (low); judged low because no authority beyond the publisher role is gained. Minimal fix preserving the design: in publish also require `msg.sender == tx.origin` next to the code-length check (an EIP-7702 delegated origin still has code and stays excluded); existing tests that `vm.prank(publisher)` become `vm.prank(publisher, publisher)`. Alternatively document that publishers may be contracts and drop the EOA claim.","line":133,"path":"contracts/src/ClausSignalStore.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.27;\nimport {Test} from \"forge-std/Test.sol\";\nimport {ClausApprovalExecutor} from \"src/ClausApprovalExecutor.sol\";\nimport {ClausReadonlyProject} from \"src/ClausReadonlyProject.sol\";\nimport {ClausSignalStore} from \"src/ClausSignalStore.sol\";\nimport {ClausPulseModule} from \"src/ClausPulseModule.sol\";\n\ninterface IPending { function pending() external view returns (ClausSignalStore, bytes32, bytes memory); }\n\n/// A contract publisher. While its constructor runs its code length is zero, so\n/// ClausSignalStore.publish's `msg.sender.code.length != 0` guard does not see it.\ncontract ConstructorPublisher {\n    constructor() {\n        (ClausSignalStore sink, bytes32 grantId, bytes memory data) = IPending(msg.sender).pending();\n        sink.publish(grantId, 1, 1, uint64(block.timestamp), uint64(block.timestamp + 300), keccak256(\"src\"), data);\n    }\n}\n\ncontract ProofPublisherEoaCheckTest is Test {\n    uint256 constant OWNER = 0xB0B;\n    uint256 constant CLAUS = 0xA11CE;\n    bytes32 constant MANDATE = keccak256(\"signal-test\");\n    bytes32 constant POLICY = keccak256(\"readonly-test\");\n    bytes32 constant SALT = keccak256(\"counterfactual-publisher\");\n    ClausApprovalExecutor executor;\n    ClausReadonlyProject target;\n    ClausSignalStore sink;\n    ClausPulseModule module;\n    bytes32 projectId;\n    bytes32 grantId;\n\n    function pending() external view returns (ClausSignalStore, bytes32, bytes memory) {\n        return (sink, grantId, abi.encode(block.chainid, uint64(123), keccak256(\"source-block\"), uint64(block.timestamp), uint256(2 gwei)));\n    }\n    function sign(uint256 key, bytes32 hash) internal pure returns (bytes memory) {\n        (uint8 v, bytes32 r, bytes32 s) = vm.sign(key, hash); return abi.encodePacked(r, s, v);\n    }\n    function setUp() public {\n        vm.warp(1800000000); vm.roll(1000); vm.chainId(11155111);\n        executor = new ClausApprovalExecutor(vm.addr(CLAUS), vm.addr(OWNER), 1 days);\n        module = new ClausPulseModule();\n        bytes32 salt = keccak256(\"pulse\"); projectId = executor.projectKey(vm.addr(OWNER), salt);\n        bytes memory config = abi.encode(uint256(1 gwei), uint256(3600));\n        ClausApprovalExecutor.Registration memory r = ClausApprovalExecutor.Registration(projectId, vm.addr(OWNER), salt,\n            address(module), address(module).codehash, keccak256(config), MANDATE, POLICY, 0, 0, uint64(block.timestamp + 1 days));\n        target = ClausReadonlyProject(executor.registerProject(r, config, sign(OWNER, executor.registrationDigest(r))));\n        sink = new ClausSignalStore(address(executor), projectId, address(target), module.FEED(), module.SCHEMA());\n        // The owner is handed a code-less address (a counterfactual CREATE2 contract) and signs a grant for it.\n        address counterfactual = vm.computeCreate2Address(SALT, keccak256(type(ConstructorPublisher).creationCode), address(this));\n        assertEq(counterfactual.code.length, 0);\n        ClausSignalStore.Grant memory g = ClausSignalStore.Grant(counterfactual, 1, 10, 1, uint64(block.timestamp + 1 days), 0);\n        grantId = sink.registerGrant(g, sign(OWNER, sink.grantDigest(g)));\n    }\n\n    /// Expected: a contract publisher is rejected with PublisherOnly. Actual: it publishes.\n    function testContractPublisherIsRejected() public {\n        vm.expectRevert(ClausSignalStore.PublisherOnly.selector);\n        new ConstructorPublisher{salt: SALT}();\n    }\n\n    /// Expected: no record is ever written by a contract. Actual: sequence 1 is written and a grant write is consumed.\n    function testContractPublisherCannotWriteRecords() public {\n        try new ConstructorPublisher{salt: SALT}() {} catch {}\n        assertEq(sink.latest().sequence, 0, \"a contract publisher wrote a record\");\n        assertEq(sink.grant(grantId).used, 0, \"a contract publisher consumed a grant write\");\n    }\n}","reproduction":"Setup as test/ClausSignalStore.t.sol (owner 0xB0B, chainId 11155111, timestamp 1800000000, project with ClausPulseModule, store deployed). Compute A = CREATE2 address of a contract whose constructor calls sink.publish(grantId, 1, 1, now, now+300, keccak256('src'), validPulseData); A.code.length == 0. Owner signs Grant(publisher=A, generation 1, maximumWrites 10, minimumInterval 1, expiresAt now+1d, nonce 0); registerGrant succeeds. Deploy the contract at A. Expected: revert PublisherOnly, latest().sequence == 0, grant(grantId).used == 0. Actual: call succeeds, latest().sequence == 1, grant(grantId).used == 1. Run from contracts/: forge test --match-path test/scratch/Proof_0968aacbcc89.t.sol -> both tests FAIL on this commit ('next call did not revert as expected', 'a contract publisher wrote a record: 1 != 0'); against a scratch copy of the store with `msg.sender != tx.origin` added to the line-133 check both PASS. Specialist proof Proof_2efbc0058ddc.t.sol additionally shows the same address publishing sequence 2 after a same-tx selfdestruct (also fails here, passes with the fix).","severity":"low","snippet":"        if (msg.sender != g.publisher || msg.sender.code.length != 0) revert PublisherOnly();","title":"Publisher EOA-only guard is only a code-length check; contract code publishes from its constructor (CREATE2 counterfactual publisher, repeatable via same-tx selfdestruct)"},{"citation":"resolved","description":"registerGrant (line 94) has no caller restriction, so anyone holding the owner's EIP-712 signature can register the grant. The owner's two withdrawal tools have disjoint coverage that switches at registration: cancelGrantNonce (lines 109-114) only matters while the grant is unregistered, and once registerGrant has set grantNonces[owner][nonce] (line 104) the cancel still succeeds and emits GrantNonceCancelled but changes nothing; revokeGrant (lines 116-122) reverts InvalidGrant at line 119 for any digest not yet registered, so the owner cannot pre-revoke by digest. A publisher (or anyone it shared the signature with) who sees the owner's cancelGrantNonce in the mempool registers first, optionally bundling the first publish (the first write is exempt from minimumInterval, line 135); the grant is then live until the owner notices the ineffective cancel and sends a separate revokeGrant. The owner, an EOA by requirement (line 99), cannot make register-then-revoke atomic. The code comment at line 96 and testGrantCannotCrossChainsStoresSignersOrCancelledNonces present nonce cancellation as the complete pre-activation control; the owner's tooling gets no on-chain signal that the cancel did nothing. Exposure is bounded (owner-chosen publisher, module-validated data, grant limits, revokeGrant still available), hence low. Merged from audit_math (low), audit_flow (low) and audit_permissions (info). Minimal fix preserving design: let revokeGrant mark any digest revoked (drop the line-119 existence check): registerGrant leaves the revoked flag untouched, so a pre-revoked grant registers into a dead state and publish reverts GrantInactive at line 134. Additionally make cancelGrantNonce revert (or revoke the registered grant via an owner=>nonce=>grantId map) when the nonce is already consumed.","line":119,"path":"contracts/src/ClausSignalStore.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.27;\nimport {Test} from \"forge-std/Test.sol\";\nimport {ClausApprovalExecutor} from \"src/ClausApprovalExecutor.sol\";\nimport {ClausReadonlyProject} from \"src/ClausReadonlyProject.sol\";\nimport {ClausSignalStore} from \"src/ClausSignalStore.sol\";\nimport {ClausPulseModule} from \"src/ClausPulseModule.sol\";\n\n/// A signed grant can be registered by anyone. The owner has two withdrawal\n/// tools, but neither works in the state the other covers: cancelGrantNonce\n/// only matters before registration, revokeGrant only after. A third party\n/// who registers the grant ahead of the owner's cancellation activates it.\n/// The owner must be able to kill a signed grant it knows the digest of in\n/// one transaction, whatever a front-runner does.\ncontract GrantPreRevocationTest is Test {\n    uint256 constant OWNER = 0xB0B;\n    uint256 constant CLAUS = 0xA11CE;\n    uint256 constant PUBLISHER = 0xBEEF;\n    bytes32 constant MANDATE = keccak256(\"signal-test\");\n    bytes32 constant POLICY = keccak256(\"readonly-test\");\n    ClausApprovalExecutor executor;\n    ClausReadonlyProject target;\n    ClausSignalStore sink;\n    ClausPulseModule module;\n    bytes32 projectId;\n\n    function sign(uint256 key, bytes32 hash) internal pure returns (bytes memory) {\n        (uint8 v, bytes32 r, bytes32 s) = vm.sign(key, hash); return abi.encodePacked(r, s, v);\n    }\n    function setUp() public {\n        vm.warp(1800000000); vm.chainId(11155111);\n        executor = new ClausApprovalExecutor(vm.addr(CLAUS), vm.addr(OWNER), 1 days);\n        module = new ClausPulseModule();\n        bytes32 salt = keccak256(\"pulse\"); projectId = executor.projectKey(vm.addr(OWNER), salt);\n        bytes memory config = abi.encode(uint256(1 gwei), uint256(3600));\n        ClausApprovalExecutor.Registration memory r = ClausApprovalExecutor.Registration(projectId, vm.addr(OWNER), salt,\n            address(module), address(module).codehash, keccak256(config), MANDATE, POLICY, 0, 0, uint64(block.timestamp + 1 days));\n        target = ClausReadonlyProject(executor.registerProject(r, config, sign(OWNER, executor.registrationDigest(r))));\n        sink = new ClausSignalStore(address(executor), projectId, address(target), module.FEED(), module.SCHEMA());\n    }\n    function pulse(uint64 observed) internal view returns (bytes memory) {\n        return abi.encode(block.chainid, uint64(123), keccak256(\"source-block\"), observed, uint256(2 gwei));\n    }\n\n    /// Owner signed a grant, then decides to withdraw it before anyone registered it.\n    /// Expected: one owner transaction naming the grant leaves it unusable even if a\n    /// third party registers the signed terms afterwards (or in the same block, ahead of it).\n    /// Actual: revokeGrant reverts with InvalidGrant for an unregistered digest, the\n    /// third party registers the grant, and the publisher writes a record.\n    function testOwnerCanWithdrawSignedGrantBeforeRegistration() public {\n        uint64 now64 = uint64(block.timestamp);\n        ClausSignalStore.Grant memory g = ClausSignalStore.Grant(vm.addr(PUBLISHER), 1, 10, 1, now64 + 1 days, 77);\n        bytes memory sig = sign(OWNER, sink.grantDigest(g));\n        bytes32 id = sink.grantDigest(g);\n\n        // Owner withdraws the grant by its digest, the only identifier it has for a signed grant.\n        vm.prank(vm.addr(OWNER));\n        sink.revokeGrant(id);\n\n        // Anyone may still submit the signed terms afterwards.\n        vm.prank(address(0xDEAD));\n        sink.registerGrant(g, sig);\n\n        // The withdrawn grant must not be usable.\n        assertTrue(sink.grant(id).revoked, \"withdrawn grant must stay revoked\");\n        vm.prank(vm.addr(PUBLISHER));\n        vm.expectRevert(ClausSignalStore.GrantInactive.selector);\n        sink.publish(id, 1, 1, now64, now64 + 60, keccak256(\"s\"), pulse(now64));\n        assertEq(sink.latest().sequence, 0, \"no record may be published under a withdrawn grant\");\n    }\n}","reproduction":"Setup as test/ClausSignalStore.t.sol. Owner signs Grant(publisher=vm.addr(0xBEEF), generation 1, maximumWrites 10, minimumInterval 1, expiresAt now+1d, nonce 77). (1) Owner calls revokeGrant(grantDigest(g)) before registration: expected the grant to be dead; actual revert InvalidGrant() at line 119; 0xDEAD then calls registerGrant(g, sig) successfully and the publisher's publish(id, 1, 1, now, now+60, keccak256('s'), pulse) lands (latest().sequence == 1). (2) Race variant: 0xDEAD registers Grant(..., nonce 7) first; owner calls cancelGrantNonce(7): succeeds, emits GrantNonceCancelled(owner, 7), grant(id).revoked == false; publisher publishes sequence 1 under id (judge scratch test testCancelNonceAfterFrontRunRegistrationIsNoOp in test/scratch/JudgeRepro.t.sol asserts this state). Run from contracts/: forge test --match-path test/scratch/Proof_2157a573390a.t.sol -> FAIL with InvalidGrant() on this commit; PASS against a scratch copy of the store with line 119 removed.","severity":"low","snippet":"        if (grants[id].terms.publisher == address(0)) revert InvalidGrant();","title":"Signed grant cannot be withdrawn before registration: revokeGrant rejects unregistered digests and cancelGrantNonce is a silent no-op once a permissionless registerGrant front-runs it"},{"citation":"resolved","description":"The module already rejects observations whose chainId differs from block.chainid and whose timestamp lies in the future or is older than maximumAge (lines 23-25), i.e. it sanity-checks the parts of the attestation that are checkable on-chain. It applies no comparable bound to the observed block number: any nonzero `number`, including one far above block.number, with any nonzero hash is accepted and committed to the record and the DataPublished event. Publisher attestation is the documented trust model, so a plausible-but-wrong block or base fee is deliberate scope; a block number greater than the current head is provably false at zero cost and is not attestable data on the same chain. Consumers then receive a record whose block number can never be matched against chain history. Low: the actor is the owner-chosen publisher and the module is an example transformation, but the asymmetry with the timestamp check is a defect in the module's own validation. Merged from audit_flow and audit_economics. Fix: add `number > block.number` to the InvalidObservation condition. Optionally, when block.number - number < 256, also require `hash == blockhash(number)`; that is an enhancement beyond the attestation model.","line":23,"path":"contracts/src/ClausPulseModule.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.27;\nimport {Test} from \"forge-std/Test.sol\";\nimport {ClausApprovalExecutor} from \"src/ClausApprovalExecutor.sol\";\nimport {ClausReadonlyProject} from \"src/ClausReadonlyProject.sol\";\nimport {ClausSignalStore} from \"src/ClausSignalStore.sol\";\nimport {ClausPulseModule} from \"src/ClausPulseModule.sol\";\n\ncontract ProofPulseFutureBlockTest is Test {\n    uint256 constant OWNER = 0xB0B;\n    uint256 constant CLAUS = 0xA11CE;\n    uint256 constant PUBLISHER = 0xBEEF;\n    bytes32 constant MANDATE = keccak256(\"signal-test\");\n    bytes32 constant POLICY = keccak256(\"readonly-test\");\n    ClausApprovalExecutor executor;\n    ClausReadonlyProject target;\n    ClausSignalStore sink;\n    ClausPulseModule module;\n    bytes32 projectId;\n    bytes32 grantId;\n\n    function sign(uint256 key, bytes32 hash) internal pure returns (bytes memory) {\n        (uint8 v, bytes32 r, bytes32 s) = vm.sign(key, hash); return abi.encodePacked(r, s, v);\n    }\n    function setUp() public {\n        vm.warp(1800000000); vm.roll(1000); vm.chainId(11155111);\n        executor = new ClausApprovalExecutor(vm.addr(CLAUS), vm.addr(OWNER), 1 days);\n        module = new ClausPulseModule();\n        bytes32 salt = keccak256(\"pulse\"); projectId = executor.projectKey(vm.addr(OWNER), salt);\n        bytes memory config = abi.encode(uint256(1 gwei), uint256(3600));\n        ClausApprovalExecutor.Registration memory r = ClausApprovalExecutor.Registration(projectId, vm.addr(OWNER), salt,\n            address(module), address(module).codehash, keccak256(config), MANDATE, POLICY, 0, 0, uint64(block.timestamp + 1 days));\n        target = ClausReadonlyProject(executor.registerProject(r, config, sign(OWNER, executor.registrationDigest(r))));\n        sink = new ClausSignalStore(address(executor), projectId, address(target), module.FEED(), module.SCHEMA());\n        ClausSignalStore.Grant memory g = ClausSignalStore.Grant(vm.addr(PUBLISHER), 1, 10, 1, uint64(block.timestamp + 1 days), 0);\n        grantId = sink.registerGrant(g, sign(OWNER, sink.grantDigest(g)));\n    }\n\n    /// Directly on the module: an observation of block 1,001,000 while the chain is at block 1,000.\n    /// Expected: InvalidObservation (the block cannot exist). Actual: accepted and classified.\n    function testModuleRejectsBlockNumberAboveChainHead() public {\n        bytes memory observation = abi.encode(block.chainid, uint64(block.number + 1_000_000), keccak256(\"x\"), uint64(block.timestamp), uint256(2 gwei));\n        bytes memory input = abi.encode(ClausSignalTypesInput(block.chainid, address(sink), projectId, module.FEED(), module.SCHEMA(),\n            vm.addr(PUBLISHER), 1, uint64(block.timestamp), uint64(block.timestamp + 300), keccak256(\"src\"), observation));\n        vm.expectRevert(ClausPulseModule.InvalidObservation.selector);\n        module.evaluate(abi.encode(uint256(1 gwei), uint256(3600)), input);\n    }\n\n    /// Through the store: the same observation becomes the published record.\n    function testStoreDoesNotPublishBlockNumberAboveChainHead() public {\n        bytes memory observation = abi.encode(block.chainid, uint64(block.number + 1_000_000), keccak256(\"x\"), uint64(block.timestamp), uint256(2 gwei));\n        vm.prank(vm.addr(PUBLISHER));\n        vm.expectRevert(ClausReadonlyProject.ModuleCallFailed.selector);\n        sink.publish(grantId, 1, 1, uint64(block.timestamp), uint64(block.timestamp + 300), keccak256(\"src\"), observation);\n    }\n\n    // Mirror of ClausSignalTypes.Input, declared locally so the test only depends on the contracts under review.\n    struct ClausSignalTypesInput {\n        uint256 chainId; address store; bytes32 projectId; bytes32 feedId; bytes32 schemaHash; address publisher;\n        uint64 sequence; uint64 observedAt; uint64 validUntil; bytes32 sourceHash; bytes data;\n    }\n}","reproduction":"Setup as test/ClausSignalStore.t.sol plus vm.roll(1000); config (1 gwei, 3600); active grant. data = abi.encode(block.chainid, uint64(block.number + 1_000_000), keccak256('x'), uint64(block.timestamp), uint256(2 gwei)). (a) module.evaluate(abi.encode(1 gwei, 3600), abi.encode(Input{...data})) directly: expected InvalidObservation; actual returns abi.encode(1001000, keccak256('x'), 2 gwei, true). (b) publisher calls sink.publish(grantId, 1, 1, now, now+300, keccak256('src'), data): expected ModuleCallFailed; actual succeeds and latest().output decodes to block 1001000. Run from contracts/: forge test --match-path test/scratch/Proof_81cd19443c98.t.sol -> both tests FAIL on this commit ('next call did not revert as expected'); both PASS against a scratch copy of the module with `number > block.number` added at line 23.","severity":"low","snippet":"        if (chainId != context.chainId || number == 0 || hash == bytes32(0) || timestamp != context.observedAt ||","title":"ClausPulseModule accepts an attested block number above the chain head"},{"citation":"resolved","description":"revokeGrant (line 120) sets only grants[id].revoked; setAuthority/proposeProjectOwner/acceptProjectOwner only change the executor's generation; applyUpgrade only changes the evaluator. None of them touches `record`, and latest() returns it unconditionally. A consumer that checks only validUntil (and version) keeps consuming the last value written by a now-revoked publisher, or produced by a superseded module version, for up to MAX_DATA_LIFETIME (1 day; the Pulse config further caps it at maximumAge). Revocation is the owner's sole emergency control over a misbehaving publisher and it does not reach the data already written; the owner cannot clear the record (deliberately, the owner cannot write data) and can only displace it by granting another publisher who writes sequence+1 with observedAt >= the bad record's. The condition is detectable (grant(latest().grantId).revoked, grant terms' authorityGeneration vs executor.projects(projectId).authorityGeneration, evaluator.version() vs record.version) but nothing in the kernel or its comments tells consumers to check. This may be a deliberate consequence of the limited profile (the executor README states that authority revocation does not disable public reads); if so it must be stated for consumers. Merged from audit_permissions (low), audit_flow (low), audit_economics (info). Minimal fix keeping the owner out of the data path: add a view (latestActive) that returns the record only when its grant is not revoked, its generation is current and evaluator.version() == record.version, or have revokeGrant set record.validUntil = uint64(block.timestamp) when record.grantId == id.","line":125,"path":"contracts/src/ClausSignalStore.sol","reproduction":"Setup as test/ClausSignalStore.t.sol. Publisher publishes sequence 1 with observedAt=now, validUntil=now+3600. Owner calls sink.revokeGrant(grantId). vm.warp(+1800). Expected (for revocation to be visible on reads): latest() empty or flagged. Actual: latest().sequence == 1, latest().grantId == grantId, latest().validUntil > block.timestamp while grant(grantId).revoked == true. Then an approved upgrade (executor.queue/execute of the same module with a new config): target.version() == 2 but latest().version == 1 and the record is unchanged; after owner setAuthority(projectId, MANDATE, true) the record is still served. Judge scratch test testRevokedGrantRecordStillServedAndOldVersionPersistsAfterUpgrade in test/scratch/JudgeRepro.t.sol passes on this commit asserting exactly that state.","severity":"low","snippet":"    function latest() external view returns (Record memory) { return record; }","title":"Revoking a grant, bumping the authority generation or upgrading the evaluator stops future writes but leaves the superseded record served unchanged by latest()"},{"citation":"resolved","description":"create() checks only that the caller is the current EOA owner of an enabled, non-pending project. feedId and schemaHash are never compared with what the installed evaluator accepts (ClausPulseModule rejects any other FEED/SCHEMA on every evaluate), the store's feedId/schemaHash are immutable, and stores[projectId] is write-once (line 25). One mistaken create() therefore leaves the project with a registry entry whose every publish reverts ModuleCallFailed, and the project can never obtain another factory store; recovery needs either a reviewed Claus-approved evaluator upgrade to a module that accepts the wrong feed, a new project (new projectId and review), or a store deployed outside the factory that consumers resolving factory.stores(projectId) will not find. The same dead end is reached if an approved upgrade moves the evaluator to a module with a different FEED/SCHEMA. Consistent with 'one immutable store per project', so this is an operational liveness trap with no bypass, not an access defect. Merged from audit_permissions (info), audit_flow (low), audit_economics (low). Options that keep uniqueness: allow the current owner to replace the mapping only while the registered store has never published (latest().sequence == 0), or have create() probe target.evaluate with a zero-data Input and refuse feeds/schemas the current module rejects outright, or at minimum document that create() arguments must be copied from the evaluator's constants.","line":25,"path":"contracts/src/ClausSignalFactory.sol","reproduction":"Setup as test/ClausSignalStore.t.sol; factory = new ClausSignalFactory(executor). Owner calls factory.create(projectId, keccak256('typo'), module.SCHEMA()) -> succeeds, stores[projectId] set. Owner signs a grant on that store (nonce 1); publisher calls publish(id, 1, 1, now, now+300, keccak256('s'), validPulseData). Expected: a usable store or a correctable mistake. Actual: publish reverts ClausReadonlyProject.ModuleCallFailed (InvalidObservation inside the module) for every input; owner's corrective factory.create(projectId, module.FEED(), module.SCHEMA()) reverts StoreAlreadyCreated. Judge scratch test testFactoryBindsUnusableFeedPermanently in test/scratch/JudgeRepro.t.sol passes on this commit asserting this.","severity":"low","snippet":"        if (stores[projectId] != address(0)) revert StoreAlreadyCreated();","title":"Factory permanently binds a project to its first store with unvalidated feedId/schemaHash; a mistaken create() cannot be corrected"},{"citation":"resolved","description":"The constructor validates the executor/project/evaluator binding and non-zero feed/schema but not msg.sender, so a third party can deploy a second store for someone else's project, byte-for-byte identical to a factory store (or with a different feed/schema). Such a store is harmless on-chain: grants are EIP-712-bound to the store address (verifyingContract) and only the project owner can sign them, so it stays empty unless the owner signs for it deliberately. The risk is off-chain: 'fixed factory deployment' holds only for consumers that resolve the store through factory.stores(projectId) (or the StoreCreated event / CREATE2 address with exact constructor arguments). A consumer that accepts any contract whose executor()/projectId()/evaluator() match will accept a parallel store. Trust assumption for the integration review, not a kernel defect. Merged from audit_permissions and audit_economics. Option: record an immutable `factory = msg.sender` in the store for consumers to check, or restrict the constructor to the factory/owner if direct deployments are not intended.","line":71,"path":"contracts/src/ClausSignalStore.sol","reproduction":"Setup as test/ClausSignalStore.t.sol; owner creates the canonical store via the factory. Address 0xDEAD calls new ClausSignalStore(executor, projectId, target, module.FEED(), module.SCHEMA()). Expected (if factory-only deployment were enforced on-chain): revert. Actual: deployment succeeds with evaluator() == target and projectId() == projectId; factory.stores(projectId) still points at the canonical store; parallel.registerGrant with the owner's signature made for the canonical store reverts InvalidSignature. Judge scratch test testAnyoneCanDeployParallelStore in test/scratch/JudgeRepro.t.sol passes on this commit asserting this.","severity":"info","snippet":"    constructor(address approvalExecutor, bytes32 id, address target, bytes32 feed, bytes32 schema)","title":"Anyone can deploy a ClausSignalStore for any project outside the factory; canonicality exists only in factory.stores()"},{"citation":"resolved","description":"The idempotent early return at line 97 happens before any project or signature check, so registerGrant(g, 0x00) for a registered grant returns the id and emits nothing. Off-chain tooling that treats a non-reverting registerGrant as proof the signature was accepted would be wrong. Separately, ECDSA.recover (OpenZeppelin v5) reverts with ECDSAInvalidSignatureLength / ECDSAInvalidSignature / ECDSAInvalidSignatureS for malformed input, so the store's own InvalidSignature is only reached for well-formed signatures from a different key. No safety impact: the idempotent path changes no state and malformed signatures still revert. From audit_flow. Suggestion: document the idempotent path (or revert AlreadyRegistered), and optionally use ECDSA.tryRecover to return InvalidSignature uniformly.","line":97,"path":"contracts/src/ClausSignalStore.sol","reproduction":"After setUp registers grant g (nonce 0): sink.registerGrant(g, hex'00') returns grantId without reverting. With g.nonce = 9: sink.registerGrant(g, hex'00') reverts with ECDSAInvalidSignatureLength(1), not ClausSignalStore.InvalidSignature. Judge scratch test testRegisterGrantIdempotentPathAndOzErrors in test/scratch/JudgeRepro.t.sol passes on this commit asserting both.","severity":"info","snippet":"        if (grants[id].terms.publisher != address(0)) return id;","title":"registerGrant returns success for an already registered id without validating the supplied signature; malformed signatures surface OpenZeppelin ECDSA errors instead of InvalidSignature"},{"citation":"resolved","description":"sequence and observedAt are store-global while grants are per publisher, and the brief states multiple separately approved grants are intentional. With two live grants each publisher must supply exactly record.sequence+1 and observedAt >= record.observedAt; whichever transaction lands second reverts InvalidRecord and must be re-submitted with a new sequence. A publisher with a valid grant can also keep record.observedAt at the current timestamp with short validUntil values so the other must always observe later. Liveness friction between parties the owner already trusts; the owner's remedy is revocation. From audit_flow. If concurrent grants are expected in practice, assigning the sequence inside the store (passing it to the module from state) removes the contention without weakening the module's context.","line":137,"path":"contracts/src/ClausSignalStore.sol","reproduction":"Two grants on one store: publisher A (0xBEEF, nonce 0) and publisher B (0xCAFE, nonce 5). A publishes sequence 1 at now. B's publish(id2, 1, 1, now, now+300, ...) in the same block: expected (from B's view) success; actual revert InvalidRecord. B's publish(id2, 2, 1, now, now+300, ...) then succeeds and latest().grantId == id2. Judge scratch test testConcurrentGrantsContendOnSequence in test/scratch/JudgeRepro.t.sol passes on this commit.","severity":"info","snippet":"        if (sequence != record.sequence + 1 || observedAt == 0 || observedAt > block.timestamp ||","title":"Concurrent owner-approved grants contend on the single store-wide sequence and observedAt; the second publisher's write in a block reverts"},{"citation":"resolved","description":"The module accepts age <= maximumAge (line 24) and caps validUntil <= observedAt + maximumAge (line 25); the store requires validUntil > block.timestamp (ClausSignalStore.sol line 138). When block.timestamp - observedAt == maximumAge the module's cap gives validUntil <= block.timestamp, which the store rejects, so no validUntil satisfies both and the effective maximum age through the only writer is maximumAge - 1. Arithmetic is checked and both subtractions are guarded by the preceding disjunct, so no value is at risk; the publisher simply uses a fresher observation, but the configured maximumAge does not mean what it says on the store path and any consumer or test pinning the edge disagrees with the module. From audit_math. Fix: make the module's age check `>=` so the publishable bound equals the configured one, or document maximumAge as exclusive for store publication.","line":24,"path":"contracts/src/ClausPulseModule.sol","reproduction":"Config abi.encode(1 gwei, 3600); T0 = 1800000000; grant with minimumInterval 1. publish(seq 1, observedAt T0, validUntil T0+3600) at T0 succeeds. vm.warp(T0+3600). publish(seq 2, observedAt T0, validUntil T0+3600): expected acceptable (age == maximumAge); actual InvalidRecord (validUntil <= block.timestamp). publish(seq 2, observedAt T0, validUntil T0+3601): actual ModuleCallFailed (validUntil > timestamp + maximumAge). publish(seq 2, observedAt T0+1, validUntil T0+3601) (age 3599) succeeds. Judge scratch test testAgeBoundaryIsExclusiveThroughStore in test/scratch/JudgeRepro.t.sol passes on this commit.","severity":"info","snippet":"            timestamp > block.timestamp || block.timestamp - timestamp > maximumAge ||","title":"Store and module time bounds compose to an exclusive age limit: an observation exactly maximumAge old passes the module in isolation but can never be published"},{"citation":"resolved","description":"ClausApprovalExecutor.execute and ClausReadonlyProject.applyUpgrade store the configuration bytes without consulting the module; the module checks length, threshold and maximumAge only at evaluate time (lines 16-18). A Claus-approved upgrade whose configuration is 64 bytes with threshold 0 (or any length other than 64) is accepted by the executor and makes every subsequent publish revert ModuleCallFailed until a further independently reviewed and Claus-signed upgrade executes; with the owner-selected zero mandatory delay the halt is immediate. Requires a privileged, reviewed action, so this is information for the reviewers of module and config changes, not a defect. From audit_economics. An off-chain pre-check calling evaluate with the proposed configuration, or a view such as validateConfiguration(bytes) the review can call, closes the gap.","line":18,"path":"contracts/src/ClausPulseModule.sol","reproduction":"Setup as test/ClausSignalStore.t.sol. Queue and execute an Approval for the same ClausPulseModule with initCallHash = keccak256(abi.encode(uint256(0), uint256(3600))) and that configuration. Expected: a configuration the module can never accept is rejected before it is applied. Actual: execute succeeds, target.version() == 2, and publisher publish(grantId, 1, 2, now, now+300, keccak256('s'), validPulseData) reverts ClausReadonlyProject.ModuleCallFailed. Judge scratch test testBadApprovedConfigHaltsPublishing in test/scratch/JudgeRepro.t.sol passes on this commit.","severity":"info","snippet":"        if (threshold == 0 || maximumAge == 0 || maximumAge > 1 days) revert InvalidConfiguration();","title":"Pulse configuration is validated only inside evaluate, so an approved configuration the module can never accept is applied and halts publishing until the next approved upgrade"},{"citation":"resolved","description":"contracts/SOURCE_MANIFEST.json enumerates eight src files and eight test files that predate this commit and none of ClausSignalTypes.sol, ClausSignalStore.sol, ClausSignalFactory.sol, ClausPulseModule.sol or test/ClausSignalStore.t.sol. The factory derives store addresses with CREATE2 over the exact creation code, so precise source and compiler settings determine every store address; a manifest that omits them does not let a reviewer or deployer pin the artifact this review covered. The existing entries still match the current files. From audit_math. Fix: regenerate the manifest for commit 41683cf.","line":15,"path":"contracts/README.md","reproduction":"From contracts/: grep -c 'Signal\\|Pulse' SOURCE_MANIFEST.json prints 0; python3 -c \"import json;print([k for k in json.load(open('SOURCE_MANIFEST.json'))['files'] if k.startswith(('src','test'))])\" lists only ClausApprovalExecutor, ClausBalanceLens, ClausNativeVault, ClausProjectWallet, ClausReadonlyProject, ClausRequestClient, ClausRequestInbox, ClausVaultAdmission and their tests. Expected: entries for the four new src files and test/ClausSignalStore.t.sol.","severity":"info","snippet":"Run `forge test` with Foundry and Solidity 0.8.27. Exact vendored dependencies and input hashes are listed in `SOURCE_MANIFEST.json`. No package installation, FFI or remote fork is needed for the tests.","title":"SOURCE_MANIFEST.json does not list the four new signal sources or their test, although the README directs reproducers to it for exact input hashes"},{"citation":"resolved","description":"foundry.toml compiles with via_ir, and the optimizer treats block.timestamp as constant within a call frame; vm.warp breaks that assumption. In testFiniteWritesIntervalAndIdempotentGrantNeverResetUsage the warps at lines 85 and 86 both target 1800000060, so publish(3,1) at line 86 runs in the same second as publish(2,1) and is blocked by the minimum interval as well as by the write cap; the test does not demonstrate the cap alone after the interval has elapsed as its name states. In testDataUpdatesReviewedUpgradeAndRevocation the warps at lines 71 and 77 both target 1800000060. All assertions still pass because an earlier guard fires (revoked, version, write cap), so no contract defect is hidden, but edge-time behaviour (expiry exactly at expiresAt, interval boundary, lifetime boundary) is not exercised at the instants the tests name. forge lint reports environment-read-across-mutation for this file. From audit_math. Fix: read vm.getBlockTimestamp() after each warp, or capture the target instant once and pass it explicitly.","line":86,"path":"contracts/test/ClausSignalStore.t.sol","reproduction":"From contracts/: forge test --match-path test/ClausSignalStore.t.sol --match-test testFiniteWritesIntervalAndIdempotentGrantNeverResetUsage -vvvv | grep -E 'warp|publish\\(' shows VM::warp(1800000060) twice and both publish(2,1) and publish(3,1) carrying observedAt 1800000060; expected the second warp to land at 1800000120. The same command for testDataUpdatesReviewedUpgradeAndRevocation shows two VM::warp(1800000060) calls.","severity":"info","snippet":"        vm.warp(block.timestamp+60);vm.expectRevert(ClausSignalStore.GrantInactive.selector);publish(3,1);","title":"New tests re-read block.timestamp after vm.warp; under via_ir the pre-warp value is reused so the second warp in two tests does not advance time"}],"hash":"0997ca0bc4ab253a18b7cb0e7cebd6abe47b00d38d6849ec60658e6420df9f82","nodeId":"7fd865cd-0de7-4e57-bf9b-f64ec9648f9a","outcome":"completed","summary":"The review is complete. `.imd-findings.json` holds 12 merged findings, every one reproduced against commit 41683cf, and no tracked file was changed.\n\n**What I did**\n\n- Ran the committed signal-store suite (8 passing) and all four specialist proofs. Every proof fails on this code for the stated reason.\n- Confirmed each proof passes once its proposed fix is applied, using scratch copies of the contracts under `test/scratch/fixed*/`, never the sources.\n- Wrote a scratch test of my own reproducing the nine claims that had no proof, traced the via_ir warp behaviour, and checked the manifest.\n- Merged 22 specialist entries into 12 findings and dropped the \"trust assumptions confirmed\" entry, which is coverage, not a defect.\n\n**Findings kept, with proofs on the three most severe**\n\n| Severity | Finding | Proof |\n|---|---|---|\n| low | Publisher EOA-only guard is a code-length check; a contract publishes from its constructor, repeatable via same-tx selfdestruct | yes |\n| low | Signed grant cannot be withdrawn before registration; revokeGrant rejects unregistered digests, cancelGrantNonce is a silent no-op after a front-run registerGrant | yes |\n| low | Pulse module accepts a block number above the chain head | yes |\n| low | Revocation, generation bump or evaluator upgrade leaves the superseded record served by latest() | no |\n| low | Factory binds a project to its first store with unvalidated feed/schema, no correction possible | no |\n| info | Parallel stores deployable by anyone; canonicality lives only in the factory mapping | no |\n| info | registerGrant idempotent path skips signature; malformed signatures surface OpenZeppelin errors | no |\n| info | Concurrent grants contend on the store-wide sequence | no |\n| info | Store and module time bounds make maximumAge effectively exclusive | no |\n| info | Approved configuration is validated only at evaluate time, so a bad one halts publishing | no |\n| info | SOURCE_MANIFEST.json omits the four new sources and their test | no |\n| info | Tests re-read block.timestamp after vm.warp; via_ir reuses the pre-warp value | no |\n\n**Severity calls I made differently from the specialists.** I lowered the EOA bypass from medium to low. A contract publisher gains no authority beyond the publisher role the owner already granted, and the data still passes the module and grant limits. I kept the revocation-leaves-record finding at low rather than info because the owner's only emergency control does not reach already-served data and nothing tells consumers to check the grant state.\n\n**Coverage.** I independently walked the store, factory, types and module against the executor and readonly project: EIP-712 binding, nonce and generation handling, pending ownership, limits at the boundaries, the STATICCALL path and return-data bounds, and the 2048-byte input fitting under the project's 8192-byte cap. I found nothing beyond what the specialists reported. Slither was not run, and no fuzzing beyond the unit tests was done.","treeHash":null,"usage":{"cachedInputTokens":1126362,"inputTokens":386,"model":"claude-fable-5-1","outputTokens":35345,"runtime":"claude","turns":27,"wallClockMs":523052}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"ee2cc12e7dfe571a","findings":[{"citation":"resolved","description":"The profile states that owner and publisher are EOAs only (EIP-7702 excluded), and the store enforces this for the publisher with `g.publisher.code.length != 0` at registerGrant (line 99) and `msg.sender.code.length != 0` at publish (line 133). `extcodesize` is 0 for an address whose contract is still executing its constructor, so a contract that calls `publish` from its constructor passes the guard. The publisher side needs no signature, only `msg.sender`, so the semi-trusted publisher can hand the owner a counterfactual CREATE2 address (no code, indistinguishable from an EOA; registerGrant also accepts it) and then publish from contract code. With Cancun/EIP-6780, `selfdestruct` in the creation transaction deletes the account, so the identical CREATE2 address publishes the next sequence as well, up to maximumWrites. Consequence: the kernel's stated EOA-only publisher invariant does not hold; a publisher can be a contract (multi-key, governance-controlled, or atomically composing `publish` with other calls in one transaction), which is exactly what excluding EIP-7702 was meant to prevent. The owner's check in registerGrant is safe only because the owner must produce an ECDSA signature; the publisher has no such proof of key control. Fix (minimal, preserves design): in publish also require `msg.sender == tx.origin` alongside the existing code-length check (`tx.origin` with code is already excluded by the length check, so EIP-7702 stays excluded). Existing tests that `vm.prank(publisher)` must become `vm.prank(publisher, publisher)`. Alternatively, document that publishers may be contracts and drop the EOA claim.","line":133,"path":"contracts/src/ClausSignalStore.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.27;\nimport {Test} from \"forge-std/Test.sol\";\nimport {ClausApprovalExecutor} from \"src/ClausApprovalExecutor.sol\";\nimport {ClausReadonlyProject} from \"src/ClausReadonlyProject.sol\";\nimport {ClausSignalStore} from \"src/ClausSignalStore.sol\";\nimport {ClausPulseModule} from \"src/ClausPulseModule.sol\";\n\n/// Holds the publish arguments so the publisher's CREATE2 address does not depend on the grant id.\ncontract GrantHolder {\n    bytes32 public grantId; uint64 public sequence; bytes public data;\n    function set(bytes32 id, uint64 s, bytes calldata d) external { grantId = id; sequence = s; data = d; }\n}\n\n/// Acts as the \"publisher\" while it is still being constructed, so `msg.sender.code.length == 0`\n/// inside ClausSignalStore.publish. It self-destructs in the same transaction (EIP-6780 allows\n/// this), so the address is empty again and the same CREATE2 address can publish the next sequence.\ncontract HolderPublisher {\n    constructor(ClausSignalStore sink, GrantHolder holder) {\n        // Low-level so a (correct) PublisherOnly revert does not abort the deployment itself.\n        (bool ok,) = address(sink).call(abi.encodeCall(ClausSignalStore.publish, (holder.grantId(), holder.sequence(), 1,\n            uint64(block.timestamp), uint64(block.timestamp + 300), keccak256(\"attested\"), holder.data())));\n        ok;\n        selfdestruct(payable(msg.sender));\n    }\n}\n\ncontract Deployer {\n    function deploy(bytes32 salt, bytes memory initCode) external returns (address a) {\n        assembly { a := create2(0, add(initCode, 32), mload(initCode), salt) }\n    }\n}\n\ncontract PublisherConstructorBypassTest is Test {\n    uint256 constant OWNER = 0xB0B;\n    uint256 constant CLAUS = 0xA11CE;\n    bytes32 constant MANDATE = keccak256(\"signal-test\");\n    bytes32 constant POLICY = keccak256(\"readonly-test\");\n    ClausApprovalExecutor executor;\n    ClausReadonlyProject target;\n    ClausSignalStore sink;\n    ClausPulseModule module;\n    Deployer deployer;\n    GrantHolder holder;\n    bytes32 projectId;\n    bytes initCode;\n    address publisher;\n    bytes32 grantId;\n\n    function sign(uint256 key, bytes32 hash) internal pure returns (bytes memory) {\n        (uint8 v, bytes32 r, bytes32 s) = vm.sign(key, hash); return abi.encodePacked(r, s, v);\n    }\n\n    function observation() internal view returns (bytes memory) {\n        return abi.encode(block.chainid, uint64(123), keccak256(\"source-block\"), uint64(block.timestamp), uint256(2 gwei));\n    }\n\n    function setUp() public {\n        vm.warp(1800000000); vm.chainId(11155111);\n        executor = new ClausApprovalExecutor(vm.addr(CLAUS), vm.addr(OWNER), 1 days);\n        module = new ClausPulseModule();\n        bytes32 salt = keccak256(\"pulse\"); projectId = executor.projectKey(vm.addr(OWNER), salt);\n        bytes memory config = abi.encode(uint256(1 gwei), uint256(3600));\n        ClausApprovalExecutor.Registration memory r = ClausApprovalExecutor.Registration(projectId, vm.addr(OWNER), salt,\n            address(module), address(module).codehash, keccak256(config), MANDATE, POLICY, 0, 0, uint64(block.timestamp + 1 days));\n        target = ClausReadonlyProject(executor.registerProject(r, config, sign(OWNER, executor.registrationDigest(r))));\n        sink = new ClausSignalStore(address(executor), projectId, address(target), module.FEED(), module.SCHEMA());\n        deployer = new Deployer();\n        holder = new GrantHolder();\n        initCode = abi.encodePacked(type(HolderPublisher).creationCode, abi.encode(sink, holder));\n        // The address the \"publisher\" hands to the owner: no code, indistinguishable from an EOA.\n        publisher = vm.computeCreate2Address(keccak256(\"publisher-slot\"), keccak256(initCode), address(deployer));\n        assertEq(publisher.code.length, 0);\n        // Owner signs a grant for it; registerGrant's `g.publisher.code.length != 0` check passes too.\n        ClausSignalStore.Grant memory g = ClausSignalStore.Grant(publisher, 1, 2, 60, uint64(block.timestamp + 1 days), 0);\n        grantId = sink.registerGrant(g, sign(OWNER, sink.grantDigest(g)));\n    }\n\n    /// Expected (profile: publishers are EOAs only): a publish issued by contract code reverts PublisherOnly\n    /// and the store stays empty. Actual: the constructor-context call passes `msg.sender.code.length != 0`\n    /// and the record is written by contract code.\n    function testContractPublisherPassesEoaOnlyGuardFromItsConstructor() public {\n        holder.set(grantId, 1, observation());\n        deployer.deploy(keccak256(\"publisher-slot\"), initCode);\n        assertEq(sink.latest().sequence, 0, \"contract code must not be able to publish\");\n    }\n\n    /// Same bypass repeated from the identical address after selfdestruct, consuming the grant's writes.\n    function testContractPublisherCanRepeatAfterSelfdestruct() public {\n        holder.set(grantId, 1, observation());\n        deployer.deploy(keccak256(\"publisher-slot\"), initCode);\n        assertEq(publisher.code.length, 0, \"self-destructed in creation tx\");\n        vm.warp(block.timestamp + 60);\n        holder.set(grantId, 2, observation());\n        deployer.deploy(keccak256(\"publisher-slot\"), initCode);\n        assertEq(sink.latest().sequence, 0, \"contract code must not be able to publish\");\n    }\n}","reproduction":"State: project registered with ClausPulseModule, store deployed for it. Publisher computes `P = CREATE2(deployer, salt, initcode(HolderPublisher))`; `P.code.length == 0`. Owner signs Grant(P, generation 1, maximumWrites 2, minimumInterval 60, expiresAt now+1d, nonce 0); `registerGrant` succeeds (publisher EOA check passes). Publisher deploys HolderPublisher at P; its constructor calls `sink.publish(grantId, 1, 1, now, now+300, keccak256(\"attested\"), data)` and then selfdestructs. Expected: revert PublisherOnly, `latest().sequence == 0`. Actual: call succeeds, `latest().sequence == 1`, `P.code.length == 0` again after the tx. Warp 60s, redeploy at the same P with sequence 2: Actual `latest().sequence == 2`. Run: forge test --match-path test/scratch/PublisherConstructorBypass.t.sol (both tests fail on this code; pass once `msg.sender == tx.origin` is required in publish).","severity":"medium","snippet":"        if (msg.sender != g.publisher || msg.sender.code.length != 0) revert PublisherOnly();","title":"Publisher 'EOA only' guard is a code.length check that contract code passes from its constructor (CREATE2 counterfactual publisher, repeatable via same-tx selfdestruct)"},{"citation":"resolved","description":"revokeGrant (line 120), setAuthority/proposeProjectOwner generation bumps and ownership completion all make the grant inactive for publish, but none of them touches `record`. `latest()` returns the record unconditionally, so a consumer that checks only `validUntil` keeps treating data written by a now-revoked publisher as current for up to MAX_DATA_LIFETIME (1 day; the pulse module further caps it at maximumAge). The only signal is indirect: the consumer must also read `grant(latest().grantId).revoked` and compare `grant(...).terms.authorityGeneration` with the executor's current generation. Revocation is the owner's sole emergency control over a misbehaving publisher, and it does not reach the data the publisher already wrote. This may be a deliberate consequence of 'the owner cannot write data'; if so it needs to be stated for consumers. Minimal fix that keeps the owner out of the data path: have `latest()` (or a `latestActive()`) report an `active` flag computed from `grants[record.grantId].revoked`, the grant's generation vs `executor.projects(projectId).authorityGeneration`, and `enabled`, so view-level invalidation is automatic.","line":125,"path":"contracts/src/ClausSignalStore.sol","reproduction":"Publisher with a valid grant calls publish(id, 1, 1, now, now+3600, h, data) -> record.sequence 1. Owner calls revokeGrant(id). Warp 30 minutes. Expected: a consumer reading the store sees the revoked publisher's data as withdrawn. Actual: latest() returns sequence 1 with validUntil > block.timestamp, while grant(latest().grantId).revoked == true. Scratch test testRevokedGrantRecordStillServedUntilValidUntil in test/scratch/SecondaryLeads.t.sol asserts exactly this state.","severity":"low","snippet":"    function latest() external view returns (Record memory) { return record; }","title":"Revoking a grant or changing authority stops future writes but leaves the revoked publisher's last record served as valid by latest()"},{"citation":"resolved","description":"registerGrant is permissionless and idempotent, and the first write of a grant is exempt from minimumInterval (`state.used != 0 && ...`). If the owner signs a grant and then decides to withdraw it with cancelGrantNonce, the holder of the signature can bundle registerGrant + publish in front of the cancel transaction; cancelGrantNonce then marks an already-consumed nonce and has no effect (no revert, event still emitted). The owner's remedy is revokeGrant on the now-registered id, after one write has already landed. This is the ordinary off-chain-signature race and the exposure is bounded (one attested write by a publisher the owner chose, max 1-day validity), so it is reported as a profile limitation, not a defect. Documentation should say: once a grant signature has left the owner's hands, revokeGrant, not cancelGrantNonce, is the reliable withdrawal, and cancelGrantNonce could revert or flag when the nonce is already consumed by a registered grant.","line":135,"path":"contracts/src/ClausSignalStore.sol","reproduction":"Owner signs Grant(publisher, gen 1, 2 writes, 60s, now+1d, nonce 7) and later broadcasts cancelGrantNonce(7). Publisher, in the same block before it, calls registerGrant(g, sig) then publish(id, 1, 1, now, now+300, h, data). Expected (owner's intent): no write. Actual: latest().sequence == 1, grant(id).revoked == false, cancelGrantNonce(7) succeeds without effect. Scratch test testCancelNonceFrontRunGivesOneImmediateWrite in test/scratch/SecondaryLeads.t.sol.","severity":"info","snippet":"            state.used >= g.maximumWrites || (state.used != 0 && block.timestamp < uint256(state.lastWrite) + g.minimumInterval)) revert GrantInactive();","title":"A signed grant can be registered and used for one immediate write ahead of the owner's cancelGrantNonce; cancellation only protects unregistered nonces"},{"citation":"resolved","description":"create() checks only that the caller is the current EOA owner of an enabled, non-pending project. feedId and schemaHash are not checked against the installed module (the store cannot know the module's FEED/SCHEMA constants in general) and the mapping slot is written once forever. If the owner passes a feed or schema the module rejects, every publish on that store reverts ModuleCallFailed, and create() for the same project reverts StoreAlreadyCreated, so the project can never get a working factory store; the owner must register a new project (new projectId, new review) or use a non-factory store that off-chain consumers do not recognise as canonical. Self-inflicted and consistent with 'one immutable data store per registered project', so recorded as a limitation. Options if the requester wants a remedy without weakening uniqueness: let the owner replace the mapping only while the existing store has never published (`latest().sequence == 0`), or have the factory probe `target.evaluate` with a zero-data Input and refuse feeds/schemas the current module rejects outright.","line":25,"path":"contracts/src/ClausSignalFactory.sol","reproduction":"Owner calls factory.create(projectId, keccak256(\"typo\"), module.SCHEMA()) -> store S. Owner registers a grant on S, publisher calls S.publish(id, 1, 1, now, now+300, h, validPulseData). Expected: data published or a correctable mistake. Actual: publish reverts ModuleCallFailed (module checks feedId != FEED); factory.create(projectId, module.FEED(), module.SCHEMA()) reverts StoreAlreadyCreated. Scratch test testFactoryBricksProjectSlotOnWrongFeed in test/scratch/SecondaryLeads.t.sol.","severity":"info","snippet":"        if (stores[projectId] != address(0)) revert StoreAlreadyCreated();","title":"Factory permanently binds a project to its first store with unvalidated feedId/schemaHash; a mistaken create() cannot be corrected"},{"citation":"resolved","description":"The constructor validates executor/target/project binding but not msg.sender, so a third party can deploy a second store for a project (also with a different feed/schema). Such a store is harmless on-chain because grants are EIP-712-bound to the store address and only the project owner can sign them; the owner would have to sign for the parallel store deliberately. The risk is off-chain: 'fixed factory deployment' is only true for consumers that resolve the store through `factory.stores(projectId)` (or verify the CREATE2 address against the factory and the exact constructor arguments). A consumer that trusts any contract whose `executor()`/`projectId()`/`evaluator()` match will accept a parallel store whose data the owner signed for under a different feed/schema. Not a kernel defect; the integration review should require address resolution via the factory mapping, or the store could record an immutable `factory = msg.sender` for consumers to check.","line":71,"path":"contracts/src/ClausSignalStore.sol","reproduction":"Any address X calls `new ClausSignalStore(executor, projectId, target, FEED, SCHEMA)` after the factory store exists. Expected (if 'fixed factory deployment' were enforced on-chain): revert. Actual: deployment succeeds with evaluator() == target and projectId() == projectId; factory.stores(projectId) still points at the original. Scratch test testAnyoneCanDeployParallelStoreForProject in test/scratch/SecondaryLeads.t.sol.","severity":"info","snippet":"    constructor(address approvalExecutor, bytes32 id, address target, bytes32 feed, bytes32 schema)","title":"ClausSignalStore can be deployed by anyone outside the factory for any project; the store itself does not record its creator, so canonicality exists only in factory.stores()"}],"hash":"1ebe5e1ff30abd34a6fcdd8702b6402fd4e7b55ab18e449285e605a01a2f71a3","nodeId":"32d23819-367d-4c64-ba88-65468816ef4e","outcome":"completed","summary":"Review complete. The findings file at the repository root holds five entries; no source files were changed, and only the scratch test directory is untracked.\n\n**Outcome.** The signal kernel is largely sound. Grant binding, replay, revocation, generation and ownership handling, limits, monotonic sequences, version pinning, STATICCALL with bounded return data, target verification and the factory's CREATE2 deployment all behave as specified, and the 74 existing tests plus the executor suite pass. One real defect in the stated trust profile stands out.\n\n**Medium, with a failing Foundry proof.** The publisher \"EOA only\" guard at `contracts/src/ClausSignalStore.sol:133` is a code-length check, which contract code passes while its constructor runs. The publisher needs no signature, only `msg.sender`, so a publisher can give the owner a counterfactual CREATE2 address that looks like an EOA, pass `registerGrant`, and then publish from contract code. With Cancun's same-transaction self-destruct the same address publishes again on the next sequence. The profile explicitly excludes contract and EIP-7702 publishers, and this bypasses that. The proof test under test/scratch fails on this code and passes once publish also requires the sender to equal the transaction origin.\n\n**Low and info, each with a reproduced state.**\n- Revocation and authority changes stop future writes but leave the revoked publisher's last record served by the latest-record view until it expires. Consumers must cross-check the grant's revoked flag and generation themselves.\n- A signed grant can be registered and used for one immediate write ahead of the owner's nonce cancellation, since the first write skips the interval. Standard signature race, bounded, reported as a limitation.\n- The factory binds a project to its first store forever without validating feed or schema, so a mistaken create permanently bricks that project's canonical store slot.\n- Anyone can deploy a parallel store for a project outside the factory. Harmless on-chain because grants are bound to the store address, but canonicality exists only in the factory mapping.\n\n**Checked and found consistent with the deliberate profile, not reported:** zero upgrade delay, multiple owner-approved grants as a rate-limit multiplier, grants dying on any generation bump including a cancelled ownership proposal, the executor's signer recovery not pausing publishing, and the pulse module not verifying block hashes.\n\n**Limitations.** No static analysis or fuzzing tools beyond Foundry were run. The new runtime, host policy and client integration were out of scope as instructed.","treeHash":null,"usage":{"cachedInputTokens":1225052,"inputTokens":450,"model":"claude-fable-5-1","outputTokens":50224,"runtime":"claude","turns":29,"wallClockMs":724659}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"b4f6137e7c93a2b3","findings":[{"citation":"resolved","description":"registerGrant is permissionless: anyone holding an owner-signed Grant can register it. cancelGrantNonce is the owner's only way to withdraw a grant that is signed but not yet registered, and revokeGrant only works on an already registered id. The two paths are not linked: registerGrant marks grantNonces[owner][nonce] itself (line 104), and cancelGrantNonce blindly sets the same flag again and emits GrantNonceCancelled without checking whether the nonce was already consumed. An owner who signs a grant, changes their mind and submits cancelGrantNonce(n) can be front-run (same block or earlier) by the publisher calling registerGrant with the signature. The owner's cancel transaction then succeeds and emits a cancellation event, yet the grant is registered, not revoked, and the publisher can write up to maximumWrites records until the owner notices and separately calls revokeGrant(id). The owner's tooling has no on-chain signal that the cancel did nothing. Preconditions: the owner has already released a signature to the publisher (that is the normal flow); exploitability is bounded by the grant's own limits (<=10000 writes, <=30 days, module-validated data). Minimal fix preserving design: in cancelGrantNonce, revert (or revoke the registered grant) when grantNonces[owner][nonce] is already true, e.g. keep a mapping owner=>nonce=>grantId written in registerGrant and set grants[id].revoked = true in cancelGrantNonce when present.","line":112,"path":"contracts/src/ClausSignalStore.sol","reproduction":"Setup as in test/ClausSignalStore.t.sol (owner 0xB0B EOA, publisher 0xBEEF, store bound to a Pulse evaluator). Owner signs Grant{publisher,generation 1,maximumWrites 10,minimumInterval 60,expiresAt now+1d,nonce 7}. Tx1 (publisher): sink.registerGrant(g,sig) -> returns id, grantNonces[owner][7]=true. Tx2 (owner): sink.cancelGrantNonce(7) -> succeeds, emits GrantNonceCancelled. Expected: the grant with nonce 7 is unusable. Actual: sink.grant(id).revoked == false and the publisher's sink.publish(id,1,1,now,now+300,keccak('s'),validPulseData) succeeds; sink.latest().grantId == id. Confirmed by scratch test testCancelNonceAfterFrontRunRegistrationLeavesGrantLive.","severity":"low","snippet":"        grantNonces[owner][nonce] = true;","title":"cancelGrantNonce is a silent no-op once the nonce was consumed by a front-run registerGrant; the grant stays live"},{"citation":"resolved","description":"The module requires the attested chainId to equal block.chainid, i.e. the observation is about the chain it runs on, and it validates the timestamp against block.timestamp. It applies no comparable sanity check to the block number: any nonzero number, including one far above block.number, with any nonzero hash is accepted and committed to the store's record and DataPublished event. Publisher attestation is the documented trust model, so a plausible-but-wrong block is deliberate scope; a block number greater than the current head is not attestable data on the same chain and is rejectable at zero cost. For numbers within the last 256 blocks the module could additionally require blockhash(number) == hash, and for older numbers at least number <= block.number. Minimal fix: add number > block.number to the InvalidObservation condition; optionally add (block.number - number < 256 && blockhash(number) != hash).","line":23,"path":"contracts/src/ClausPulseModule.sol","reproduction":"Store with Pulse evaluator, config (1 gwei, 3600), valid grant. At block.number 1000 the publisher calls sink.publish(grantId,1,1,now,now+300,keccak('s'), abi.encode(block.chainid, uint64(type(uint64).max), bytes32(uint256(1)), uint64(now), uint256(2 gwei))). Expected: InvalidObservation. Actual: the call succeeds and sink.latest().output decodes to number 18446744073709551615, hash 0x...01, aboveThreshold true. Confirmed by scratch test testPulseAcceptsFutureBlockNumber.","severity":"low","snippet":"        if (chainId != context.chainId || number == 0 || hash == bytes32(0) || timestamp != context.observedAt ||","title":"ClausPulseModule accepts observations for block numbers that cannot exist yet and never checks blockhash for recent blocks"},{"citation":"resolved","description":"revokeGrant sets grants[id].revoked only. The Record written by that grant, including its output, stays in storage and latest() returns it unchanged until its validUntil (up to MAX_DATA_LIFETIME of 1 day after observedAt). The same holds when the owner disables the project or changes generation, and when the executor installs a new evaluator version: record.version keeps the old version and latest() keeps serving the output produced by the superseded module. The owner's revocation reasons (bad data, compromised publisher) are therefore not reflected in the public read path; a consumer that checks only validUntil consumes the revoked publisher's last value for up to a day, and the owner has no way to clear it other than registering a fresh grant and publishing a replacement with sequence+1 and observedAt >= the bad record's. A consumer can detect the condition through grant(latest().grantId).revoked and version, so this is a consumer-contract requirement rather than a kernel bypass. Minimal fix options that keep the store immutable: have revokeGrant clear or mark the record when record.grantId == id (e.g. set record.validUntil = uint64(block.timestamp)), or add a view such as latestValid() that returns the record only if the grant is not revoked, the generation matches, and evaluator.version() == record.version, and document that consumers must use it.","line":125,"path":"contracts/src/ClausSignalStore.sol","reproduction":"Valid grant; publisher publishes sequence 1 with observedAt=now, validUntil=now+3600. Owner calls sink.revokeGrant(grantId). Warp 1800s. Expected (for a revocation to take effect on reads): latest() empty or flagged. Actual: sink.latest().sequence == 1, validUntil > block.timestamp, while sink.grant(latest().grantId).revoked == true. Same after vm.prank(executor) target.applyUpgrade(newModule, cfg): target.version()==2 but latest().version==1 and the record remains valid. Confirmed by scratch tests testRevokedGrantRecordStillServed and testOldVersionRecordPersistsAfterUpgrade.","severity":"low","snippet":"    function latest() external view returns (Record memory) { return record; }","title":"Revoking a grant, bumping the authority generation or upgrading the evaluator does not invalidate the record already served by latest()"},{"citation":"resolved","description":"create() lets the current owner choose feedId and schemaHash freely and never consults the evaluator (ClausPulseModule exposes FEED and SCHEMA constants, but the store and factory do not read them). The mapping stores[projectId] is write-once. If the owner passes a feed or schema the installed module does not accept, every publish through the factory-created store reverts inside the module (surfaced as ClausReadonlyProject.ModuleCallFailed), and the project can never obtain another factory store; recovery requires either a reviewed Claus-approved evaluator upgrade to a module that accepts that feed/schema, or abandoning the factory registry for a directly deployed store, which consumers relying on factory.stores(projectId) will not find. This is consistent with the stated 'one immutable store per project' design, so it is reported as an operational foot-gun with no bypass. Minimal fix: have the store constructor (or factory) attempt a static probe of the evaluator with a zero-data Input to check the feed/schema are at least recognised, or allow the owner to re-create after the project's authority generation changes; alternatively document that create() arguments must be copied from the evaluator's constants.","line":25,"path":"contracts/src/ClausSignalFactory.sol","reproduction":"Project registered with ClausPulseModule. Owner calls factory.create(projectId, keccak256('typo'), module.SCHEMA()) -> succeeds, stores[projectId] set. Owner signs a grant on that store; publisher calls publish with otherwise valid Pulse data. Expected: a usable store. Actual: publish reverts with ModuleCallFailed (module InvalidObservation) on every input; factory.create(projectId, module.FEED(), module.SCHEMA()) reverts StoreAlreadyCreated. Confirmed by scratch test testFactoryBindsUnusableFeedPermanently.","severity":"low","snippet":"        if (stores[projectId] != address(0)) revert StoreAlreadyCreated();","title":"Factory binds a project's single store to an unvalidated feedId/schemaHash; a mistaken create() permanently occupies the project's canonical slot"},{"citation":"resolved","description":"The idempotent early return happens before any signature or project check, so registerGrant(g, 0x00) for an already registered grant returns the id and emits nothing. Off-chain tooling that treats a non-reverting registerGrant as proof that the signature was accepted would be wrong. Separately, ECDSA.recover (OZ v5) reverts with ECDSAInvalidSignatureLength / ECDSAInvalidSignature / ECDSAInvalidSignatureS for malformed input, so the contract's own InvalidSignature error is only reached for well-formed signatures from a different key. Neither affects safety: no state changes on the idempotent path and malformed signatures still revert. Suggested: emit nothing but document the idempotent path, or revert with an explicit AlreadyRegistered error; optionally wrap ECDSA.tryRecover to return InvalidSignature uniformly.","line":97,"path":"contracts/src/ClausSignalStore.sol","reproduction":"After setUp registers grant g (nonce 0): sink.registerGrant(g, hex'00') returns grantId without reverting. With g.nonce = 9: sink.registerGrant(g, hex'00') reverts with ECDSAInvalidSignatureLength(1), not ClausSignalStore.InvalidSignature. Confirmed by scratch test testRegisterGrantIdempotentPathSkipsSignatureAndGarbageSigRevertsWithOzError.","severity":"info","snippet":"        if (grants[id].terms.publisher != address(0)) return id;","title":"registerGrant returns success for an existing id without validating the supplied signature, and malformed signatures surface OpenZeppelin ECDSA errors rather than InvalidSignature"},{"citation":"resolved","description":"sequence and observedAt are store-global while grants are per publisher. The brief states multiple separately approved grants are intentional. With two live grants, each publisher must supply exactly record.sequence+1 and observedAt >= record.observedAt; whichever transaction lands second reverts with InvalidRecord and must be re-signed and re-submitted. A publisher with a valid grant can also keep the record's observedAt at the current timestamp with short validUntil values, forcing the other publisher to always observe later. This is liveness friction between parties the owner already trusts, not a bypass, and the owner's remedy is revocation. If concurrent grants are expected in practice, assigning sequence inside the store (and passing it to the module from state) removes the contention without weakening the module's context.","line":137,"path":"contracts/src/ClausSignalStore.sol","reproduction":"Two grants on one store: publisher A (0xBEEF) and publisher B (0xCAFE). A publishes sequence 1. B's publish(id2,1,1,...) in the same block reverts InvalidRecord. Confirmed by scratch test testConcurrentGrantsContendOnSequence.","severity":"info","snippet":"        if (sequence != record.sequence + 1 || observedAt == 0 || observedAt > block.timestamp ||","title":"Concurrent owner-approved grants contend on the single store-wide sequence and observedAt; a competing publisher's write reverts the other's"},{"citation":"resolved","description":"Reviewed and found to behave as specified; recorded so the accepted scope is explicit. (1) EIP-712 grants bind chainId and store via the domain, and projectId/feedId/schemaHash/publisher/authorityGeneration/limits/expiresAt/nonce via the struct hash; cross-chain, cross-store and wrong-signer registrations revert (existing tests plus scratch checks). (2) Nonces are per current owner per store; a used nonce can never be re-registered and re-registration of an identical grant never resets used/lastWrite/revoked. (3) Every setAuthority, proposeProjectOwner (including a cancelling proposal to address(0)) and acceptProjectOwner increments authorityGeneration, so all grants die on any ownership or mandate action; pending transfers also block publish/registerGrant via _project(true). setPolicy does not affect grants (upgrade delay only). (4) Limits hold exactly at the boundary: maximumWrites 10001, expiresAt now+30d+1s and minimumInterval 7d+1s are rejected; 10000 / 30d / 1s accepted. Unlimited separately signed grants per owner are intentional. (5) publish requires evaluator.version()==expectedVersion before the call, and the call is a Solidity view call (STATICCALL) into the immutable ClausReadonlyProject, which forwards a fixed 200000 gas to the module via STATICCALL, bounds returndata to 8256 bytes and validates the ABI header before copying; the store then rejects empty or >512-byte output. State writes occur after the call; reentrancy is impossible under STATICCALL. abi.encode(Input) with 2048-byte data is 2464 bytes, under the project's 8192-byte input cap. (6) The store cannot change the evaluator; applyUpgrade remains executor-only (test asserts ExecutorOnly). No Ether, tokens, delegatecall or arbitrary calls are introduced; the only external calls are view reads of the executor and evaluator. (7) Owner and publisher must have empty code at registration/publish; EIP-7702-delegated accounts are rejected, as intended. revokeGrant and cancelGrantNonce do not require an EOA owner, which is the safe direction. Projects whose executor owner is an ERC-1271 contract cannot use the factory or register grants (InvalidProject / InvalidGrant); that is the stated EOA-only scope. (8) The factory requires msg.sender to be the current enabled owner with no pending transfer and deploys with CREATE2 salt = projectId; the store constructor re-verifies the executor's project record, evaluator.executor() and evaluator.projectId(). Direct (non-factory) store deployments are possible by anyone but are not registered in stores[projectId] and need their own owner-signed grants, so they confer no authority. (9) Publishers attest data and sourceHash; neither is bound to the other on chain, and the Pulse module does not verify the reported base fee or hash, which the brief declares out of scope. The zero mandatory upgrade delay is the executor's existing owner-selected behaviour and was not re-reviewed here.","line":94,"path":"contracts/src/ClausSignalStore.sol","reproduction":"Not a defect. Existing suite: forge test --match-path test/ClausSignalStore.t.sol (8 passing). Scratch checks in test/scratch/SignalReview.t.sol: testGrantLimitBoundaries, testOldVersionRecordPersistsAfterUpgrade, testConcurrentGrantsContendOnSequence (all passing on this commit).","severity":"info","snippet":"    function registerGrant(Grant calldata g, bytes calldata signature) external returns (bytes32 id) {","title":"Trust assumptions and deliberate limited-profile behaviour confirmed in this commit (no defect)"}],"hash":"385049ae21a550f6c99c5473f8bfb2fcd96bf0b8eeb79a2ebd292bf29ec1b4f0","nodeId":"865c816d-118a-4de2-a76a-c8bb893010f3","outcome":"completed","summary":"The review is complete. The findings file is written and validated, and the full suite plus my scratch reproductions pass on this commit.\n\n**Outcome.** The signal kernel holds up: I found no critical, high or medium defect. Grant binding, replay protection, generation invalidation, the version check, STATICCALL isolation and the output bounds all behave as the brief specifies. The findings file holds four low findings, two informational notes and one record of confirmed trust assumptions, each with a reproduction confirmed by a scratch test.\n\n**Low findings:**\n- **Cancel-nonce race.** `cancelGrantNonce` succeeds silently after a publisher front-runs it with `registerGrant`, so the grant stays live and the owner's cancellation event is misleading. Bounded by the grant's own limits.\n- **Pulse module accepts impossible blocks.** A block number above the current head with any hash is accepted and stored. Fix is a one-clause check.\n- **Revocation does not reach reads.** A revoked grant's record, or one from a superseded evaluator version, keeps being served by `latest()` until its `validUntil`. Consumers must check the grant and version themselves.\n- **Factory slot is write-once with unvalidated feed and schema.** A mistyped `create()` leaves the project with a permanently unusable canonical store.\n\n**Informational:** the idempotent `registerGrant` path skips signature validation and malformed signatures raise OpenZeppelin errors rather than the contract's own; concurrent grants contend on the store-wide sequence. The seventh entry documents the deliberate EOA-only, attestation-based profile as confirmed behavior rather than defects.\n\n**Coverage.** Reviewed all four new contracts, the test file, and their integration with the unchanged executor and readonly project. The reference guides' execution-trace, periphery and first-principles passes were applied; no fuzzing beyond the existing suite was run. Nothing outside `.imd-findings.json` and `contracts/test/scratch/` was written.","treeHash":null,"usage":{"cachedInputTokens":933861,"inputTokens":386,"model":"claude-fable-5-1","outputTokens":40404,"runtime":"claude","turns":22,"wallClockMs":571035}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"f768e94767a9dde3","findings":[{"citation":"resolved","description":"The brief states the publisher must be an EOA and the store enforces this only with `msg.sender.code.length != 0` (publish, line 133) and `g.publisher.code.length != 0` (registerGrant, line 99). Both checks read EXTCODESIZE, which is zero for an address whose contract is still being constructed and zero for any counterfactual CREATE/CREATE2 address. An owner who is handed such an address cannot distinguish it from a key-controlled EOA when signing the grant; registration accepts it, and the contract then calls publish from its constructor and the write succeeds. The record, sequence and grant write counter all advance under a contract publisher. Because the project deliberately excludes EIP-7702 and smart accounts, this is a violation of a stated kernel invariant rather than of a documented limitation; it lets a publisher compose the write atomically with other calls in the same transaction, which an EOA cannot do, and under EIP-6780 a constructor that self-destructs lets the same CREATE2 address be re-created in later transactions and publish again. Minimal fix that preserves the intended EOA-only profile: in publish (and symmetrically in registerGrant for the publisher) also require `msg.sender == tx.origin`, keeping the code-length check so EIP-7702 delegated EOAs remain excluded.","line":133,"path":"contracts/src/ClausSignalStore.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.27;\nimport {Test} from \"forge-std/Test.sol\";\nimport {ClausApprovalExecutor} from \"src/ClausApprovalExecutor.sol\";\nimport {ClausReadonlyProject} from \"src/ClausReadonlyProject.sol\";\nimport {ClausSignalStore} from \"src/ClausSignalStore.sol\";\nimport {ClausPulseModule} from \"src/ClausPulseModule.sol\";\n\ninterface IPending { function pending() external view returns (ClausSignalStore, bytes32, bytes memory); }\n\n/// A contract publisher. While its constructor runs its code length is zero, so\n/// ClausSignalStore.publish's `msg.sender.code.length != 0` guard does not see it.\ncontract ConstructorPublisher {\n    constructor() {\n        (ClausSignalStore sink, bytes32 grantId, bytes memory data) = IPending(msg.sender).pending();\n        sink.publish(grantId, 1, 1, uint64(block.timestamp), uint64(block.timestamp + 300), keccak256(\"src\"), data);\n    }\n}\n\ncontract ProofPublisherEoaCheckTest is Test {\n    uint256 constant OWNER = 0xB0B;\n    uint256 constant CLAUS = 0xA11CE;\n    bytes32 constant MANDATE = keccak256(\"signal-test\");\n    bytes32 constant POLICY = keccak256(\"readonly-test\");\n    bytes32 constant SALT = keccak256(\"counterfactual-publisher\");\n    ClausApprovalExecutor executor;\n    ClausReadonlyProject target;\n    ClausSignalStore sink;\n    ClausPulseModule module;\n    bytes32 projectId;\n    bytes32 grantId;\n\n    function pending() external view returns (ClausSignalStore, bytes32, bytes memory) {\n        return (sink, grantId, abi.encode(block.chainid, uint64(123), keccak256(\"source-block\"), uint64(block.timestamp), uint256(2 gwei)));\n    }\n    function sign(uint256 key, bytes32 hash) internal pure returns (bytes memory) {\n        (uint8 v, bytes32 r, bytes32 s) = vm.sign(key, hash); return abi.encodePacked(r, s, v);\n    }\n    function setUp() public {\n        vm.warp(1800000000); vm.roll(1000); vm.chainId(11155111);\n        executor = new ClausApprovalExecutor(vm.addr(CLAUS), vm.addr(OWNER), 1 days);\n        module = new ClausPulseModule();\n        bytes32 salt = keccak256(\"pulse\"); projectId = executor.projectKey(vm.addr(OWNER), salt);\n        bytes memory config = abi.encode(uint256(1 gwei), uint256(3600));\n        ClausApprovalExecutor.Registration memory r = ClausApprovalExecutor.Registration(projectId, vm.addr(OWNER), salt,\n            address(module), address(module).codehash, keccak256(config), MANDATE, POLICY, 0, 0, uint64(block.timestamp + 1 days));\n        target = ClausReadonlyProject(executor.registerProject(r, config, sign(OWNER, executor.registrationDigest(r))));\n        sink = new ClausSignalStore(address(executor), projectId, address(target), module.FEED(), module.SCHEMA());\n        // The owner is handed a code-less address (a counterfactual CREATE2 contract) and signs a grant for it.\n        address counterfactual = vm.computeCreate2Address(SALT, keccak256(type(ConstructorPublisher).creationCode), address(this));\n        assertEq(counterfactual.code.length, 0);\n        ClausSignalStore.Grant memory g = ClausSignalStore.Grant(counterfactual, 1, 10, 1, uint64(block.timestamp + 1 days), 0);\n        grantId = sink.registerGrant(g, sign(OWNER, sink.grantDigest(g)));\n    }\n\n    /// Expected: a contract publisher is rejected with PublisherOnly. Actual: it publishes.\n    function testContractPublisherIsRejected() public {\n        vm.expectRevert(ClausSignalStore.PublisherOnly.selector);\n        new ConstructorPublisher{salt: SALT}();\n    }\n\n    /// Expected: no record is ever written by a contract. Actual: sequence 1 is written and a grant write is consumed.\n    function testContractPublisherCannotWriteRecords() public {\n        try new ConstructorPublisher{salt: SALT}() {} catch {}\n        assertEq(sink.latest().sequence, 0, \"a contract publisher wrote a record\");\n        assertEq(sink.grant(grantId).used, 0, \"a contract publisher consumed a grant write\");\n    }\n}","reproduction":"State: a registered project with a ClausSignalStore bound to it. Input: compute the CREATE2 address A of a contract whose constructor calls sink.publish(grantId, 1, 1, block.timestamp, block.timestamp+300, keccak256('src'), validPulseData). A.code.length == 0, so the owner's signed Grant(publisher=A, generation=1, maximumWrites=10, minimumInterval=1, expiresAt=now+1 day, nonce=0) registers. Deploy the contract with that salt. Expected: publish reverts with PublisherOnly because the caller is a contract. Actual: the call succeeds; sink.latest().sequence == 1, sink.latest().grantId == grantId, sink.grant(grantId).used == 1, and A.code.length > 0 after the transaction. Run `forge test --match-path test/scratch/ProofPublisherEoaCheck.t.sol` from contracts/: both tests fail on the current code.","severity":"low","snippet":"        if (msg.sender != g.publisher || msg.sender.code.length != 0) revert PublisherOnly();","title":"Publisher EOA-only guard is bypassed by a contract publishing from its constructor"},{"citation":"resolved","description":"The evaluator rejects observations whose timestamp lies in the future (`timestamp > block.timestamp`) but applies no equivalent bound to the observed block number, so `number` may exceed `block.number`. Unlike the block hash and base fee, which the publisher attests and which the review explicitly does not treat as proof of truth, a block number above the current head is provably false on-chain at zero cost. The published record then carries a block number that no consumer can ever match against chain history, and the asymmetry with the timestamp check means the freshness guard can be satisfied by an observation that cannot have been taken. Fix: add `number > block.number` to the InvalidObservation condition. Optionally, when `number` is within the last 256 blocks, also require `hash == blockhash(number)`; that is an enhancement beyond the attestation model, not part of this defect.","line":23,"path":"contracts/src/ClausPulseModule.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.27;\nimport {Test} from \"forge-std/Test.sol\";\nimport {ClausApprovalExecutor} from \"src/ClausApprovalExecutor.sol\";\nimport {ClausReadonlyProject} from \"src/ClausReadonlyProject.sol\";\nimport {ClausSignalStore} from \"src/ClausSignalStore.sol\";\nimport {ClausPulseModule} from \"src/ClausPulseModule.sol\";\n\ncontract ProofPulseFutureBlockTest is Test {\n    uint256 constant OWNER = 0xB0B;\n    uint256 constant CLAUS = 0xA11CE;\n    uint256 constant PUBLISHER = 0xBEEF;\n    bytes32 constant MANDATE = keccak256(\"signal-test\");\n    bytes32 constant POLICY = keccak256(\"readonly-test\");\n    ClausApprovalExecutor executor;\n    ClausReadonlyProject target;\n    ClausSignalStore sink;\n    ClausPulseModule module;\n    bytes32 projectId;\n    bytes32 grantId;\n\n    function sign(uint256 key, bytes32 hash) internal pure returns (bytes memory) {\n        (uint8 v, bytes32 r, bytes32 s) = vm.sign(key, hash); return abi.encodePacked(r, s, v);\n    }\n    function setUp() public {\n        vm.warp(1800000000); vm.roll(1000); vm.chainId(11155111);\n        executor = new ClausApprovalExecutor(vm.addr(CLAUS), vm.addr(OWNER), 1 days);\n        module = new ClausPulseModule();\n        bytes32 salt = keccak256(\"pulse\"); projectId = executor.projectKey(vm.addr(OWNER), salt);\n        bytes memory config = abi.encode(uint256(1 gwei), uint256(3600));\n        ClausApprovalExecutor.Registration memory r = ClausApprovalExecutor.Registration(projectId, vm.addr(OWNER), salt,\n            address(module), address(module).codehash, keccak256(config), MANDATE, POLICY, 0, 0, uint64(block.timestamp + 1 days));\n        target = ClausReadonlyProject(executor.registerProject(r, config, sign(OWNER, executor.registrationDigest(r))));\n        sink = new ClausSignalStore(address(executor), projectId, address(target), module.FEED(), module.SCHEMA());\n        ClausSignalStore.Grant memory g = ClausSignalStore.Grant(vm.addr(PUBLISHER), 1, 10, 1, uint64(block.timestamp + 1 days), 0);\n        grantId = sink.registerGrant(g, sign(OWNER, sink.grantDigest(g)));\n    }\n\n    /// Directly on the module: an observation of block 1,001,000 while the chain is at block 1,000.\n    /// Expected: InvalidObservation (the block cannot exist). Actual: accepted and classified.\n    function testModuleRejectsBlockNumberAboveChainHead() public {\n        bytes memory observation = abi.encode(block.chainid, uint64(block.number + 1_000_000), keccak256(\"x\"), uint64(block.timestamp), uint256(2 gwei));\n        bytes memory input = abi.encode(ClausSignalTypesInput(block.chainid, address(sink), projectId, module.FEED(), module.SCHEMA(),\n            vm.addr(PUBLISHER), 1, uint64(block.timestamp), uint64(block.timestamp + 300), keccak256(\"src\"), observation));\n        vm.expectRevert(ClausPulseModule.InvalidObservation.selector);\n        module.evaluate(abi.encode(uint256(1 gwei), uint256(3600)), input);\n    }\n\n    /// Through the store: the same observation becomes the published record.\n    function testStoreDoesNotPublishBlockNumberAboveChainHead() public {\n        bytes memory observation = abi.encode(block.chainid, uint64(block.number + 1_000_000), keccak256(\"x\"), uint64(block.timestamp), uint256(2 gwei));\n        vm.prank(vm.addr(PUBLISHER));\n        vm.expectRevert(ClausReadonlyProject.ModuleCallFailed.selector);\n        sink.publish(grantId, 1, 1, uint64(block.timestamp), uint64(block.timestamp + 300), keccak256(\"src\"), observation);\n    }\n\n    // Mirror of ClausSignalTypes.Input, declared locally so the test only depends on the contracts under review.\n    struct ClausSignalTypesInput {\n        uint256 chainId; address store; bytes32 projectId; bytes32 feedId; bytes32 schemaHash; address publisher;\n        uint64 sequence; uint64 observedAt; uint64 validUntil; bytes32 sourceHash; bytes data;\n    }\n}","reproduction":"State: chain at block 1000, project configured with threshold 1 gwei and maximumAge 3600, an active grant. Input: data = abi.encode(block.chainid, uint64(1_001_000), keccak256('x'), uint64(block.timestamp), 2 gwei); publisher calls sink.publish(grantId, 1, 1, block.timestamp, block.timestamp+300, keccak256('src'), data). Expected: the module reverts InvalidObservation (surfaced by the project as ModuleCallFailed) because block 1,001,000 does not exist. Actual: the call succeeds and sink.latest().output decodes to (1001000, keccak256('x'), 2 gwei, true). Calling module.evaluate(abi.encode(1 gwei, 3600), abi.encode(Input{... data})) directly returns the same output instead of reverting. Run `forge test --match-path test/scratch/ProofPulseFutureBlock.t.sol` from contracts/: both tests fail on the current code.","severity":"low","snippet":"        if (chainId != context.chainId || number == 0 || hash == bytes32(0) || timestamp != context.observedAt ||\n            timestamp > block.timestamp || block.timestamp - timestamp > maximumAge ||\n            context.validUntil > uint256(timestamp) + maximumAge) revert InvalidObservation();","title":"Pulse module accepts an attested block number above the chain head"},{"citation":"resolved","description":"create() lets the current owner bind the project's one factory store to any non-zero feedId and schemaHash without comparing them to what the registered evaluator will accept (ClausPulseModule checks `context.feedId != FEED || context.schemaHash != SCHEMA` on every evaluate). The store's feedId and schemaHash are immutable, the factory mapping is write-once, and nothing in the store or factory can retire or replace a store. A single mistaken create() therefore leaves the project with a registry entry whose every publish reverts, and the only recoveries are an owner-plus-Claus evaluator upgrade to a module that accepts the wrong feed, or a store deployed outside the factory that the registry does not point to. The same dead end is reached whenever an approved upgrade moves the evaluator to a module with a different FEED or SCHEMA. This is consistent with the stated one-immutable-store design, so it is reported as a design-level liveness trap rather than an access defect. Possible fixes without changing the ownership model: have the factory read FEED()/SCHEMA() from the project's current implementation when they are exposed and reject mismatches, or allow the current owner to create a replacement store for a project whose registered store was never written to.","line":25,"path":"contracts/src/ClausSignalFactory.sol","reproduction":"State: registered project whose evaluator is ClausPulseModule, factory deployed. Input: owner calls factory.create(projectId, keccak256('typo'), module.SCHEMA()). Expected: either a revert because the evaluator will never accept the feed, or a later corrective create(). Actual: a store is created and registered; a publisher with a valid grant calling publish on it reverts with ModuleCallFailed (InvalidObservation inside the module) for every input; the owner's corrective factory.create(projectId, module.FEED(), module.SCHEMA()) reverts StoreAlreadyCreated. Verified by testFactoryStoreWithWrongFeedCannotBeReplaced in test/scratch/SignalReview.t.sol.","severity":"low","snippet":"        if (stores[projectId] != address(0)) revert StoreAlreadyCreated();","title":"Factory store slot is single-use and never checks the feed or schema the evaluator accepts"},{"citation":"resolved","description":"revokeGrant only sets `grants[id].revoked = true` and gates future publish calls. The current record, including its output and validUntil, stays in place and latest() returns it unchanged, so a consumer that checks only validUntil and version keeps consuming data from a publisher the owner has just cut off, for up to MAX_DATA_LIFETIME (1 day). The owner has no clear or supersede function; the only way to displace the record is another active grantee publishing a newer sequence with observedAt at or above the revoked record's observedAt, which is always possible within one block but requires a second grant and a passing evaluation. Behaviour is self-consistent and arguably deliberate (the record is an attestation that was valid when written), so this is informational: either document that consumers must also check `grant(latest().grantId).revoked`, or let revokeGrant zero the record when `record.grantId == id`.","line":125,"path":"contracts/src/ClausSignalStore.sol","reproduction":"State: active grant G. Publisher publishes sequence 1 with validUntil = now + 3600. Owner calls sink.revokeGrant(G). Read sink.latest(): grantId == G, validUntil > block.timestamp, output unchanged, while sink.grant(G).revoked == true. Verified by testRevocationLeavesRevokedRecordAsLatest in test/scratch/SignalReview.t.sol.","severity":"info","snippet":"    function latest() external view returns (Record memory) { return record; }","title":"Revoking a grant does not invalidate the record it already published"},{"citation":"resolved","description":"ClausApprovalExecutor.execute and ClausReadonlyProject.applyUpgrade store the configuration bytes without calling the module, and the module checks length, threshold and maximumAge only inside evaluate. A Claus-approved upgrade whose configuration is 64 bytes with threshold 0, or any length other than 64, is accepted by the executor and makes every subsequent publish revert with ModuleCallFailed until another independently reviewed and Claus-signed upgrade is executed. The zero mandatory delay the owner selected means the halt takes effect immediately. This requires a privileged, reviewed action and is therefore reported as information for the reviewers of module and config changes, not as a defect: an off-chain pre-check of `ClausPulseModule.evaluate` with the proposed config, or a view such as `validateConfiguration(bytes)` the review can call, closes the gap.","line":18,"path":"contracts/src/ClausPulseModule.sol","reproduction":"State: registered project with a working store and grant. Input: queue and execute an Approval for the same ClausPulseModule with initCallHash = keccak256(abi.encode(uint256(0), uint256(3600))) and pass that config to execute. Expected: a config the module can never accept is rejected before it is applied. Actual: execute succeeds, version becomes 2, and publisher calls to publish(grantId, 1, 2, ...) with otherwise valid pulse data revert ModuleCallFailed (InvalidConfiguration inside the module) on every attempt until a further approved upgrade.","severity":"info","snippet":"        if (threshold == 0 || maximumAge == 0 || maximumAge > 1 days) revert InvalidConfiguration();","title":"Pulse configuration is only validated at evaluate time, so a bad approved config halts publishing until the next approved upgrade"},{"citation":"resolved","description":"The store constructor validates the executor, project, evaluator, feed and schema but not the deployer, so a third party can deploy a store that is bound to someone else's project with the same feed and schema and is byte-for-byte identical to a factory store. Such a store cannot be written to without a grant signed by the project owner over that store's own domain (verifyingContract differs), so it cannot forge data; the risk is confusion for consumers and off-chain tooling that locate stores by project rather than through ClausSignalFactory.stores(projectId) or the store's own address. Information only: document that the factory mapping (or the StoreCreated event) is the only registry, or restrict the constructor to `msg.sender == owner || msg.sender is the factory` if direct deployments are not intended.","line":71,"path":"contracts/src/ClausSignalStore.sol","reproduction":"State: project P owned by O, factory store S = factory.stores(P). Input: unrelated address X calls new ClausSignalStore(executor, P, target, module.FEED(), module.SCHEMA()). Expected (if only owner-created stores are intended): revert. Actual: deployment succeeds and S2.projectId() == P, S2.evaluator() == target, S2.feedId() == S.feedId(); S2.registerGrant with a signature O made for S reverts InvalidSignature (verified by testGrantCannotCrossChainsStoresSignersOrCancelledNonces), so S2 stays empty unless O signs for it.","severity":"info","snippet":"    constructor(address approvalExecutor, bytes32 id, address target, bytes32 feed, bytes32 schema)","title":"Any address can deploy a ClausSignalStore for any project outside the factory"}],"hash":"9d34dee3ccb76686b2b3b679c66399f4c19627590ac90e9cf824352025658d42","nodeId":"0cbefd8f-7d00-4475-8c1f-fd13b5272b05","outcome":"completed","summary":"The review is complete. Findings are in `.imd-findings.json` at the repository root, no contract or configuration file was changed, and the only new files are under `contracts/test/scratch/`.\n\n**Outcome.** The signal kernel holds up well against the commissioned checks. I found no critical, high, or medium defect. The grant signature binds chain, store, project, feed, schema, publisher, generation and nonce; registration is idempotent and never resets usage or revocation; pending and completed ownership changes and every authority change kill old grants through the generation; sequences and observation times are monotonic; the evaluator version is pinned per write; evaluation is a STATICCALL with 200k gas and bounded return bytes; and nothing custodial, payable or delegatecall-based was introduced. The 70 existing tests pass.\n\n**Findings written, most severe first:**\n\n- **Low, with proof.** The publisher EOA-only guard in `publish` is an EXTCODESIZE check, so a contract calling from its constructor passes it. An owner handed a counterfactual CREATE2 address cannot tell it from an EOA, registration accepts it, and the contract writes a record and consumes a grant write. Fix is to also require the caller to equal the transaction origin while keeping the code-length check for EIP-7702.\n- **Low, with proof.** The pulse module bounds the observed timestamp to the past but not the block number, so an observation of block 1,001,000 is accepted while the chain is at block 1,000. A one-term check against the current block number closes it.\n- **Low.** The factory slot is write-once and never compares the chosen feed and schema to what the evaluator accepts. One mistaken creation, or an upgrade to a module with a different feed, leaves the project's only registered store permanently unable to publish.\n- **Info.** Revocation stops future writes but leaves the revoked publisher's record as `latest()` until another grantee overwrites it. Consumers must also check the record's grant for revocation.\n- **Info.** Pulse configuration is validated only at evaluate time, so an approved config with a zero threshold halts publishing immediately until the next reviewed upgrade.\n- **Info.** Anyone can deploy a store for any project outside the factory. It cannot be written to without the owner signing for that store's own domain, but tooling must treat the factory mapping as the only registry.\n\n**Verification.** Both proof files fail on the current code for the stated reasons and are written to pass once the one-line fixes land. The scratch directory also holds the harness I used to confirm the factory and revocation observations. Documented powers of the owner, Claus and grantees, including a grantee overwriting or front-running another grantee's sequence, are recorded as trust assumptions rather than findings.","treeHash":null,"usage":{"cachedInputTokens":2372029,"inputTokens":482,"model":"claude-fable-5-1","outputTokens":58937,"runtime":"claude","turns":35,"wallClockMs":842487}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"af9a875696459139","findings":[{"citation":"resolved","description":"registerGrant (line 94) has no caller restriction: anyone holding the owner's EIP-712 signature can register the grant. The owner has two withdrawal tools whose coverage is disjoint and switches at the moment of registration. cancelGrantNonce (lines 109-114) only matters while the grant is unregistered; once registerGrant has set grantNonces[owner][nonce] (line 104) the cancel is a silent no-op that still emits GrantNonceCancelled. revokeGrant (lines 116-122) reverts with InvalidGrant at line 119 for any digest that is not yet registered, so the owner cannot pre-revoke by digest. A publisher (or anyone it shared the signature with) who sees the owner's cancelGrantNonce transaction in the mempool registers the grant first; the owner's cancel then succeeds without effect and the grant is live until the owner notices and sends a second transaction (revokeGrant). The owner cannot close this race from an EOA (the only owner type the store admits, line 99) because register-then-revoke is two transactions. This is distinct from the deliberate per-grant limits: the contract comment at line 96 and the tests (testGrantCannotCrossChainsStoresSignersOrCancelledNonces) present nonce cancellation as a complete pre-activation control. Exploitability is limited: only the owner-chosen publisher EOA can write, bounded by maximumWrites/minimumInterval/expiresAt, the data is an attestation with no custody, and the owner retains revokeGrant, hence Low. Minimal fix preserving the design: let revokeGrant mark any digest revoked (drop the line-119 check); registerGrant already leaves the revoked flag untouched, so a pre-revoked grant registers into a dead state and publish reverts GrantInactive at line 134. Optionally make cancelGrantNonce revert or emit a distinct event when the nonce is already consumed.","line":119,"path":"contracts/src/ClausSignalStore.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.27;\nimport {Test} from \"forge-std/Test.sol\";\nimport {ClausApprovalExecutor} from \"src/ClausApprovalExecutor.sol\";\nimport {ClausReadonlyProject} from \"src/ClausReadonlyProject.sol\";\nimport {ClausSignalStore} from \"src/ClausSignalStore.sol\";\nimport {ClausPulseModule} from \"src/ClausPulseModule.sol\";\n\n/// A signed grant can be registered by anyone. The owner has two withdrawal\n/// tools, but neither works in the state the other covers: cancelGrantNonce\n/// only matters before registration, revokeGrant only after. A third party\n/// who registers the grant ahead of the owner's cancellation activates it.\n/// The owner must be able to kill a signed grant it knows the digest of in\n/// one transaction, whatever a front-runner does.\ncontract GrantPreRevocationTest is Test {\n    uint256 constant OWNER = 0xB0B;\n    uint256 constant CLAUS = 0xA11CE;\n    uint256 constant PUBLISHER = 0xBEEF;\n    bytes32 constant MANDATE = keccak256(\"signal-test\");\n    bytes32 constant POLICY = keccak256(\"readonly-test\");\n    ClausApprovalExecutor executor;\n    ClausReadonlyProject target;\n    ClausSignalStore sink;\n    ClausPulseModule module;\n    bytes32 projectId;\n\n    function sign(uint256 key, bytes32 hash) internal pure returns (bytes memory) {\n        (uint8 v, bytes32 r, bytes32 s) = vm.sign(key, hash); return abi.encodePacked(r, s, v);\n    }\n    function setUp() public {\n        vm.warp(1800000000); vm.chainId(11155111);\n        executor = new ClausApprovalExecutor(vm.addr(CLAUS), vm.addr(OWNER), 1 days);\n        module = new ClausPulseModule();\n        bytes32 salt = keccak256(\"pulse\"); projectId = executor.projectKey(vm.addr(OWNER), salt);\n        bytes memory config = abi.encode(uint256(1 gwei), uint256(3600));\n        ClausApprovalExecutor.Registration memory r = ClausApprovalExecutor.Registration(projectId, vm.addr(OWNER), salt,\n            address(module), address(module).codehash, keccak256(config), MANDATE, POLICY, 0, 0, uint64(block.timestamp + 1 days));\n        target = ClausReadonlyProject(executor.registerProject(r, config, sign(OWNER, executor.registrationDigest(r))));\n        sink = new ClausSignalStore(address(executor), projectId, address(target), module.FEED(), module.SCHEMA());\n    }\n    function pulse(uint64 observed) internal view returns (bytes memory) {\n        return abi.encode(block.chainid, uint64(123), keccak256(\"source-block\"), observed, uint256(2 gwei));\n    }\n\n    /// Owner signed a grant, then decides to withdraw it before anyone registered it.\n    /// Expected: one owner transaction naming the grant leaves it unusable even if a\n    /// third party registers the signed terms afterwards (or in the same block, ahead of it).\n    /// Actual: revokeGrant reverts with InvalidGrant for an unregistered digest, the\n    /// third party registers the grant, and the publisher writes a record.\n    function testOwnerCanWithdrawSignedGrantBeforeRegistration() public {\n        uint64 now64 = uint64(block.timestamp);\n        ClausSignalStore.Grant memory g = ClausSignalStore.Grant(vm.addr(PUBLISHER), 1, 10, 1, now64 + 1 days, 77);\n        bytes memory sig = sign(OWNER, sink.grantDigest(g));\n        bytes32 id = sink.grantDigest(g);\n\n        // Owner withdraws the grant by its digest, the only identifier it has for a signed grant.\n        vm.prank(vm.addr(OWNER));\n        sink.revokeGrant(id);\n\n        // Anyone may still submit the signed terms afterwards.\n        vm.prank(address(0xDEAD));\n        sink.registerGrant(g, sig);\n\n        // The withdrawn grant must not be usable.\n        assertTrue(sink.grant(id).revoked, \"withdrawn grant must stay revoked\");\n        vm.prank(vm.addr(PUBLISHER));\n        vm.expectRevert(ClausSignalStore.GrantInactive.selector);\n        sink.publish(id, 1, 1, now64, now64 + 60, keccak256(\"s\"), pulse(now64));\n        assertEq(sink.latest().sequence, 0, \"no record may be published under a withdrawn grant\");\n    }\n}","reproduction":"Setup as in test/ClausSignalStore.t.sol: owner 0xB0B, publisher 0xBEEF, project registered with ClausPulseModule, store deployed, chainId 11155111, timestamp 1800000000. Owner signs Grant(publisher=vm.addr(0xBEEF), authorityGeneration=1, maximumWrites=10, minimumInterval=1, expiresAt=1800086400, nonce=77). (1) Owner calls revokeGrant(grantDigest(g)) before anyone registered it: expected the grant to be dead; actual revert InvalidGrant() (line 119). (2) Race variant: owner broadcasts cancelGrantNonce(77); address 0xDEAD front-runs with registerGrant(g, sig) which succeeds; cancelGrantNonce(77) then succeeds and emits GrantNonceCancelled but grant(id).revoked == false; publisher calls publish(id, 1, 1, 1800000000, 1800000060, keccak256('s'), pulse data) and latest().grantId == id, sequence 1. Expected: a signed grant the owner has withdrawn by digest or nonce cannot write; actual: it writes until a separate revokeGrant lands. Scratch reproduction: forge test --match-path test/scratch/GrantPreRevocation.t.sol fails with InvalidGrant() on this code and passes when the line-119 check is removed (verified against a scratch copy).","severity":"low","snippet":"        if (grants[id].terms.publisher == address(0)) revert InvalidGrant();","title":"Signed grant cannot be withdrawn before registration: revokeGrant requires a registered id, so cancelGrantNonce can be front-run through the permissionless registerGrant"},{"citation":"resolved","description":"Boundary x invariant seam between two independently correct limits. The module accepts age <= maximumAge (line 24) and caps validUntil <= observedAt + maximumAge (line 25). The store requires validUntil > block.timestamp (ClausSignalStore.sol line 138). When block.timestamp - observedAt == maximumAge the module's cap gives validUntil <= block.timestamp, which the store rejects, so the acceptable validUntil set is empty: the effective maximum age is maximumAge - 1 and the module's own inclusive bound is unreachable through the only writer. No value is at risk (arithmetic is checked, no overflow or truncation; both subtractions are guarded by the preceding disjunct) and the publisher simply uses a fresher observation, but the documented configuration value does not mean what it says for the store path, and any consumer or test that pins the edge will disagree with the module. Minimal fix: either make the module age check '>=' so the publishable bound equals the configured one, or document maximumAge as exclusive for store publication.","line":24,"path":"contracts/src/ClausPulseModule.sol","reproduction":"Configuration abi.encode(uint256(1 gwei), uint256(3600)); T0 = 1800000000; grant with minimumInterval 1. publish(seq 1, observedAt T0, validUntil T0+3600) at T0 succeeds (both bounds met exactly). vm.warp(T0+3600). publish(seq 2, observedAt T0, validUntil T0+3600): expected acceptable since age == maximumAge; actual revert InvalidRecord() (validUntil <= block.timestamp). publish(seq 2, observedAt T0, validUntil T0+3601): actual revert ModuleCallFailed() (module: validUntil > timestamp + maximumAge). publish(seq 2, observedAt T0+1, validUntil T0+3601) (age 3599) succeeds. Verified in test/scratch/Probe2.t.sol testAgeBoundary.","severity":"info","snippet":"            timestamp > block.timestamp || block.timestamp - timestamp > maximumAge ||","title":"Store and module time bounds compose to an exclusive age limit: an observation exactly maximumAge old is accepted by the module in isolation but can never be published"},{"citation":"resolved","description":"contracts/SOURCE_MANIFEST.json enumerates eight sources and eight tests that predate this commit and none of ClausSignalTypes.sol, ClausSignalStore.sol, ClausSignalFactory.sol, ClausPulseModule.sol or test/ClausSignalStore.t.sol. The factory derives store addresses with CREATE2 over the exact creation code (ClausSignalFactory.sol line 26), so the precise source and compiler settings determine every store address; a manifest that omits them does not let a reviewer or deployer pin the artifact this review covered. The existing entries still match (sha256 of ClausApprovalExecutor.sol and ClausReadonlyProject.sol equal the manifest values), so only the additions are missing. Fix: regenerate the manifest for commit 41683cf.","line":15,"path":"contracts/README.md","reproduction":"grep -c 'Signal\\|Pulse' contracts/SOURCE_MANIFEST.json prints 0. sha256sum of the untracked files: ClausSignalStore.sol 0bb41e23c614e5981125ee371e9081d80ef6498d664256c5a970e1240061f185, ClausSignalFactory.sol 100f4fe0a4bc288bd81fd10777828572f4dab9ef32eb006b5897d44118610fc0, ClausPulseModule.sol 728c9e3b789d97bb89fc5b2d5e023b256c2f8b8c98326598763f67c1d3ef8610, ClausSignalTypes.sol 72b7dc98f3333a1abf2d22d4408a46801c9455709de63bd6d3f9e1fce78ff61a, test/ClausSignalStore.t.sol b0fc2c930b9ff5940d9bf20318beb3a09c69951a8472644c2caca963ded80dd9; none appears in the manifest, while src/ClausApprovalExecutor.sol 92df1230... and src/ClausReadonlyProject.sol 7c7fe172... do.","severity":"info","snippet":"Run `forge test` with Foundry and Solidity 0.8.27. Exact vendored dependencies and input hashes are listed in `SOURCE_MANIFEST.json`. No package installation, FFI or remote fork is needed for the tests.","title":"SOURCE_MANIFEST.json does not list the four new signal sources or their test, although the README directs reproducers to it for exact input hashes"},{"citation":"resolved","description":"foundry.toml compiles with via_ir, and the optimizer treats block.timestamp as constant within a call frame; vm.warp breaks that assumption. Traces of the committed suite show it happening: in testDataUpdatesReviewedUpgradeAndRevocation the warps at lines 71 and 77 both target 1800000060, and in testFiniteWritesIntervalAndIdempotentGrantNeverResetUsage the warps at lines 85 and 86 both target 1800000060, so publish(3,1) at line 86 runs in the same second as publish(2,1) and is blocked by the interval as well as by the write cap; the test does not demonstrate the cap alone after the interval has elapsed as its name states. All assertions still pass because an earlier guard fires (revoked, version, write cap), so no contract defect is hidden by this, but edge-time behaviour (expiry at exactly expiresAt, interval boundary, lifetime boundary) is not exercised at the instants the tests name. forge lint reports 29 environment-read-across-mutation warnings for this file at lines 35, 51, 56, 61, 71, 77, 85, 86, 112, 113, 120 and 121. Fix: read vm.getBlockTimestamp() after each warp (or capture the target instant once and pass it explicitly).","line":71,"path":"contracts/test/ClausSignalStore.t.sol","reproduction":"forge test --match-path test/ClausSignalStore.t.sol --match-test testFiniteWritesIntervalAndIdempotentGrantNeverResetUsage -vvvv shows VM::warp(1800000060) twice and both publish(2,1) and publish(3,1) carrying observedAt 1800000060; expected the second warp to land at 1800000120. The same pattern in a scratch test produced observedAt 1799996401 instead of 1800000001 after a warp, which is how the behaviour was first observed.","severity":"info","snippet":"        vm.warp(block.timestamp+60);","title":"New tests re-read block.timestamp after vm.warp; under via_ir the pre-warp value is reused, so the second warp in two tests does not advance time"}],"hash":"ccd10833d7538730deb40693942b5062998302b7f3d76e93b20a0859837dff29","nodeId":"e1b9bad7-8d0d-4ce0-8865-1753e0af4294","outcome":"completed","summary":"The findings file is present at the repository root (excluded from git by the harness's own `.git/info/exclude`, as intended), it parses, and no tracked file was modified. The review is complete.\n\n## Review outcome\n\nThe four new contracts are arithmetically clean. The signal kernel contains no division, no multiplication and no unchecked block. Every subtraction is guarded by the preceding disjunct in the same `||` chain, every narrowing cast is of a value that cannot exceed the target type, and every mixed-width comparison promotes safely. I confirmed each documented limit at its exact edge with scratch tests (writes 10000 vs 10001, interval 7 days vs 7 days + 1, expiry 30 days vs 30 days + 1, input 2048 vs 2049, output 512 vs 513 vs 0, lifetime 1 day vs 1 day + 1). I also confirmed the ABI decoder rejects dirty high bits in the pulse payload's `uint64` fields rather than truncating them, that the executor-tuple destructuring reads authority generation and not policy generation (every existing test has both equal to 1, so a wrong index would have gone unnoticed), and that a publish through the maximum 8 KiB kernel configuration costs about 783k gas.\n\n**Findings written to `.imd-findings.json`:**\n\n- **Low.** A signed grant cannot be withdrawn before registration. `revokeGrant` reverts for an unregistered digest and `cancelGrantNonce` is a silent no-op once anyone has registered the grant, so a holder of the signature can front-run the owner's cancellation and the grant goes live until a second owner transaction lands. Carries a proof test that fails on the current code with `InvalidGrant` and passes on a scratch copy with the one-line fix (let `revokeGrant` mark any digest).\n- **Info.** Store and pulse-module time bounds compose to an exclusive age limit: an observation exactly `maximumAge` old is accepted by the module alone but can never be published, because the module caps `validUntil` at `block.timestamp` while the store requires it to be strictly later. Effective bound is `maximumAge - 1`.\n- **Info.** `SOURCE_MANIFEST.json` omits all four new sources and their test while the README points reproducers to it for exact input hashes. Matters because the factory derives store addresses from the exact creation code.\n- **Info.** The new test file re-reads `block.timestamp` after `vm.warp`; under `via_ir` the pre-warp value is reused, and traces show the second warp in two tests re-targets the same instant. Assertions still pass, but for an earlier guard than the one named.\n\n**Verified as deliberate, not defects:** generation bumps from any authority change (including a cancelled owner proposal) permanently kill all grants; a pending owner transfer freezes publishing and registration while leaving revocation available; the sequence and `observedAt` floor are store-global, so concurrent publishers must track each other; separate grants to one publisher are not interval-coupled; the factory binds exactly one feed and schema per project for life, so a module feed change or a typo strands the canonical store with no factory remedy; the kernel's `evaluate` is public and will transform forged context for any caller, but only `publish` writes the record and it always builds the context itself.\n\n**Scope limits.** The two deployed executor commits are not in this clone, so the kernel was reviewed as it stands at HEAD. No `receive`, `fallback`, `payable`, `delegatecall` or value transfer exists in the new code. Scratch probes live in `contracts/test/scratch/` and are not part of the submission.","treeHash":null,"usage":{"cachedInputTokens":3292072,"inputTokens":770,"model":"claude-fable-5-1","outputTokens":86962,"runtime":"claude","turns":55,"wallClockMs":1401261}}],"verification":[]}