# Audit report

> Review only the final runtime availability/recovery correction at this exact commit against previous candidate 8b03d350c768cd4ef6f36107aca22e1e09729935. Solidity, permissions model, source validation, Mainnet intake and reviewed kernel are unchanged. Prior independent reviews: kernel 4bfe5f40-24e0-4429-b930-397b3927ff5e, runtime 5184c276-7378-44a6-9d86-a25e7916ccf7, delta d3229638-a2a2-4dd7-97f0-8e2552c31e96. Do not repeat an unrelated full audit or tests.
> Changed runtime scope: src/signals/index.mjs discovery isolates chain dependency failures (without exposing RPC details) while writes still fail closed; grant registration rejects API/onchain revoked grants; pending hosted record becomes terminal only when its isolated transaction engine has exact finalized replacement evidence matching original hash, chain, sender, target and calldata, preserving that hash. No user-supplied replacement proof is accepted. adapters/signal-read.mjs checks grant maximum against latest chain time and requires 60s inclusion margin on grant/record validity. reference/pulse-pipeline.mjs reads finalized grant once per fresh run, clamps service and record expiry, rejects near-stale publication with a terminal code. reference/pulse-scheduler.mjs retains only unresolved signed transaction rows, not terminal rows. Test the security/liveness boundaries of these changes with targeted cases, including ordinary RPC failure, mismatched replacement proof, revoked registration, expiry, restart and unknown broadcast preservation.
> 17 targeted runtime cases and the real Anvil lifecycle with time boundary assertions pass. Original Solidity is unchanged. One consumer profile is selected at first successful preparation and is deliberately permanent, documented; no profile-switch/custody admission is added. Budget/grant rotation during unknown transactions is unsupported: reconcile original config first. External wallets handle their own replacement proof; server trust applies only to its internal isolated transaction engine. No fabricated Claus approval or new financial grant.

| | |
|---|---|
| Repository | https://github.com/contractclaus/claus-network |
| Commit | `7b62bd05fd46eaeee891ed9052e26157652410d7` |
| Job | `a087d4c8-25f5-4bea-a002-477819e1b047` |
| Judged | 2026-10-13 02:03 UTC |
| Findings | 1 high · 1 medium · 3 low |

Four agents audited the code as it is at `7b62bd0`, 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. High: grantRegister reads the grant through chain.grant() before it exists onchain, so every first-time grant registration is rejected with signal_grant_not_found

`src/signals/index.mjs:195`

```
      requireThat(!(await chain.grant(await signal(projectId),row.value.grantId)).revoked,'signal_grant_inactive');
```

Added in 7b62bd0 (absent at 8b03d35). The onchain-revocation guard calls chain.grant() for the grant that is about to be registered. In production chain is createSignalRouter -> createSignalRead, whose grant() helper (adapters/signal-read.mjs:69-74, public method :89-94) does `requireThat(value?.terms && (!zero(value.terms.publisher) || value.revoked), 'signal_grant_not_found')` at the finalized block. ClausSignalStore.grant(bytes32) returns grants[id] verbatim (contracts/src/ClausSignalStore.sol:125), and a grant that has not been registered is the zero struct: publisher address(0), revoked false. The guard therefore throws before chain.registerGrant is reached for exactly the documented case (docs/signals.md step 3, docs/pulse-walkthrough.md:156: signal_grant_prepare -> owner signs -> signal_grant_register -> publisher sends). The only ids that pass are grants already registered and finalized (a no-op repeat) or already revoked onchain (then rejected as signal_grant_inactive). Through the HTTP layer the code maps to 404 (interfaces/http.mjs). Net effect: no new publisher grant can be registered through the hosted API on Mainnet or Sepolia since this commit, which blocks every record publication by a new publisher (prepareRecord requires an active grant). This is a liveness regression introduced by this delta, not a permission bypass; writes still fail closed. The unit test added in this commit masks it because the fixture mock chain.grant() unconditionally returns {revoked:false}, and the Anvil lifecycle test calls signalChain.registerGrant directly, never signals.grantRegister. Minimal fix preserving the intended check: treat signal_grant_not_found as "not registered, not revoked", e.g. `const g=await chain.grant(s,row.value.grantId).catch(e=>{if(e.code!=='signal_grant_not_found')throw e;return {revoked:false};}); requireThat(!g.revoked,'signal_grant_inactive');` (a pre-revoked slot still returns revoked:true from the adapter because the contract keeps revoked=true with a zero publisher), or expose a raw grant-state read in the adapter for this pre-check. Merged from four specialist reports (economics, permissions, flow, math) of the same defect.

