# Audit report

> 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.

| | |
|---|---|
| Repository | https://github.com/contractclaus/claus-network |
| Commit | `41683cf02aa2039b14a99f179496a4ca9c91e477` |
| Job | `4bfe5f40-24e0-4429-b930-397b3927ff5e` |
| Judged | 2026-10-13 01:10 UTC |
| Findings | 5 low · 7 info |

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

## Findings

### 1. Low: Publisher EOA-only guard is only a code-length check; contract code publishes from its constructor (CREATE2 counterfactual publisher, repeatable via same-tx selfdestruct)

`contracts/src/ClausSignalStore.sol:133`

```
        if (msg.sender != g.publisher || msg.sender.code.length != 0) revert PublisherOnly();
```

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.

**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).

**Proof**: a Foundry test that fails on this code and passes once it is fixed.

```solidity
// SPDX-License-Identifier: MIT
pragma solidity 0.8.27;
import {Test} from "forge-std/Test.sol";
import {ClausApprovalExecutor} from "src/ClausApprovalExecutor.sol";
import {ClausReadonlyProject} from "src/ClausReadonlyProject.sol";
import {ClausSignalStore} from "src/ClausSignalStore.sol";
import {ClausPulseModule} from "src/ClausPulseModule.sol";

interface IPending { function pending() external view returns (ClausSignalStore, bytes32, bytes memory); }

/// A contract publisher. While its constructor runs its code length is zero, so
/// ClausSignalStore.publish's `msg.sender.code.length != 0` guard does not see it.
contract ConstructorPublisher {
    constructor() {
        (ClausSignalStore sink, bytes32 grantId, bytes memory data) = IPending(msg.sender).pending();
        sink.publish(grantId, 1, 1, uint64(block.timestamp), uint64(block.timestamp + 300), keccak256("src"), data);
    }
}

contract ProofPublisherEoaCheckTest is Test {
    uint256 constant OWNER = 0xB0B;
    uint256 constant CLAUS = 0xA11CE;
    bytes32 constant MANDATE = keccak256("signal-test");
    bytes32 constant POLICY = keccak256("readonly-test");
    bytes32 constant SALT = keccak256("counterfactual-publisher");
    ClausApprovalExecutor executor;
    ClausReadonlyProject target;
    ClausSignalStore sink;
    ClausPulseModule module;
    bytes32 projectId;
    bytes32 grantId;

    function pending() external view returns (ClausSignalStore, bytes32, bytes memory) {
        return (sink, grantId, abi.encode(block.chainid, uint64(123), keccak256("source-block"), uint64(block.timestamp), uint256(2 gwei)));
    }
    function sign(uint256 key, bytes32 hash) internal pure returns (bytes memory) {
        (uint8 v, bytes32 r, bytes32 s) = vm.sign(key, hash); return abi.encodePacked(r, s, v);
    }
    function setUp() public {
        vm.warp(1800000000); vm.roll(1000); vm.chainId(11155111);
        executor = new ClausApprovalExecutor(vm.addr(CLAUS), vm.addr(OWNER), 1 days);
        module = new ClausPulseModule();
        bytes32 salt = keccak256("pulse"); projectId = executor.projectKey(vm.addr(OWNER), salt);
        bytes memory config = abi.encode(uint256(1 gwei), uint256(3600));
        ClausApprovalExecutor.Registration memory r = ClausApprovalExecutor.Registration(projectId, vm.addr(OWNER), salt,
            address(module), address(module).codehash, keccak256(config), MANDATE, POLICY, 0, 0, uint64(block.timestamp + 1 days));
        target = ClausReadonlyProject(executor.registerProject(r, config, sign(OWNER, executor.registrationDigest(r))));
        sink = new ClausSignalStore(address(executor), projectId, address(target), module.FEED(), module.SCHEMA());
        // The owner is handed a code-less address (a counterfactual CREATE2 contract) and signs a grant for it.
        address counterfactual = vm.computeCreate2Address(SALT, keccak256(type(ConstructorPublisher).creationCode), address(this));
        assertEq(counterfactual.code.length, 0);
        ClausSignalStore.Grant memory g = ClausSignalStore.Grant(counterfactual, 1, 10, 1, uint64(block.timestamp + 1 days), 0);
        grantId = sink.registerGrant(g, sign(OWNER, sink.grantDigest(g)));
    }

    /// Expected: a contract publisher is rejected with PublisherOnly. Actual: it publishes.
    function testContractPublisherIsRejected() public {
        vm.expectRevert(ClausSignalStore.PublisherOnly.selector);
        new ConstructorPublisher{salt: SALT}();
    }

    /// Expected: no record is ever written by a contract. Actual: sequence 1 is written and a grant write is consumed.
    function testContractPublisherCannotWriteRecords() public {
        try new ConstructorPublisher{salt: SALT}() {} catch {}
        assertEq(sink.latest().sequence, 0, "a contract publisher wrote a record");
        assertEq(sink.grant(grantId).used, 0, "a contract publisher consumed a grant write");
    }
}
```

