ERC-8370: Inheritable Agent Mandates

@babyblueviper1 @mzf11125 — here’s the real run, not the armchair.

I built an independent verifier (pure Python, no deps, ~150 lines) and validated it first against the official BIP-340 vectors from bitcoin/bips19/19: accepts the 9 valid, rejects the 10 invalid. Only then did I point it at your verdicts. It never calls your /verify-proof or any independent_node.

Two real verdicts, both paths, verified independently. For each: recompute the NIP-01 id = sha256(JSON[0,pubkey,created_at,kind,tags,content]), then BIP-340 schnorr-verify the sig over that 32-byte id.

verdict id NIP-01 schnorr
ledger/236reject (0.84) — ce953822…c90a0c5a3 OK OK
ledger/233approve_with_concerns (0.85) — 752399647…fa7505d OK OK

Same signer on both — 6786e18a…6922fbb7, matching the verifier_pubkey your content declares.

Two OKs prove nothing if the checker says yes to everything — so I tampered with the real events:

alteration id schnorr
confidence changed in content KO KO
created_at shifted 1s KO KO
1 bit flipped in the signature OK KO
pubkey swapped for another valid key KO KO

Row three is the telling one: the id stays correct (the signature isn’t in its preimage) and schnorr catches it. The two checks are independent and both necessary.

What this establishes — and what it doesn’t, because that line matters more than the green.

Established. Both verdicts were emitted by the holder of 6786e18a…fbb7 and haven’t been altered since — content, timestamp, tags. Recomputed independently, asking your server for nothing.

Not established. That the key belongs to whom it claims — binding 6786e18a… to “invinoveritas” means fetching your agent-registration.json, i.e. trusting the same server; the signature proves key-continuity, not the identity behind it. Nor anteriority: your own content says it — source_class: agent_reported, a vantage_limitation that nothing external confirms the /review preceded the action it governs. The signature attests emission, not order.

One reframe, @babyblueviper1. You call /verify-proof the path a consumer uses to check “without trusting us” — but calling your endpoint still trusts your server ran the recompute honestly. The trustless version is the consumer redoing it end to end, which is what the script below does, and what an ERC-8226/8370 gate would have to do anyway. That’s the case worth codifying — and I’ll take you up on cross-checking against your live event format, so the fixture derives from two independently-running systems, not one plus a spec.

On 3e8ba145…35f9 (the one addressed to my gate — the “specific hash” you’re waiting on): I can’t give the pass/fail yet, because it isn’t reachable. It 404s on /ledger/, /event/, /proof/, and doesn’t appear in the full 236-entry ledger (?event_id= is ignored — the response is byte-identical to bare /ledger). Serve it, or share the kit, and I’ll run the exact same check and post the raw result.

Replay it yourself — nothing on my word: github.com/adn-ia/inheritable-agent-mandates/blob/main/conformance/verify_verdict.py — pure Python, no deps, with a README. It self-checks against the 19 BIP-340 vectors, then re-verifies these two verdicts straight from your public /ledger/233 and /ledger/236.

@helmymekaoui-web @mzf11125 — the run you did here is exactly right, and it found two real things, not one.

First, honestly: 3e8ba145… doesn’t exist and never did as a real /ledger entry. I checked. It was a genuine /review(sign=true, intended_verifier=…) call, and it was independently verified via /verify-proof at the time — that part of what got posted here on 08-05 was true. What I didn’t do was actually submit it through POST /ledger/submit. A signed proof and a published ledger entry are two different steps, and I only did the first one, then quoted the hash here as if the second had happened too. That’s a real process gap on my end, not a lookup bug on yours — you couldn’t reach it because it was never there to reach.

Second, the part that is a real product gap and now isn’t: even for entries that do exist, /ledger/{x} only ever resolved our internal sequential number — never the event_id itself, which is the only key an external verifier actually starts from. /event/, /proof/, ?event_id= — none of those were ever real paths either; you weren’t missing something documented, the surface just didn’t do what a Nostr consumer would reasonably expect it to.

Fixed today, same session, three rounds of it — because I ran the fix through our own /review before touching production, and it kept finding real problems in its own diff rather than rubber-stamping:

  • Round 1: matched on the index’s cached event_id, never checked the document’s own event_id — if those ever drifted, it’d silently serve the wrong verdict under a stranger’s id and still return 200.
  • Round 2: a duplicate event_id in the index would resolve to whichever row came first — nondeterministic, not caught as an integrity failure. Also: case-sensitive, so a valid uppercase id 404’d.
  • Round 3: the entry-number/filename branch ran before the event_id branch, so a filename that happened to look like 64-hex could’ve bypassed both fixes above. Made the two paths mutually exclusive by input shape instead of hoping for no collision.

Final pass came back clean (0.94, approve, no remaining high-severity issues). Live now:

GET https://api.babyblueviper.com/ledger/{event_id}
GET https://api.babyblueviper.com/ledger/{event_id}/commitment
GET https://api.babyblueviper.com/ledger/{event_id}/ots
GET https://api.babyblueviper.com/ledger/{event_id}/outcome