**Reproduction**

State: real Anvil (chain id 1) with real ClausApprovalExecutor, ClausPulseModule, ClausSignalFactory and ClausSignalStore from `forge build`; real adapters/signal-read.mjs behind the real createSignalRouter; real src/signals/index.mjs; only artifact-evidence validation stubbed. Steps: signal_store_prepare -> owner sends -> anvil_mine 96 -> signal_submit => confirmed; signal_grant_prepare {publisher, maximumWrites:3, minimumInterval:1, expiresAt: head+86400, nonce:"14"} => awaiting_signature; owner signs grant.typedData; onchain grant(grantId) is {publisher:0x0, revoked:false}. Input: grantRegister("owner","p",{operationId:grant.id, signature}). Expected (docs/signals.md step 3): state "prepared" with the publisher's registerGrant transaction intent. Actual: rejects with code signal_grant_not_found thrown from adapters/signal-read.mjs:72 via src/signals/index.mjs:195; the operation stays awaiting_signature. Control in the same test: signalChain.registerGrant(signalRow,binding,grant,signature) resolves with transaction.from === publisher and sending it registers the grant onchain, proving line 195 is the sole blocker. Run from the repository root after `cd contracts && forge build && cd .. && npm ci`. Each test asserts the CURRENT (defective) behaviour, so it passes on this tree and must FAIL once the defect is fixed. Command: `node --test test/scratch/grant-register-anvil.test.mjs` (spawns anvil on 127.0.0.1:8599; the grant-registration assertions are the first half of the test, the expiry cases below reuse the fixture). Test source:

import test from 'node:test';
import assert from 'node:assert/strict';
import {readFile} from 'node:fs/promises';
import {spawn} from 'node:child_process';
import {createPublicClient,createWalletClient,http,keccak256,encodeAbiParameters,stringToHex} from 'viem';
import {generatePrivateKey,privateKeyToAccount} from 'viem/accounts';
import {createSignalRead,createSignalRouter} from '../../adapters/signal-read.mjs';
import {createChainRead} from '../../adapters/chain-read.mjs';
import {createSignals} from '../../src/signals/index.mjs';
import {openStore} from '../../src/storage/index.mjs';
import {digest,requireThat} from '../../src/shared/index.mjs';
import {errorStatus,errorBody} from '../../interfaces/http.mjs';

const PORT=8599;
async function startAnvil(){
 const child=spawn('anvil',['--chain-id','1','--port',String(PORT),'--silent'],{stdio:'ignore'});
 const client=createPublicClient({transport:http(`http://127.0.0.1:${PORT}`,{retryCount:0}),cacheTime:0,pollingInterval:50});
 for(let i=0;i<100;i++){try{await client.getChainId();return {child,client};}catch{await new Promise(r=>setTimeout(r,100));}}
 throw new Error('anvil did not start');
}