### 2. Low: 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

`contracts/src/ClausSignalStore.sol:119`

```
        if (grants[id].terms.publisher == address(0)) revert InvalidGrant();
```

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.

**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.

**Proof**: a Foundry test that fails on this code and passes once it is fixed.

```solidity
// SPDX-License-Identifier: MIT
pragma solidity 0.8.27;
import {Test} from "forge-std/Test.sol";
import {ClausApprovalExecutor} from "src/ClausApprovalExecutor.sol";
import {ClausReadonlyProject} from "src/ClausReadonlyProject.sol";
import {ClausSignalStore} from "src/ClausSignalStore.sol";
import {ClausPulseModule} from "src/ClausPulseModule.sol";

/// A signed grant can be registered by anyone. The owner has two withdrawal
/// tools, but neither works in the state the other covers: cancelGrantNonce
/// only matters before registration, revokeGrant only after. A third party
/// who registers the grant ahead of the owner's cancellation activates it.
/// The owner must be able to kill a signed grant it knows the digest of in
/// one transaction, whatever a front-runner does.
contract GrantPreRevocationTest is Test {
    uint256 constant OWNER = 0xB0B;
    uint256 constant CLAUS = 0xA11CE;
    uint256 constant PUBLISHER = 0xBEEF;
    bytes32 constant MANDATE = keccak256("signal-test");
    bytes32 constant POLICY = keccak256("readonly-test");
    ClausApprovalExecutor executor;
    ClausReadonlyProject target;
    ClausSignalStore sink;
    ClausPulseModule module;
    bytes32 projectId;

    function sign(uint256 key, bytes32 hash) internal pure returns (bytes memory) {
        (uint8 v, bytes32 r, bytes32 s) = vm.sign(key, hash); return abi.encodePacked(r, s, v);
    }
    function setUp() public {
        vm.warp(1800000000); vm.chainId(11155111);
        executor = new ClausApprovalExecutor(vm.addr(CLAUS), vm.addr(OWNER), 1 days);
        module = new ClausPulseModule();
        bytes32 salt = keccak256("pulse"); projectId = executor.projectKey(vm.addr(OWNER), salt);
        bytes memory config = abi.encode(uint256(1 gwei), uint256(3600));
        ClausApprovalExecutor.Registration memory r = ClausApprovalExecutor.Registration(projectId, vm.addr(OWNER), salt,
            address(module), address(module).codehash, keccak256(config), MANDATE, POLICY, 0, 0, uint64(block.timestamp + 1 days));
        target = ClausReadonlyProject(executor.registerProject(r, config, sign(OWNER, executor.registrationDigest(r))));
        sink = new ClausSignalStore(address(executor), projectId, address(target), module.FEED(), module.SCHEMA());
    }
    function pulse(uint64 observed) internal view returns (bytes memory) {
        return abi.encode(block.chainid, uint64(123), keccak256("source-block"), observed, uint256(2 gwei));
    }

    /// Owner signed a grant, then decides to withdraw it before anyone registered it.
    /// Expected: one owner transaction naming the grant leaves it unusable even if a
    /// third party registers the signed terms afterwards (or in the same block, ahead of it).
    /// Actual: revokeGrant reverts with InvalidGrant for an unregistered digest, the
    /// third party registers the grant, and the publisher writes a record.
    function testOwnerCanWithdrawSignedGrantBeforeRegistration() public {
        uint64 now64 = uint64(block.timestamp);
        ClausSignalStore.Grant memory g = ClausSignalStore.Grant(vm.addr(PUBLISHER), 1, 10, 1, now64 + 1 days, 77);
        bytes memory sig = sign(OWNER, sink.grantDigest(g));
        bytes32 id = sink.grantDigest(g);

        // Owner withdraws the grant by its digest, the only identifier it has for a signed grant.
        vm.prank(vm.addr(OWNER));
        sink.revokeGrant(id);

        // Anyone may still submit the signed terms afterwards.
        vm.prank(address(0xDEAD));
        sink.registerGrant(g, sig);

        // The withdrawn grant must not be usable.
        assertTrue(sink.grant(id).revoked, "withdrawn grant must stay revoked");
        vm.prank(vm.addr(PUBLISHER));
        vm.expectRevert(ClausSignalStore.GrantInactive.selector);
        sink.publish(id, 1, 1, now64, now64 + 60, keccak256("s"), pulse(now64));
        assertEq(sink.latest().sequence, 0, "no record may be published under a withdrawn grant");
    }
}
```