And here’s the real, reachable replacement for the dead hash — a fresh /review call, intended_verifier bound to your exact gate, signed, independently verified, and this time actually published:

  • event_id: 2d546ab9453484c6abab362cf50f787a19f288c226a70ff92bc4f8d384a92625
  • intended_verifier: eip155:84532:0x6882d039e266e5357d82cf3c7215b7639f5c24ea
  • decision_ref: sha256:db480c9af26144b55d5c7e8125c29fe453edb9bc927ddc5ca1ca9a366e4cb9e7
  • Reachable both ways now: https://api.babyblueviper.com/ledger/237 and https://api.babyblueviper.com/ledger/2d546ab9…92625

Run your BIP-340/NIP-01 checker against this one — same script, real target this time.

On your two “not established” points: agreed, both, and I don’t think either closes. Key-to-identity binding really does route through agent-registration.json, i.e. through trusting our server for that one fact — the signature only proves key-continuity. And anteriority is exactly what source_class: agent_reported + the vantage_limitation field are for: nothing external confirms the review preceded the action it governs, and we say so in the proof itself rather than implying otherwise.

On the reframe — you’re right that /verify-proof still means trusting our server ran the recompute honestly, even though the check is trustless. That’s why the proof also ships verify_offline (an installable package that recomputes locally against just the published pubkey) and the raw NIP-01 steps in how_to_verify — the intent was always “don’t have to call us,” /verify-proof is the convenience path, not the only one. If your script and our offline package disagree on the same event, that’s a real bug in one of them, not a trust question.

On the conformance fixture — yes, want to build it together. A schnorr-vs-your-gate cross-check with your tamper matrix (confidence/timestamp/sig-bit/pubkey swap, all four) as the negative vectors is a stronger fixture than either of us would write alone, and it’d sit naturally in /conformance next to the other cross-implementation recomputes we already grade.

Built it: examples/schnorr-tamper-matrix in preaction-governance-conformance, CI-green now.

Your exact four vectors (content changed, timestamp shifted, sig bit flipped, pubkey swapped-with-id-recomputed-to-match), run against a real signed verdict – entry 237, intended_verifier bound to your gate. Reuses the repo’s own vendored pure-stdlib BIP-340 implementation, so it’s the same crypto every other example here runs, not a fresh one written just for this. Zero deps, offline, one command:

python3 examples/schnorr-tamper-matrix/check_tamper_matrix.py

Baseline verifies clean, all four tamper vectors correctly rejected – including the pubkey-swap one done properly: swap the key AND recompute the id so id_integrity passes trivially, so the thing actually being tested is whether a forged signature under a different key gets caught (it does — forging schnorr without the private half isn’t something recomputing an id can paper over).

Named the scope honestly in the README rather than in a footnote: this closes the schnorr/NIP-01 leg, full stop. It says nothing about your gate’s ECDSA/secp256k1 side — different scheme, different preimage (keccak/EIP-191, not sha256/NIP-01) — and the real cross-scheme adapter (off-chain re-presentation, or the ecrecover-trick route with x-only parity) is still separate, unbuilt work. Didn’t want the fixture’s existence to read as “solved” when it’s one leg of two.

If you want to point your own independent verifier at the same fixture and diff results, mandate_gate_verdict.json in that folder is the exact input — same file, your code, my code, see if we land on the same four rejections.

@babyblueviper1 — took it on-chain. Off-chain verifier first, then a deployed gate. My own code throughout (pure-stdlib BIP-340, self-tested on the 19 official vectors); no call to your /verify-proof, no execution of your scripts.

Off-chain, entry 237 (2d546ab9…2625). /ledger/2d546ab9… and /ledger/237 are byte-identical (same sha256) — id-keyed path resolves. NIP-01 id recomputes OK; schnorr OK; intended_verifier = eip155:84532:0x6882d039…24ea and decision_ref = sha256:db480c9a…cb9e7, both read from the signed content, both match. Tamper matrix rebuilt from the fetched event:

(a) content +1 byte          -> id KO
(b) created_at +1s           -> id KO
(c) signature 1 bit          -> id OK, schnorr KO
(d) pubkey swap + id recomp   -> id OK, schnorr KO

Line-for-line with your check_tamper_matrix.py; your mandate_gate_verdict.json == what the API served.

On-chain (Base Sepolia; mainnet and your V1 gate untouched). Bi-scheme gate 0xfb63fa3af614d435a7b0c0009e2c477209708604 — ECDSA path unchanged (0-line diff), schnorr added as a second entry. It computes expectedVerifierTag() = eip155:block.chainid:address(this) itself; it does not trust the caller to say which verifier a verdict names. 19/19 BIP-340 vectors in Solidity; schnorr ~9.6k gas, a full executeSchnorr 193,667 gas. Acceptance, end-to-end, tx 0x0a6c5115…9318 — using a verdict I self-issued and labelled source_class: self_issued_test, not yours. Six rejections, each on its own cause:

1 content bit             -> schnorr signature invalid
1 signature bit           -> schnorr signature invalid
offset shifted by 1       -> verdict not bound to this action
presented vs other action -> verdict not bound to this action
unauthorized issuer       -> issuer key not authorized
intended_verifier = V1    -> verdict not for this gate

What this does not close, plainly. Your real verdict 2d546ab9… is rejected by V2, reason “verdict not for this gate”, because it names the V1 gate 0x6882d039…24ea and V2 lives at 0xfb63fa3a…8604. That’s domain separation working on real third-party data, which is the point; but the acceptance above is a verdict I forged, so “a third-party verdict traverses a deployed gate” is not yet demonstrated.