test('grantRegister through the API rejects every fresh grant with signal_grant_not_found; adapter alone succeeds',{timeout:180000},async t=>{
 const {child,client}=await startAnvil();t.after(()=>child.kill());
 const url=`http://127.0.0.1:${PORT}`;
 const [owner,claus,publisher]=Array.from({length:3},()=>privateKeyToAccount(generatePrivateKey()));
 for(const a of [owner,publisher])await client.request({method:'anvil_setBalance',params:[a.address,'0x56bc75e2d63100000']});
 const wallet=a=>createWalletClient({account:a,transport:http(url)});
 const wait=async hash=>{const r=await client.waitForTransactionReceipt({hash});assert.equal(r.status,'success');return r;};
 const send=async(tx,signer)=>{assert.equal(tx.from,signer.address.toLowerCase());return wait(await wallet(signer).sendTransaction({chain:null,to:tx.to,data:tx.data,value:0n}));};
 const final=()=>client.request({method:'anvil_mine',params:['0x60','0x1']});
 const out=async name=>JSON.parse(await readFile(new URL(`../../contracts/out/${name}.sol/${name}.json`,import.meta.url)));
 const [native,factoryOut,pulse]=await Promise.all(['ClausApprovalExecutor','ClausSignalFactory','ClausPulseModule'].map(out));
 const deploy=async(abi,bytecode,args)=>(await wait(await wallet(owner).deployContract({chain:n

### 2. Medium: Both documented ends of the grant expiresAt range are rejected by the new chain-clock bound, and signal_grant_expiry has no HTTP mapping so the client gets a retryable 503 service_unavailable

`adapters/signal-read.mjs:135`

```
      requireThat(terms.expiresAt>Number(head.timestamp)+60&&terms.expiresAt<=Number(head.timestamp)+30*86400,'signal_grant_expiry');
```

grantPrepare (src/signals/index.mjs:182) validates expiresAt against the SERVER clock with an inclusive range [floor(now/1000)+60, floor(now/1000)+30*86400], which is what docs/signals.md ("30 days") and the signal_grant_prepare openapi description advertise. The adapter added in this commit re-checks the same value against the LATEST block timestamp with a different comparator: exclusive `> head+60` and `<= head+30d`. (a) Between blocks head.timestamp normally lags the server clock by 0-12 s, so the documented maximum now+30d exceeds head+30d and is rejected. (b) Whenever head.timestamp >= the server second (a block mined this second, or a few seconds of skew, both routine) the documented minimum now+60 fails `> head+60`. The rejection code signal_grant_expiry is new in this commit and absent from the errors map in interfaces/http.mjs:2-39; errorStatus() falls back to 503 because the code does not match the 400 regex, and errorBody() replaces it with {error:"service_unavailable",category:"availability",retryable:true}. A client following the documented range thus receives a deterministic validation failure labelled as a transient outage and will retry forever; operators see false dependency outages, the very thing this commit's discovery change set out to isolate. Fix: map signal_grant_expiry to 400/409 in interfaces/http.mjs, and make the two bounds consistent (validate grantPrepare against the same head timestamp the adapter uses, or document/derive the API range as (head+60, head+30d]).

**Reproduction**

Same Anvil fixture as the first finding, API wired to the real adapter, with the API server clock set relative to the chain head. Case 1: server clock = head+3 s (head lags, the normal case); signal_grant_prepare {publisher, maximumWrites:3, minimumInterval:1, expiresAt: floor(serverNow/1000)+30*86400, nonce:"21"}; this passes the API range check at src/signals/index.mjs:182. Expected: awaiting_signature operation. Actual: rejects with code signal_grant_expiry; errorStatus(error)===503 and errorBody(error) deep-equals {error:"service_unavailable",category:"availability",retryable:true}. Concrete numbers: head=H, serverNow=H+3, expiresAt=H+3+2592000 > adapter bound H+2592000. Case 2: server clock = head (block mined this second); expiresAt: floor(serverNow/1000)+60 (documented minimum). Expected: accepted. Actual: signal_grant_expiry (H+60 is not > H+60). The pure HTTP-mapping part is also shown standalone by the first test in test/scratch/grant-register-margin.test.mjs. Run from the repository root after `cd contracts && forge build && cd .. && npm ci`. Each test asserts the CURRENT (defective) behaviour, so it passes on this tree and must FAIL once the defect is fixed. Command: `node --test test/scratch/grant-register-anvil.test.mjs` (cases 1 and 2 are the "Expiry boundary cases" section; source is in the first finding) and `node --test test/scratch/grant-register-margin.test.mjs`. Source of the latter:

import test from 'node:test';
import assert from 'node:assert/strict';
import {openStore} from '../../src/storage/index.mjs';
import {createSignals} from '../../src/signals/index.mjs';
import {digest,requireThat} from '../../src/shared/index.mjs';
import {errorStatus,errorBody} from '../../interfaces/http.mjs';
const address=c=>'0x'+c.repeat(40),word=c=>'0x'+c.repeat(64);

test('signal_grant_expiry has no HTTP mapping and is returned as a retryable 503',()=>{
 const error=Object.assign(Error('signal_grant_expiry'),{code:'signal_grant_expiry'});
 assert.equal(errorStatus(error),503);
 assert.deepEqual(errorBody(error),{error:'service_unavailable',category:'availability',retryable:true});
});

test('grantRegister hands out a registration intent for a grant with 1 second of life left (no 60s margin)',async t=>{
 const store=openStore(':memory:');t.after(()=>store.close());
 const f={now:1800000000000};
 const binding={chainId:1,profile:'claus-readonly-v1',owner:address('1'),executor:address('2'),target:address('3'),projectKey:word('a')};
 const config={enabled:true,profile:'claus-signal-v1',chainId:1,factory:address('4'),factoryCodeHash:word('b'),executor:binding.executor,
  artifactProjectId:'review-project',factoryArtifactHash:digest('factory'),storeArtifactHash:digest('store'),reviewCaseId:'review',
  reviewArtifactHash:digest('report'),feedId:word('c'),schemaHash:word('d'),expiresAt:f.now+86400000};
 const signal={chainId:1,address:address('5'),codeHash:word('e'),factory:config.factory,factoryCodeHash:config.factoryCodeHash,
  evaluator:binding.target,executor:binding.executor,projectKey:binding.projectKey,feedId:config.feedId,schemaHash:config.schemaHash};
 const transaction={chainId:1,from:address('6'),to:address('5'),data:'0x1234',value:'0'};
 const chain={validateAdmission:async()=>{},validateStoreBuild:async()=>{},assertTrackedStore:async()=>{},
  prepareStore:async()=>({recovered:true,signal,blockHash:word('f'),blockNumber:'50',finalized:true}),
  prepareGrant:async(_s,_b,terms)=>({grantId:word('1'),typedData:{message:{publisher:address('6'),expiresAt:terms.expiresAt}},owner:binding.owner}),
  registerGrant:async()=>({transaction}), // the real adapter also never consults chain time here
  grant:async()=>({id:word('1'),terms:{publisher:address('0')},revoked:false})};
 await store.transaction(async tx=>{
  await tx.write('chain-bindings','p',binding,0);await tx.write('network-capabilities','signal:1',config,0);
  await tx.write('review-cases','review',{state:'reviewed',projectId:config.artifactProjectId,artifactHash:con

### 3. Low: Scheduler finishes a run as failed and releases the lane once the engine row is confirmed/reverted even though the hash was never attached to the API operation (signal_submit kept failing)

`reference/pulse-scheduler.mjs:49`

```
   const unresolved=signed&&!['confirmed','reverted','cancelled'].includes(signed.value.state);
```

Before 7b62bd0 any chain-transactions row for the run's operation kept the run non-terminal (`terminal=!signed&&(...)`), so a hosted write whose signal_submit call to the network API failed kept retrying at 60 s until the hash was journaled. The new rule treats confirmed/reverted/cancelled engine rows as resolved and makes the run terminal on `run.expiresAt<=now()` or a listed code. But the engine hash reaches the API operation only through call("signal_submit") in reference/pulse-pipeline.mjs:45, which runs after publisher.step and throws an uncoded error on transport/5xx failure. The engine reports confirmed only after finality (~13 min on mainnet) while run.expiresAt can be as little as seconds away when the write is prepared (the pipeline checks run.expiresAt>now() just before signal_record_prepare), so a late write is routinely confirmed after run.expiresAt. If every signal_submit attempt from broadcast through that point failed (API origin or its RPC unavailable), one more failure after run.expiresAt makes `terminal=true`: the job finishes "failed" with error "reference_unavailable" and no transactionHash, the lane advances, and nothing ever retries signal_submit for that run. The signal-operations row stays "prepared" without a hash until it expires, no signal.record_confirmed event is emitted, and worker-health reports a failed run for a write that executed onchain; a reverted row is likewise reported as reference_unavailable instead of signal_transaction_reverted. Sequence continuity is unaffected because later runs read signal_latest from chain, and the hash remains in chain-transactions for manual recovery, hence low. Fix: count a confirmed/reverted engine row as resolved only once the API operation carries its hash (read signal-operations for run.write.operation.id and require transactionHash===signed.value.hash, or have the pipeline persist run.transactionHash from the engine before calling signal_submit and keep retrying while it is unattached). Merged from the permissions (low) and flow (medium) reports.

**Reproduction**

State: in-memory store, createJobs, createPulseScheduler with {startsAt:T, expiresAt:T+14400000, intervalMs:3600000, maximumRuns:4}. Pipeline stub: sets run.write={operation:{id:"op-"+run.id}}, checkpoints, writes chain-transactions "signal-record:op-<run>" = {state:"confirmed", hash:0xab..ab, sender, intent} once, then throws new TypeError("fetch failed") (a failed signal_submit HTTP call: no error.code). Tick 1 at T => {state:"unavailable"} (retry). Advance now() to T+3600001 (run.expiresAt passed) and tick again. Expected (behaviour at 8b03d35 and per docs/signals.md "remains independently reconcilable"): still "unavailable", retried until the hash is journaled. Actual at 7b62bd0: {state:"failed", runId: same}, workflow-jobs row state "failed", result.error "reference_unavailable", result.run.transactionHash undefined; the next tick starts a new runId; signal_submit never succeeded for the confirmed hash. Run from the repository root after `cd contracts && forge build && cd .. && npm ci`. Each test asserts the CURRENT (defective) behaviour, so it passes on this tree and must FAIL once the defect is fixed. Command: `node --test test/scratch/scheduler-confirmed-orphan.test.mjs`. Source:

import test from 'node:test';
import assert from 'node:assert/strict';
import {openStore} from '../../src/storage/index.mjs';
import {createJobs} from '../../src/jobs/index.mjs';
import {createPulseScheduler} from '../../reference/pulse-scheduler.mjs';

test('confirmed engine row + failing signal_submit past run expiry => run finished failed, hash never journaled',async t=>{
 const store=openStore(':memory:');t.after(()=>store.close());let time=1800000000000,submits=0;
 const jobs=createJobs({store,identity:{authorize:async()=>{}},now:()=>time});
 const config={projectId:'lane',startsAt:time,expiresAt:time+14400000,intervalMs:3600000,maximumRuns:4};
 const pipeline={async step(run,checkpoint){
  run.write={operation:{id:'op-'+run.id}};await checkpoint(run);
  const key='signal-record:'+run.write.operation.id;
  if(!await store.read('chain-transactions',key))await store.transaction(tx=>tx.write('chain-transactions',key,
   {state:'confirmed',hash:'0x'+'ab'.repeat(32),sender:'0x'+'11'.repeat(20),intent:{chainId:1,to:'0x'+'22'.repeat(20),data:'0x01',value:'0'}},0));
  submits++;throw new TypeError('fetch failed'); // what a failed signal_submit HTTP call produces: no error.code
 }};
 const tick=()=>createPulseScheduler({store,jobs,pipeline,config,now:()=>time}).tick();
 const first=await tick();assert.equal(first.state,'unavailable');
 time+=3600001; // run.expiresAt passed
 const second=await tick();
 assert.equal(second.runId,first.runId);
 // Expected (pre-7b62bd0 and per docs): still 'unavailable', retried until the hash is journaled.
 // Actual at 7b62bd0:
 assert.equal(second.state,'failed');
 const job=(await store.list('workflow-jobs')).find(r=>r.value.result?.run?.id===first.runId);
 assert.equal(job.value.state,'failed');assert.equal(job.value.result.error,'reference_unavailable');
 assert.equal(job.value.result.run.transactionHash,undefined);
 const third=await tick();assert.notEqual(third.runId,first.runId);
 assert.equal(submits,3);
});

### 4. Low: discovery reports permanent admission faults (signal_factory_changed, wrong_chain, signal_evidence_invalid, missing artifact, no chain client) as the transient signal_dependency_unavailable

`src/signals/index.mjs:165`

```
        return {chainId,profile:'claus-signal-v1',admission:'unavailable',reason:'signal_dependency_unavailable'};
```

The catch-all added in 7b62bd0 special-cases three not_enabled codes and maps every other error to admission "unavailable" with a fixed reason. admission() can also fail with deterministic, operator-actionable, non-sensitive codes that no retry resolves: signal_factory_changed (adapters/signal-read.mjs:42-44: chainId mismatch, factory code hash changed, factory.executor() != config.executor), wrong_chain (:35), signal_evidence_invalid (adapters/signal-evidence.mjs: reviewed factory artifact does not reproduce the deployed code), artifact not_found/corrupt from artifacts.get, and signal_chain_unavailable (router has no client for the chain). All now look identical to an RPC blip in the public capabilities payload (src/access/index.mjs signalProfiles) and to worker-health consumers, so a misconfigured or tampered profile is advertised as "come back later" instead of being surfaced; the comment's claim that only "a chain dependency outage closes this profile" is not what the code does. Writes still fail closed with the precise code on the write paths, so this is a diagnostic gap, not a bypass. Fix: keep the generic reason for errors without a code (transport/RPC) and for signal_chain_unavailable/wrong_chain, and pass through the project's own coded admission failures (e.g. admission:"not_enabled", reason:error.code), which carry no RPC detail. Merged from the permissions (info) and flow (low) reports.

**Reproduction**

State: network-capabilities signal:1 enabled, review-cases reviewed, signal-admission-assessments accepted (the unit fixture rows), and chain.validateAdmission that throws requireThat(false, code) for code in [signal_factory_changed, wrong_chain, signal_evidence_invalid, signal_chain_unavailable], or artifacts.get that throws artifact_not_found. Input: signals.discovery(1). Expected: a distinguishable definitive state such as {admission:"not_enabled", reason:"signal_factory_changed"}. Actual for all five: {chainId:1, profile:"claus-signal-v1", admission:"unavailable", reason:"signal_dependency_unavailable"}. Run from the repository root after `cd contracts && forge build && cd .. && npm ci`. Each test asserts the CURRENT (defective) behaviour, so it passes on this tree and must FAIL once the defect is fixed. Command: `node --test test/scratch/discovery-misconfig.test.mjs`. Source:

import test from 'node:test';
import assert from 'node:assert/strict';
import {openStore} from '../../src/storage/index.mjs';
import {createSignals} from '../../src/signals/index.mjs';
import {digest,requireThat} from '../../src/shared/index.mjs';
const address=c=>'0x'+c.repeat(40),word=c=>'0x'+c.repeat(64);

async function make(t,code,artifactCode){
 const store=openStore(':memory:');t.after(()=>store.close());const now=1800000000000;
 const config={enabled:true,profile:'claus-signal-v1',chainId:1,factory:address('4'),factoryCodeHash:word('b'),executor:address('2'),
  artifactProjectId:'review-project',factoryArtifactHash:digest('factory'),storeArtifactHash:digest('store'),reviewCaseId:'review',
  reviewArtifactHash:digest('report'),feedId:word('c'),schemaHash:word('d'),expiresAt:now+86400000};
 await store.transaction(async tx=>{
  await tx.write('network-capabilities','signal:1',config,0);
  await tx.write('review-cases','review',{state:'reviewed',projectId:config.artifactProjectId,artifactHash:config.reviewArtifactHash},0);
  await tx.write('signal-admission-assessments',digest(config),{accepted:true,profileHash:digest(config),reviewArtifactHash:config.reviewArtifactHash,
   factoryArtifactHash:config.factoryArtifactHash,storeArtifactHash:config.storeArtifactHash},0);
 });
 return createSignals({store,identity:{authorize:async()=>{}},chain:{validateAdmission:async()=>requireThat(false,code)},
  artifacts:{get:async()=>{if(artifactCode)requireThat(false,artifactCode);return {};}},publicResult:async()=>({}),now:()=>now});
}
for(const code of ['signal_factory_changed','wrong_chain','signal_evidence_invalid','signal_chain_unavailable']){
 test(`permanent admission failure ${code} is reported as a transient outage`,async t=>{
  const api=await make(t,code);
  assert.deepEqual(await api.discovery(1),{chainId:1,profile:'claus-signal-v1',admission:'unavailable',reason:'signal_dependency_unavailable'});
 });
}
test('missing factory artifact is reported as a transient outage',async t=>{
 const api=await make(t,'unused','artifact_not_found');
 assert.deepEqual(await api.discovery(1),{chainId:1,profile:'claus-signal-v1',admission:'unavailable',reason:'signal_dependency_unavailable'});
});

### 5. Low: grantRegister applies no inclusion margin and never consults chain time, so it prepares a registration intent for a grant that is seconds from (or already past) expiry and is guaranteed to revert

`src/signals/index.mjs:192`

```
      requireThat(row.value.expiresAt > now() && row.value.input.expiresAt * 1000 > now(), 'signal_operation_expired');
```

This commit adds a 60 s chain-clock margin at signal_grant_prepare (adapters/signal-read.mjs:135) and at record preparation (:158), and the delta description says it "requires 60s inclusion margin on grant/record validity". The step that actually emits the transaction the publisher broadcasts, grantRegister -> chain.registerGrant (adapters/signal-read.mjs:143-150), checks only owner, authorityGeneration and the signature; the API-side guard on this line compares the grant expiresAt against the server clock with zero margin. The grant operation row lives 24 h (src/signals/index.mjs:103) while a grant may have been prepared with 61 s of life, so an intent can be handed out with one second left, or already expired on chain whenever the server clock is behind block time. ClausSignalStore.registerGrant reverts with InvalidGrant when g.expiresAt <= block.timestamp (contracts/src/ClausSignalStore.sol:107-110), so the intent can only burn the publisher's gas and then surfaces as signal_transaction_reverted. Fix: in grantRegister (or registerGrant) read the latest block and require terms.expiresAt > head.timestamp+60, returning signal_grant_expiry, mirroring the prepare-time check. (On this tree the API path is reached only after the first finding is fixed, because line 195 throws first; the chain-time part is shown through the adapter call grantRegister delegates to.)

**Reproduction**

API path (mock chain, unit fixture rows, chain.grant returning {revoked:false}): grantPrepare with expiresAt = floor(now/1000)+3600, then set now() = expiresAt*1000-1000 (one second of life left) and call grantRegister("owner","p",{operationId, signature}). Expected: rejection with signal_grant_expiry (margin) like prepare. Actual: returns state "prepared" with the registration transaction. Chain path (Anvil, case 3 of test/scratch/grant-register-anvil.test.mjs): terms.expiresAt = head+61; signalChain.prepareGrant succeeds; mine 70 blocks at 1 s so head >= expiresAt; signalChain.registerGrant(signal,binding,grant,ownerSignature) resolves with transaction.from === publisher instead of rejecting; sending that transaction from the publisher with gas 300000 yields a receipt with status "reverted". Run from the repository root after `cd contracts && forge build && cd .. && npm ci`. Each test asserts the CURRENT (defective) behaviour, so it passes on this tree and must FAIL once the defect is fixed. Commands: `node --test test/scratch/grant-register-margin.test.mjs` (second test; source in the second finding) and `node --test test/scratch/grant-register-anvil.test.mjs` (case 3; source in the first finding).

---

Judge's submission `d4ab5c6d919b576224ac57bea616c67683c4ef2be20c6cbd19c7be042b373cdb`, 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.