### 3. Low: ClausPulseModule accepts an attested block number above the chain head

`contracts/src/ClausPulseModule.sol:23`

```
        if (chainId != context.chainId || number == 0 || hash == bytes32(0) || timestamp != context.observedAt ||
```

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.

**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.

**Proof**: a Foundry test that fails on this code and passes once it is fixed.

```solidity
// SPDX-License-Identifier: MIT
pragma solidity 0.8.27;
import {Test} from "forge-std/Test.sol";
import {ClausApprovalExecutor} from "src/ClausApprovalExecutor.sol";
import {ClausReadonlyProject} from "src/ClausReadonlyProject.sol";
import {ClausSignalStore} from "src/ClausSignalStore.sol";
import {ClausPulseModule} from "src/ClausPulseModule.sol";

contract ProofPulseFutureBlockTest is Test {
    uint256 constant OWNER = 0xB0B;
    uint256 constant CLAUS = 0xA11CE;
    uint256 constant PUBLISHER = 0xBEEF;
    bytes32 constant MANDATE = keccak256("signal-test");
    bytes32 constant POLICY = keccak256("readonly-test");
    ClausApprovalExecutor executor;
    ClausReadonlyProject target;
    ClausSignalStore sink;
    ClausPulseModule module;
    bytes32 projectId;
    bytes32 grantId;

    function sign(uint256 key, bytes32 hash) internal pure returns (bytes memory) {
        (uint8 v, bytes32 r, bytes32 s) = vm.sign(key, hash); return abi.encodePacked(r, s, v);
    }
    function setUp() public {
        vm.warp(1800000000); vm.roll(1000); vm.chainId(11155111);
        executor = new ClausApprovalExecutor(vm.addr(CLAUS), vm.addr(OWNER), 1 days);
        module = new ClausPulseModule();
        bytes32 salt = keccak256("pulse"); projectId = executor.projectKey(vm.addr(OWNER), salt);
        bytes memory config = abi.encode(uint256(1 gwei), uint256(3600));
        ClausApprovalExecutor.Registration memory r = ClausApprovalExecutor.Registration(projectId, vm.addr(OWNER), salt,
            address(module), address(module).codehash, keccak256(config), MANDATE, POLICY, 0, 0, uint64(block.timestamp + 1 days));
        target = ClausReadonlyProject(executor.registerProject(r, config, sign(OWNER, executor.registrationDigest(r))));
        sink = new ClausSignalStore(address(executor), projectId, address(target), module.FEED(), module.SCHEMA());
        ClausSignalStore.Grant memory g = ClausSignalStore.Grant(vm.addr(PUBLISHER), 1, 10, 1, uint64(block.timestamp + 1 days), 0);
        grantId = sink.registerGrant(g, sign(OWNER, sink.grantDigest(g)));
    }

    /// Directly on the module: an observation of block 1,001,000 while the chain is at block 1,000.
    /// Expected: InvalidObservation (the block cannot exist). Actual: accepted and classified.
    function testModuleRejectsBlockNumberAboveChainHead() public {
        bytes memory observation = abi.encode(block.chainid, uint64(block.number + 1_000_000), keccak256("x"), uint64(block.timestamp), uint256(2 gwei));
        bytes memory input = abi.encode(ClausSignalTypesInput(block.chainid, address(sink), projectId, module.FEED(), module.SCHEMA(),
            vm.addr(PUBLISHER), 1, uint64(block.timestamp), uint64(block.timestamp + 300), keccak256("src"), observation));
        vm.expectRevert(ClausPulseModule.InvalidObservation.selector);
        module.evaluate(abi.encode(uint256(1 gwei), uint256(3600)), input);
    }

    /// Through the store: the same observation becomes the published record.
    function testStoreDoesNotPublishBlockNumberAboveChainHead() public {
        bytes memory observation = abi.encode(block.chainid, uint64(block.number + 1_000_000), keccak256("x"), uint64(block.timestamp), uint256(2 gwei));
        vm.prank(vm.addr(PUBLISHER));
        vm.expectRevert(ClausReadonlyProject.ModuleCallFailed.selector);
        sink.publish(grantId, 1, 1, uint64(block.timestamp), uint64(block.timestamp + 300), keccak256("src"), observation);
    }

    // Mirror of ClausSignalTypes.Input, declared locally so the test only depends on the contracts under review.
    struct ClausSignalTypesInput {
        uint256 chainId; address store; bytes32 projectId; bytes32 feedId; bytes32 schemaHash; address publisher;
        uint64 sequence; uint64 observedAt; uint64 validUntil; bytes32 sourceHash; bytes data;
    }
}
```