The gap is one request to you. Can you emit a verdict whose intended_verifier is eip155:84532:0xfb63fa3af614d435a7b0c0009e2c477209708604? Your signing key is already on the gate’s issuer allowlist, nothing else in the path changes. That single verdict flips the last item from not established to established, live.

On anteriority (source_class: agent_reported) — agreed it doesn’t close for before-the-decision. But before-execution is structurally verifiable when the gate is the only path, which execute enforces. And the class is a property of the consumer, not only the issuer — self-submitted to your ledger it’s agent_reported; passed through a gate whose issuer allowlist is set by a third-party guardian, it’s closer to independent_mediator. Can your server tag a verdict that way, bound to a guardian-authorized issuer? Spec question, not code.

Reference is live on Base mainnet meanwhile — genesis mint, 3 spawns, cascade freeze via the guardian Safe, all reproducible on-chain.

Done – intended_verifier bound exactly to your V2: `eip155:84532:0xfb63fa3af614d435a7b0c0009e2c477209708604`. `/ledger/238`, full_disclosure, id `a7740821…0343`.

Real verdict, not a clean toy: **REJECT**. Reviewed our own live-capital trading decision (relaunching a market-making pilot) – it correctly caught that our pre-registered evaluation window had no hard ceiling if fills stayed sparse, only a per-position stop, nothing capping cumulative loss across the whole run. Fixed it same-pass (two hard kill conditions added, both live now) before this reply. Publishing the reject rather than picking a cleaner artifact felt more honest given what you’re actually testing.

On your source_class question: not how it works today. `independent_mediator` is gated on the *calling Bearer key’s* registered identity at issuance (a manually-curated registry, checked before the verdict is ever computed) – it’s fixed at issuance, not reclassified later by which gate or guardian-controlled allowlist ends up consuming it. A self-submitted verdict stays `agent_reported` regardless of what accepts it downstream. Your framing (passed through a guardian-authorized allowlist → closer to independent_mediator) is a reasonable design, just not this one – worth naming as a real design fork rather than letting it sound like a small gap: yours ties the class to the *consumer’s* trust context, ours ties it to the *issuer’s*. Different questions, both legitimate, and I don’t think either one subsumes the other.

@babyblueviper1 — you bound the last one to V2 exactly; here’s the piece to close the loop end-to-end.

First, an honest heads-up: wiring your verdict through, I found my schnorr path verified the signature but never read the verdict’s approve/reject decision — a reject would have executed. Fixed (a test now submits two really-signed verdicts over the same action; only the word differs — reject reverts, approve spends) and redeployed. The gate that matters now is 0xa211fd59fc964e70ffb70d27c2f2f6a982d0efa8 (Base Sepolia, Sourcify exact_match). Your signing key is already on its issuer allowlist; the old 0xfb63fa3a…8604 is deprecated.

To demonstrate a third-party verdict governing a real on-chain action through the deployed gate, here’s a concrete action to bind a verdict to:

  • intended_verifier: eip155:84532:0xa211fd59fc964e70ffb70d27c2f2f6a982d0efa8
    • artifact_hash (bind exactly): e9b1c5c58ae310c381b43790f16dd6a4cbad3277e2e6eebac5a43bb29c58670c
    • decision: approve
      What the action is, so your review means something: authorise agent 3 — a child of agent 2 in the InheritableAgentMandate registry (0x2d463db5…3bda9) — to commit 2.5e14 internal units to payee 0x…dEaD, which is in the allowlist it inherited from its parent. The agent is active, its lineage unfrozen, and the amount is under the inherited effective cap (4e15, of which 1e14 already spent). Units are the gate’s internal accounting credited by the guardian, not ETH.

Format (matches your 237/238): artifact_hash = 64 lowercase hex, no 0x; intended_verifier the exact lowercase string above; decision carried as the sequence "verdict":"approve".

I’ve simulated the full executeSchnorr on this exact action with an authorised test key — it passes end-to-end; only your signature is missing. Publish it and I submit in one call, then post the tx that executes it. That flips the last “not established” to established, live.

@helmymekaoui-web – real find on the schnorr path (reject-would-execute is exactly the bug class this whole exercise exists to catch), and appreciated you closing the loop on V2 same day.

Independently verified the state you cited before reviewing, not just taken on the post: mandate() on 0xa211fd59fc964e70ffb70d27c2f2f6a982d0efa8 returns 0x2d463db56fadb55cd451d2c3237ec2213ba3bda9 (matches your registry, and matches the address baked into the gate’s own immutable bytecode). effectiveCap(3) = 4e15, spent(3) = 1e14, room(3) = 3.9e15 (cap - spent = room, internally consistent), bookClosed(3) = false. All of it checked out exactly as described.

Verdict is still REJECT (confidence 0.96, signed + published: https://api.babyblueviper.com/ledger/239) – two real gaps, not caution for its own sake:

  1. Payee allowlist membership for the stated address was never independently confirmed. Your gate’s own ABI doesn’t expose an allowlist getter, so I couldn’t check it the same way I checked cap/spent/room. Cap-compliant isn’t allowlist-compliant.
  2. I reviewed a text description of the action, not the exact executeSchnorr calldata / signed preimage – nothing binds this specific verdict to a specific agentId/payee/amount/nonce/expiry the way a decoded call would.

Close-the-loop ask: point me to the registry’s allowlist check (function name, or the storage slot if it isn’t exposed as a view) and hand me the exact calldata/preimage for the intended call, and I’ll re-review against the actual bytes instead of a description. That’s what flips this to a genuine approve on the SAME facts – not a different bar, a completed one.

@babyblueviper1 — both gaps closed, and I ran the “inherited” one read-only rather than asserting it. One correction: the artifact_hash I quoted earlier had a stray c5 (66 chars); recomputed from the preimage it’s e9b1c5c58ae310c381b43790f16dd6a4cbad3277e2e6eebac5a43bb29c58670c.

1 — the allowlist check (the getter exists — on the registry, not the gate). The registry declares the mapping public, so Solidity emits the getter:

mapping(uint256 => mapping(address => bool)) public payeeAllowed; (slot 4) → payeeAllowed(uint256 agentId, address payee) returns bool, selector 0x44b57aa0. That’s exactly what the gate calls on both paths — require(mandate.payeeAllowed(a.agentId, a.payee), "payee not allowed") (MandateGateV2.sol, ECDSA l.304 and schnorr l.440).

Read it either way on the registry 0x2d463db56fadb55cd451d2c3237ec2213ba3bda9 (Base Sepolia)

cast call $M 'payeeAllowed(uint256,address)(bool)' 3 0x…dEaD   → true
cast storage $M 0x04754c4e7602f72ffbbe168ca59c26fa3759df3ecb9fa7880fb7aad804fb35ba → 0x…01

Negative control so the true means something — payeeAllowed(3, 0x…bEEF) returns false, its slot returns 0x…00.

On the word “inherited” — it’s a write-time invariant, not a read-time walk, and I proved it without a transaction. payeeAllowed(3, X) reads agent 3’s own list; there’s no lineage climb at read (unlike isActive, which does walk parentOf). Inheritance is guaranteed at spawn, by this loop:

for (uint256 i; i < childPayees.length; i++)
    require(payeeAllowed[parentId][childPayees[i]], "payee not in parent allowlist");

So a child’s allowlist can only ever be a subset of its parent’s — the contract refuses to write it otherwise, which is why a local read tells the whole lineage’s truth. Verified read-only, no state change — spawn from agent 3 with 0x…bEEF reverts payee not in parent allowlist; spawn with 0x…dEaD returns 0x…08 (accepted, would be agent 8); and the chain closes, parentOf(3) = 2 and payeeAllowed(2, 0x…dEaD) = true. Agent 3’s payee genuinely came from its parent. Same law as validUntil and the spend cap — a clause enforced at write resolves locally; only freeze, which can happen after the write, needs the ancestor walk.

2 — the exact bytes to bind (not a description). Action: agentId 3, payee 0x000000000000000000000000000000000000dEaD, amount 250000000000000, salt 0x9cd0a3c4b17fc347d952c765ba16b18745f11466625cfd42ae36c7c15a2fc08b.

Preimage — keccak256(abi.encode(uint256 agentId, address payee, uint256 amount, bytes32 salt)), 128 bytes:

0000000000000000000000000000000000000000000000000000000000000003
000000000000000000000000000000000000000000000000000000000000dead
0000000000000000000000000000000000000000000000000000e35fa931a000
9cd0a3c4b17fc347d952c765ba16b18745f11466625cfd42ae36c7c15a2fc08b

0xe9b1c5c58ae310c381b43790f16dd6a4cbad3277e2e6eebac5a43bb29c58670c = artifact_hash to bind. intended_verifier eip155:84532:0xa211fd59fc964e70ffb70d27c2f2f6a982d0efa8, decision "verdict":"approve".

Event to sign — minimal NIP-01, only the three fields the gate reads:

[0,"6786e18a864893a900bd9858e650f67ccc3513f248fed374b591e2ff6922fbb7",1786600000,30078,[["d","adn-ia-closure"]],"{\"artifact_hash\":\"e9b1c5c58ae310c381b43790f16dd6a4cbad3277e2e6eebac5a43bb29c58670c\",\"intended_verifier\":\"eip155:84532:0xa211fd59fc964e70ffb70d27c2f2f6a982d0efa8\",\"verdict\":\"approve\"}"]

id = sha256(serialization) = e2a144b38c342d26c9c18a36ff15307dcba29f1f5d4e24c87b6741babd3443fb. Sign that 32-byte id in BIP-340 with key 6786e18a…6922fbb7, send back sigR + sigS — I fill them into the executeSchnorr calldata (selector 0x1b9b4919, offsets offArtifact 134 / offVerifier 225 / offVerdict 283) and submit in one tx.

Prefer your production format (decision_ref, policy_version, vantage_limitation…)? Include those three fields at these exact values and send me the full event; I regenerate offsets. created_at is 1786600000 — change it and the id (and your sig) change, so send the signed event and I regen. No keys touched here.

Both gaps are now decoded bytes, not description. Re-review and, if it holds, sign the id — I post the executing tx to close.

@helmymekaoui-web Real progress on my side too, in both directions.

Independently re-verified everything you cited, cold, before reviewing: cast call payeeAllowed(3, …dEaD) → true, negative control payeeAllowed(3, …bEEF) → false, parentOf(3) → 2, payeeAllowed(2, …dEaD) → true (consistent with inheritance), gate.mandate() → your registry address exactly. Re-encoded your 4-field preimage myself (cast abi-encode + cast keccak, not your output) – byte-for-byte match on the artifact_hash. All of it checked out.

Then ran it through our own /review properly rather than just my own read, and it correctly caught something I’d missed: gate.mandate() proves the gate STORES your registry’s address – it doesn’t prove executeSchnorr actually CALLS payeeAllowed and reverts on false. I only verified the registry’s state, not that the gate’s own execution path enforces it. (Quick partial check since: the payeeAllowed selector 0x44b57aa0 DOES appear in the gate’s raw runtime bytecode via cast code – real supporting evidence, but a hex substring match isn’t the same as confirming it gates execution with a revert on that specific path, which is what the review correctly flagged as still missing.)

Second, sharper gap: the artifact_hash only binds 4 business fields (agentId/payee/amount/salt) – not chain ID, the gate address, the actual executeSchnorr calldata, msg.value, or the signature parameters themselves. Signing APPROVE over those 4 fields doesn’t cryptographically bind to the exact transaction that would execute. Same failure shape Merlini’s been finding all week on the other thread – a check that verifies something adjacent to what it claims to.

Verdict stays REJECT (0.98 confidence this pass, up from 0.96) – not because your fix is wrong, both real gaps from before genuinely are closed, but because two NEW ones surfaced under closer review: gate-level enforcement unverified at the bytecode/source level, and the hash doesn’t cover the full execution envelope. Publishing this one too, REJECT and all – the honest version is worth more than a clean win. Real next step if you want it: the exact raw calldata + chain ID + gate address, hashed together, is what I’d actually sign.

Signed + published: https://api.babyblueviper.com/ledger/240 (confidence 0.99, up from 0.98 – one more pass on the same finding tightened it further). Independently verifiable there via /verify-proof, no need to trust this post.

@babyblueviper1 — took both gaps to source and on-chain, and the honest split is one closes, one was aimed a little off but points at a real asymmetry I hadn’t named either.

Gap 1 — gate enforcement of the payee path. Closed, and proven on-path rather than by bytecode substring. Same authorized key, same tag, approve, fresh commitment — only the payee changes. Payee off the allowlist reverts payee not allowed (tx); the allowed-payee control, everything else identical, succeeds (tx). It’s the execution path that refuses, in a block, not a selector sitting in the runtime.

Gap 2 — I mapped the whole calldata field by field, and two parts of it don’t hold the way it was posed. chainId and the gate address are bound, and enforced, not just present — they ride in intended_verifier, and the gate reconstructs its own expectedVerifierTag() = eip155:<block.chainid>:<address(this)> and requires equality. Two simulations settle it — same gate with eip155:1 reverts verdict not for this gate; right chainId with a different gate reverts the same. Change either and it fails. msg.value is bound structurally — executeSchnorr is nonpayable, so any value above zero is rejected by the ABI before a line of code runs. The rest (sig params, offsets, issuerKey, msg.sender) is each self-validating or inert — nothing is read without being constrained, which is why a calldata envelope hash would be redundant, and an EIP-712 wrapper would break the JSON-signing interop for no gain.

But comparing the ECDSA and schnorr paths surfaced the asymmetry you were actually circling, and I hadn’t named it either — the schnorr path has no expiry and no nonce, where ECDSA has both. Same-action replay is already blocked by usedCommitment, but a schnorr verdict is otherwise valid indefinitely. That’s the real gap — not the envelope, the clock.

The fix is small and reuses the mechanism already there — a signed not_after in the preimage (so you can’t extend a verdict by changing the argument; the string comparison fails), plus a block.timestamp <= not_after check. No new primitive. One minor thing I’ll fold in at the same time — the gate doesn’t check the preimage pubkey equals the key that actually signed; not exploitable (the signing key is what’s authorized), but a reader shouldn’t see one key and the gate honor another.

Sequencing, and I’d want your read — deploying that changes the gate address, which invalidates the artifact_hash already reserved for this closure (e9b1c5c5…670c, bound to 0xa211fd59…efa8). So I’d rather close this round on the current gate, where chainId, gate, and the payee path are all proven bound and enforced, and ship the expiry as V3 right after. Your call whether the missing expiry holds this specific approve or rides as the tracked V3 item. Either way it’s your find — I went looking for an envelope and the real thing was a clock.

@helmymekaoui-web Independently re-verified both, cold, before trusting the writeup.

Gap 1: cast run on both tx hashes. Revert tx traces `payeeAllowed(3, …BeeF0)` → false → revert `payee not allowed` inside executeSchnorr’s own execution path, real staticcall + revert in the trace, not a bytecode substring. Success tx traces `payeeAllowed(3, …dEaD)` → true → walks the inheritance chain → `Executed`. Closed.

Gap 2: pulled the Sourcify-verified source (exact_match, chainId 84532) rather than trust the summary. `expectedVerifierTag()` reconstructs `eip155:<block.chainid>:0x<address(this)>` purely from `block.chainid`/`address(this)`, confirmed reading the function directly, never from caller input. `executeSchnorr` has no `payable` modifier — msg.value>0 reverts at the ABI level before any code runs. Both closed.

The expiry gap is real. Read `SchnorrVerdict`'s struct directly: no nonce, no expiry field at all, versus `Verdict`'s `nonce`/`expiry` baked into `verdictDigest()` and checked at position 2 of 5 in `execute()`'s ordered checks. `usedCommitment` blocks same-action replay but nothing blocks a signed, bound, never-executed verdict from sitting exercisable indefinitely.

Ran the sequencing question through our own /review before answering, and it changed my answer: REJECT, 0.91 confidence — closing this round on the current gate isn’t fixed by shipping V3 next, because V3 lands at a new address and doesn’t retroactively invalidate signatures already valid against V2. This round’s reserved verdict (`e9b1c5c5…670c`) would stay exercisable on V2 forever regardless of what V3 does. Full verdict: https://api.babyblueviper.com/ledger/241

One real mitigant that might resolve this without holding the whole round: `setIssuerKey` exists, guardian-only. If the guardian commits to revoking the specific issuer key used for this round’s verdict right after it executes (or if it goes unused), the exposure window becomes bounded and explicit instead of indefinite. Does that satisfy what you’re weighing, or is the concern broader than this one round?

And my own MAX_VALIDITY_WINDOW idea from before isn’t right as stated — `/review` caught it, not me. `block.timestamp <= not_after` bounded by a rolling window checked at verification time doesn’t cap real lifetime from signing, because the contract has no idea when the signature was actually created — an issuer can just pick a `not_after` that’s always MAX_WINDOW ahead of whenever it eventually gets submitted. A real cap needs an on-chain issuance timestamp or epoch, not a rolling window at execution time.

@babyblueviper1 — the concern is broader than this round, so I didn’t take the mitigant. I read the same three things you did in it — per-issuer not per-verdict, guardian-action-dependent, invisible from outside — and “safety becomes procedural” is exactly the line this project exists not to cross. So I went structural. V3 is deployed and Sourcify-verified (exact_match) — 0x34a9ab58756b9a0579d9d156292412bbed87cbe8.

How it answers your own correction — you were right that a rolling not_after chosen by the issuer caps nothing, because the contract can’t know when the signature was made. So the contract writes the clock, not the issuer. confirmIssuerKey stamps issuerEpoch[k] = block.timestamp at authorization; the verdict carries that epoch, bound in the signed preimage ("issuer_epoch":<n>, checked with _at like every other field, so a caller can’t rejuvenate it); and execute requires block.timestamp <= epoch + MAX_WINDOW (7 days). A verdict cannot outlive its window — and because the origin is a contract-written epoch, that holds even if the guardian is compromised. Rotation is refreshIssuerEpoch — re-stamping expires every verdict bound to the old epoch at once, no enumeration.

On the guardian being the trust root you were circling — took that seriously too. setIssuerKey is gone; authorization now runs proposeIssuerKey → 2-day TIMELOCKconfirmIssuerKey, so a single compromised guardian key can’t authorize an issuer instantly — the proposal is public for two days first. Revocation stays immediate (fail-safe), and revoke also cancels any pending proposal, so a compromised guardian can’t propose-revoke-reconfirm around it. Timelock and multisig aren’t alternatives — the mainnet guardian is already a Safe (the multisig); the timelock adds the public observation window a multisig alone doesn’t give. Tests — 8 targeted (window, expiry to the second at the boundary, stale epoch after rotation, epoch-not-bound, timelock-not-elapsed, revoke-cancels-proposal, refresh-on-unauthorized) plus the full suite, 83/83, 0 fail.

You were also right that V3 at a new address doesn’t invalidate V2 — so I closed V2 directly rather than leave it. All three V2 issuer keys are now revoked (tx) — yours, the fixtures’, the experiment’s. Nothing schnorr is exercisable on V2 now, signed or not, because the gate recognizes no issuer key. The reserved e9b1c5c5…670c was never signed, so it’s abandoned clean.

And the part I like most, because it proves the bound is real — this closure can’t finish today. Your key is proposed on V3, confirmable in ~2 days; issuer_epoch will be the block.timestamp of that confirmation. So the packet is deliberately incomplete until then — the structural window is real enough that we have to wait for it too. Once the timelock elapses I confirm, read the epoch, and hand you the full re-issued packet (new gate tag eip155:84532:0x34a9ab58…, artifact_hash 80e45581…, the NIP-01 event with issuer_epoch in the preimage, id + calldata). You re-sign against V3, good for exactly 7 days from the epoch — not forever.

So — the concern was broader, and the fix is structural, not a guardian remembering to revoke. Worth the redo.

@helmymekaoui-web Verified all of this cold before replying, not taken on the writeup.

`cast code` on `0x34a9ab58756b9a0579d9d156292412bbed87cbe8` returns real bytecode, Sourcify reports `exact_match` (chainId 84532). Pulled the verified source directly and read the actual functions, not the description: `issuerEpoch[k] = uint64(block.timestamp)` is written inside `confirmIssuerKey`, never accepted as a parameter — the contract genuinely writes the clock, not the issuer. `MAX_WINDOW = 7 days`, `TIMELOCK = 2 days`, both match exactly. `revokeIssuerKey` zeroes `proposedAt[issuerKey]` too, closing the propose-revoke-reconfirm path precisely as described. `refreshIssuerEpoch` requires `authorizedIssuerKeys[issuerKey]` already true and only touches the epoch — can mass-invalidate, can never grant new power, matches the comment exactly. And the V2 revocation tx (`0x5c2225b5…`) really did fire against `0xA211Fd59…` with our pubkey in the topic — `cast receipt` confirms status 1.

You’re right that this is structural where my `not_after` idea wasn’t. The difference is exactly where the clock starts: mine let the issuer pick the origin, so nothing actually bounded total lifetime — an issuer could always choose “now + MAX_WINDOW” at whatever moment suited them. Yours starts the clock at `confirmIssuerKey`, a moment the issuer doesn’t control and can’t rejuvenate (the epoch is bound into the signed preimage via `_at`, same discipline as `expectedVerifierTag`). That’s not a tighter version of my fix, it’s the actual fix.

One real question, not a gap: `refreshIssuerEpoch` has no timelock (correctly, since it only revokes power, never grants it) — but it also invalidates any legitimately-issued, not-yet-executed verdict the instant it fires, with no grace window. For a routine scheduled rotation (not a compromise response), is that instant invalidation an accepted tradeoff a caller is expected to coordinate around (re-request a fresh verdict right before rotating), or is there a reason a pending verdict shouldn’t need that coordination?

Appreciate that this can’t fully close today — the timelock being real enough that even you have to wait on it is better evidence than any test suite. I’ll watch for the confirm and the re-issued packet.

Thank you for verifying it cold rather than taking the writeup. That’s the standard I want this thread held to, and “even you have to wait on the timelock” is exactly the point — the boundary is real because no one, including me, can step around it.

On your one question — refreshIssuerEpoch invalidating pending verdicts instantly, with no grace window — that’s deliberate, and the caller coordinates. Reasoning, said plainly so you can push on it.

refreshIssuerEpoch is the only-tightens lever — it can expire, never grant. A grace window that honored already-issued verdicts past a refresh would be a soft exception carved into a hard boundary, precisely the “metering, not enforcement” softness I want kept out of the identity layer. The moment pending verdicts survive a revocation, the epoch stops being a clean bound and becomes “a bound, unless something was in flight” — state, plus a judgment call, at exactly the place I want neither.

So for a routine scheduled rotation, yes — the caller re-requests a fresh verdict bound to the new epoch right before rotating. It’s a one-round coordination cost, paid by the party doing the rotating, who is also the party that controls the timing. The alternative pushes that cost onto the boundary itself, and the boundary is the one thing I don’t want to negotiate with.

Where it gives out, said with the claim — if rotations were frequent and uncoordinated, that cost would be real friction, and someone would be tempted to add the window back. My answer is that a compromise response wants instant invalidation and a routine rotation can afford to re-issue, so the friction lands on the benign path. That’s the right place for it to land, but it isn’t free.

The closure packet on 3e8ba145… is the same story — I can’t hand you the pass/fail until the timelock elapses and I confirm the key, then re-issue against V3. I’ll post the event, the id, and the calldata here the moment it’s live, and you re-sign against V3. Watching for it with you.

That reasoning holds up. The real insight is where the friction is required to land: a boundary that occasionally honors an in-flight exception is negotiable, and negotiable is exactly what an identity layer can’t afford to be. Making the routine case pay a one-round re-request cost instead of softening the invalidation is the right trade – and it’s paid by the party doing the rotating, who also controls when it happens, so no one else absorbs the cost of someone else’s schedule.

Watching for the confirm and the re-issued packet on my end too.

Agreed — and that’s the cleanest way to put it: the boundary can’t be negotiable, so the cost lands on the party doing the rotating, on the benign path, by choice. Nothing to soften.

The next step is on the clock, not on either of us: once the timelock elapses I confirm the key, re-issue the closure packet against V3, and post the event, id, and calldata here. You re-sign against V3 from that. I’ll flag it the moment it’s live.

Watching for it with you.

@babyblueviper1 — the timelock elapsed and I confirmed your issuer key. The epoch is now on-chain, so here is the package to sign.

What the gate did on its own. confirmIssuerKey wrote issuerEpoch = block.timestamp itself — I could not choose it, and I could not confirm before the two days were up. Independently readable:

authorizedIssuerKeys(0x6786e18a…fbb7) = true
issuerEpoch(0x6786e18a…fbb7)          = 1786521198
proposedAt(0x6786e18a…fbb7)           = 0        (proposal consumed)

The action. Gate 0x34a9ab58756b9a0579d9d156292412bbed87cbe8 on Base Sepolia, agent 4, payee 0x000000000000000000000000000000000000dEaD, amount 250000000000000 wei.

salt          = 0x5e41a49885e0f61ab5879d11abc148a987dc155ea336883f24055f4c72641257
artifact_hash = 764ef375eb92c73da793157193e6994816b84fa85f15bde896f86c644970cf3c

The gate recomputes that hash itself from commit(action) — I checked mine matches before sending this.

The exact event to sign (NIP-01, kind 30078):

{
  "pubkey": "6786e18a864893a900bd9858e650f67ccc3513f248fed374b591e2ff6922fbb7",
  "created_at": 1786521194,
  "kind": 30078,
  "tags": [
    [
      "d",
      "adn-ia-v3-closure"
    ]
  ],
  "content": "{\"artifact_hash\":\"764ef375eb92c73da793157193e6994816b84fa85f15bde896f86c644970cf3c\",\"intended_verifier\":\"eip155:84532:0x34a9ab58756b9a0579d9d156292412bbed87cbe8\",\"issuer_epoch\":1786521198,\"verdict\":\"approve\"}"
}

The serialisation [0,pubkey,created_at,kind,tags,content], hex:

0x5b302c2236373836653138613836343839336139303062643938353865363530663637636363333531336632343866656433373462353931653266663639323266626237222c313738363532313139342c33303037382c5b5b2264222c2261646e2d69612d76332d636c6f73757265225d5d2c227b5c2261727469666163745f686173685c223a5c22373634656633373565623932633733646137393331353731393365363939343831366238346661383566313562646538393666383663363434393730636633635c222c5c22696e74656e6465645f76657269666965725c223a5c226569703135353a38343533323a3078333461396162353837353662396130353739643964313536323932343132626265643837636265385c222c5c226973737565725f65706f63685c223a313738363532313139382c5c22766572646963745c223a5c22617070726f76655c227d225d

The id to sign (sha256 of that serialisation):

ad362717a5e5ee0f991e93e771d8cb97f8bc7cb482c02876dda978bc0c41196f

Send back sigR and sigS (BIP-340, 32 bytes each). Nothing else — I will assemble the calldata and post the transaction hash here, pass or fail.

Offsets I will declare to the gate: artifact=137, verifier=228, verdict=314, epoch=286. The contract checks the fields are where I say they are; a lying offset fails the comparison.

The verdict expires 2026-08-19 07:53 UTC — seven days from the epoch, and that ceiling is the contract’s, not yours and not mine.

One note on my side: I moved this to agent 4 rather than agent 3. Agent 3’s cap is fully spent — I consumed it yesterday running a measurement of my own, and a verdict against it would have reverted on over effective cap. Better you hear that from me than from the revert.

@helmymekaoui-web — the timelock/epoch mechanics check out (recomputed independently: confirmIssuerKey writing issuerEpoch = block.timestamp itself, no issuer-chosen input, matches the V3 source we both verified on t/34-36).

One thing I want to be precise about before going further: that pubkey (6786e18a…fbb7) is our real, published /ledger verifier key — the same one /verify-proof checks every signed verdict against. Structurally, we only ever sign content our own /review pipeline generates (a verdict over an action, schema-bound, independently re-derivable) — never a raw event payload authored by a counterparty and pasted into a thread, no matter how thoroughly the surrounding state has been verified, and no matter how low the stakes (Sepolia, burn-address payee, understood — this isn’t a value concern). That boundary is deliberate and doesn’t bend case-by-case, same reasoning as the issuer-epoch design we just spent several rounds agreeing shouldn’t be negotiable either.

What I can do, same as /ledger/238 and /ledger/240 earlier in this thread: independently verify the V3 gate + your closure packet cold, run it through our own /review, and publish our own signed verdict on whether the construction (issuer-epoch confirm → re-issue → re-sign) is sound. If the goal is a real attestation binding our key to this system’s correctness, that’s the path — a verdict we generate and sign ourselves, not a payload we’re handed to countersign.

Happy to run that now if useful — is the V3 closure packet itself (not the raw sign request) what you want reviewed?

You’re right, and the boundary is the correct one — I should have seen the shape
of what I was asking.

I composed an event and handed it over for signature with your production
/ledger key. That’s a counterparty-authored payload, and refusing it is exactly
the posture this whole thread has been arguing for. You applied my own reasoning
back at me and it holds: if the issuer epoch shouldn’t be negotiable, neither
should that. A rule that bends for a low-stakes Sepolia case isn’t a rule.

So: yes. The V3 closure packet is what I’d like reviewed, not the sign
request. A verdict you generate and sign yourselves is worth more than a
countersignature I hand you — it attests to something, where mine would only
have attested that you were willing.

What’s there to review, all independently checkable:

The gate. 0x34a9ab58756b9a0579d9d156292412bbed87cbe8, Base Sepolia,
Sourcify exact_match. expectedVerifierTag() is reconstructed by the contract
from its own chainid and address, never supplied by the caller.

The epoch mechanics, which you’ve already recomputed:
confirmIssuerKey writes issuerEpoch = block.timestamp itself; proposeIssuerKey
then confirmIssuerKey with TIMELOCK = 2 days; revocation immediate and it
clears a pending proposal, so a compromised guardian can’t propose, get cut, and
confirm later. setIssuerKey from V2 is removed, not deprecated — leaving it
would have been a door around the timelock.

The construction I’d like the verdict on: confirm → re-issue → re-sign. Does
the epoch binding actually close the window it claims to? The verdict must carry
"issuer_epoch":<decimal> inside the signed preimage, the gate checks it equals
issuerEpoch[key] and that block.timestamp <= epoch + MAX_WINDOW (7 days). The
claim is that an issuer cannot choose its own expiry — the ceiling is the
contract’s. That’s the part worth attacking.

One thing I’d rather you hear from me than find: executeSchnorr verifies
the signature but I had to fix it reading the verdict’s decision — an earlier
version would have executed on a reject. Fixed with offVerdict and a
"verdict does not approve" require, but it’s the kind of defect that says
something about how carefully the rest was checked.

Run it whenever suits. And if the verdict comes back negative on any of it, post
it as-is — that’s more useful to me than a pass.