### 4. Low: Revoking a grant, bumping the authority generation or upgrading the evaluator stops future writes but leaves the superseded record served unchanged by latest()

`contracts/src/ClausSignalStore.sol:125`

```
    function latest() external view returns (Record memory) { return record; }
```

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.

**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.

### 5. Low: Factory permanently binds a project to its first store with unvalidated feedId/schemaHash; a mistaken create() cannot be corrected

`contracts/src/ClausSignalFactory.sol:25`

```
        if (stores[projectId] != address(0)) revert StoreAlreadyCreated();
```

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.

**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.

### 6. Info: Anyone can deploy a ClausSignalStore for any project outside the factory; canonicality exists only in factory.stores()

`contracts/src/ClausSignalStore.sol:71`

```
    constructor(address approvalExecutor, bytes32 id, address target, bytes32 feed, bytes32 schema)
```

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.

**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.

### 7. Info: registerGrant returns success for an already registered id without validating the supplied signature; malformed signatures surface OpenZeppelin ECDSA errors instead of InvalidSignature

`contracts/src/ClausSignalStore.sol:97`

```
        if (grants[id].terms.publisher != address(0)) return id;
```

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.

**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.

### 8. Info: Concurrent owner-approved grants contend on the single store-wide sequence and observedAt; the second publisher's write in a block reverts

`contracts/src/ClausSignalStore.sol:137`

```
        if (sequence != record.sequence + 1 || observedAt == 0 || observedAt > block.timestamp ||
```

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.

**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.

### 9. Info: 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

`contracts/src/ClausPulseModule.sol:24`

```
            timestamp > block.timestamp || block.timestamp - timestamp > maximumAge ||
```

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.

**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.

### 10. Info: 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

`contracts/src/ClausPulseModule.sol:18`

```
        if (threshold == 0 || maximumAge == 0 || maximumAge > 1 days) revert InvalidConfiguration();
```

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.

**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.

### 11. Info: 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

`contracts/README.md:15`

```
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.
```

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.

**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.

### 12. Info: 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

`contracts/test/ClausSignalStore.t.sol:86`

```
        vm.warp(block.timestamp+60);vm.expectRevert(ClausSignalStore.GrantInactive.selector);publish(3,1);
```

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.

**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.

---

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