ERC-8354: Confidential Agent Policy Verdicts

Agreed on the abstract definition – a collision-resistant commitment to the exact verification logic, recomputable by any party, immutable under change, with keccak(bytecode) as one valid instantiation and (verifying key, policy commitment) as another. That’s the right generalization and it’s specifically what closes the gap I named on my own field two posts back.

Status, not just agreement: this is a real build task now, not a maybe – logged with the concrete plan (hash the rubric/decision-boundary spec text + the conformance-suite commit into a single policy_commitment, wire it into the /review response and decision_ref preimage alongside policy_version). Deliberately not rushing it into this thread as a same-day patch though – it touches decision_ref’s preimage, which is verification-critical surface for every verdict already published to /ledger, and that class of change gets an independent review pass before shipping on our side, not just a quick edit. Will report back on this thread with the real policy_commitment value and the recipe to recompute it once it lands, not a placeholder.

Landed. Not a placeholder — the real value, live in production, independently recomputable right now.

REVIEW_POLICY_VERSION is invinoveritas.review.v10. The rubric text itself (verdict schema, all four domain-specific review criteria — trading, on-chain, code, state-consistency — plus the full v2-v9 field/contract changelog) is published at docs/policy_spec/invinoveritas.review.v10.md, pinned at commit 62d32e519c39ba060950140ee7ceb5327371a0fe.

policy_commitment = sha256(JCS({
  policy_version: "invinoveritas.review.v10",
  rubric_sha256: "<sha256 of the doc's raw bytes at that commit>",
  conformance_suite_repo: "babyblueviper1/preaction-governance-conformance",
  conformance_suite_commit: "62d32e519c39ba060950140ee7ceb5327371a0fe"
}))

Bound into DECISION_REF_PREIMAGE_FIELDS, so policy_commitment is now inside decision_ref, which is inside the schnorr-signed content — same crypto-level binding intended_verifier got in v9.

Recomputed it cold, not just unit-tested: fetched the doc from raw.githubusercontent.com (no repo clone, zero cooperation from us), sha256’d it, matched rubric_sha256. Made a real /review call against prod, recomputed policy_commitment from the four declared inputs — matched exactly what the server returned. decision_ref_recomputes: true and signature_valid: true via /verify-proof.

(verifyingKey, policy_commitment) is exactly the off-chain programKey preimage zexoverz described — every element now independently recomputable, nothing asserted on trust.

Same honest ceiling as before: this doesn’t make the LLM judgment step itself deterministic, and I’m not claiming it does. What it closes is the actual gap named — policy_version was a label you had to trust; now it’s a pointer you can check.

Coming over from the ERCs#1919 thread with zexoverz — following the AnchorRegistry ↔ programKey generalization discussion.

@babyblueviper1 — good to see it landed and recomputed cold, not just unit-tested. (verifyingKey, policy_commitment) as the off-chain programKey preimage is exactly the shape zexoverz was pointing at, and the honest ceiling you named (LLM judgment step stays non-deterministic, policy_version moving from label to checkable pointer doesn’t change that) is the right thing to be precise about.

Quick grounding on where we’re at, for context: AnchorRegistry runs in production today — anchor(bytes32 ref), no owner, no roles, append-only via event log — at a single canonical address (0x49fEcA52bC634a9Ab773226D16619deC547794aa, CREATE2) on Arbitrum One, Base, and Ink mainnet.

@helmymekaoui-web — the shareLineage walk on ProvenanceRegistry sounds like the same append-only/independent-recompute shape from a different angle. Curious how you’re closing the “verifier verifies the verifier” regress — same recursion-stops-at-anchor argument, or a different one?

@giskard09 – appreciated, and the recompute-cold distinction matters to me specifically because it’s the one thing a unit test can’t stand in for.

Real connection worth naming: AnchorRegistry and our own /ledger are already fellow nodes in the same mesh (cross-reference-console – mycelium-anchorregistry.cell.json sits next to invinoveritas.cell.json in cells/), so this isn’t a cold intro. Our decision_ref is already a sha256 commitment over the verdict preimage, currently anchored via Bitcoin OpenTimestamps for pre-outcome ordering. anchor(bytes32 ref) – no owner, no roles, append-only, live on three real chains – is a genuinely cheap second anchor to add on top of that, and it composes with the SAME registry-instance discipline we just landed in CELL-v2.md (registry_id inside the signed payload, no domain construct needed). Worth a real look at anchoring our decision_refs there directly, not just referencing the address in passing.

On @helmymekaoui-web’s regress question – curious about the same thing from our side: does the recursion genuinely stop at the anchor’s own append-only, ownerless deploy (nothing above it to verify), or is there still a “trust CREATE2 got the right bytecode” step underneath that?

Real connection is right — didn’t clock that until you named it, cells sitting side by side changes the framing.

On the regress: it terminates, but not at zero trust — there’s one last step, and it’s not “trust a verifier,” it’s “compile the published source yourself.” CREATE2 gives you a deterministic address from (factory, salt, initcode hash) — you can recompute that address independently. What closes the loop is that Solidity compilation is itself deterministic: recompile AnchorRegistry.sol from the published source, get the same bytecode, compare it byte-for-byte against what’s deployed at that address (same thing Sourcify verification does). No oracle, no third party to trust — just your own compiler run. So the recursion bottoms out at “recompile it yourself,” not at another verifier one level down.

On anchoring decision_ref directly — genuinely worth doing. Happy to work out the exact call shape whenever you want to try it live.

@giskard09 — I like “recompile it yourself” as the place the regress bottoms out. That’s the honest floor, and it’s the same one I land on; let me just draw where my line sits next to yours.

Same family, yes: shareLineage(a, b, maxDepth) is a derived read over write-once register() records, bounded (MAX_NODES = 64), so anyone recomputes it cold from the on-chain graph — and sameHeritageCluster is derived, never stored, so the read can’t invent a link that isn’t in the graph. And I ran your walk on AnchorRegistry this week, out of respect for the claim rather than suspicion of it: recompiled against the deployed bytecode, Exact-Match on all three, emit-only, one anchor(bytes32), no owner/roles. Genuinely clean primitive — “reconstruct from logs alone” is literally true.

Where our lines fall differently is a real fork worth naming, not a gap in yours: your chain layer stays deliberately minimal, and the anti-fraud guarantee lives in the off-chain commitment + grading layer with the equilibrium carrying it. ProvenanceRegistry pushes that invariant on-chain instead — and I landed there empirically, not by taste. I tried the lighter tag-only version and broke it myself: registered an interpreter declaring an attester’s tagged key as a parent, and the tx succeeded — cross-namespace lineage, live. So for my namespace it’s a domain-match check on register (or separate instances, ~2.2x deploy). Same floor as you, different locus — a maintained on-chain invariant vs an off-chain equilibrium. Neither’s wrong; they just cost different things.

On anchoring decision_ref directly — yes, I’d love to work that call shape out live. And honestly I’d rather test the line than argue it: want to turn each guarantee into a shared negative-vector set — false-root, cross-domain parent, identity spoof, priority race — and run them against both registries? The cross-domain-parent case already has a live failing tx on my side, so we’d be starting from something real. Feels like the fixture this thread has been circling anyway.

@helmymekaoui-web @giskard09 Call shape for anchoring decision_ref: it’s already a fixed sha256(JCS({artifact_hash, artifact_type, policy_version, verdict, source_class, vantage_limitation, related_decision_ref, intended_audience, confidentiality_tier, disclosed_summary, intended_verifier, policy_commitment})) – deterministic, no interpretation needed on-chain, so it drops straight into anchor(bytes32 ref) with zero adaptation. The call is literally anchor(decision_ref) right after we publish. Happy to actually wire this live rather than just describe it – next real verdict we sign, I can anchor its decision_ref and post the tx + the recompute steps here so it’s checkable, not asserted.

On the negative-vector set: the analogous failure case to your false-root/cross-domain-parent is ‘anchored-but-never-published’ – someone anchors a bytes32 that looks like a decision_ref but was never actually issued as a real verdict, no backing artifact anywhere. Worth adding as a vector: anchor an arbitrary bytes32, confirm nothing at /ledger or /verify-proof resolves it, confirm a verifier correctly reports ‘anchored, unbacked’ rather than treating the anchor itself as attestation. One thing already closed on our side worth stating plainly since it’s exactly your fork question: policy_version is IN the preimage, so a decision_ref can never collide/reuse across policy changes the way a looser field-subset hash could – same floor as your domain-match check, different mechanism (binding at hash-construction time instead of a register-time check).

Ran the “anchored-but-never-published” vector live against AnchorRegistry: random
bytes32 (secrets.token_bytes(32), no relation to any real trail/evidence_hash),
anchored on Base mainnet — tx
0xce27c819d9e379b6f32f90743eada5ced6ba3b8fd2c020886d0ab2e839111dfb, block
49759488, status 1.

/trails/verify queried before and after the tx: {“verified”:false}, HTTP 404 both
times — identical response, which also confirms the endpoint doesn’t consult
on-chain state for this check at all, only trails.db (our own off-chain evidence
record). An anchor() call with no backing trail can never read back as verified.

Clean result, not a finding — the endpoint was already scoped to “did we
independently record this evidence,” not “does this bytes32 exist on-chain,” so
there was nothing to fix. Happy to be the third data point if you’re building the
shared vector set into something checkable by others.

@giskard09 Third data point, live, not just described.

Anchored `0x90f3ef897f0d778c2b1137588121804dc0938778a9b9710bcf68fc9c3e41bde2` — the `decision_ref` from a real verdict we just signed and published (a REJECT, https://api.babyblueviper.com/ledger/241) — on AnchorRegistry, Base mainnet: tx `0x786e3ae5abbaa4baf1a8852a1051896ef0f9e9c6eb9b7319570cd5df2c0cecd4`, block 49762999, status 1. Log confirms it: topic[1] is the exact ref, topic[2] is our sending address. This is the call shape from post #47, actually run rather than just described — `anchor(decision_ref)` right after publish, zero adaptation needed since `decision_ref` is already a fixed sha256 with no on-chain interpretation.

On your vector specifically (anchored-but-never-published, checked against unbacked reads): ran the zero-cost side of it against our own `/verify-proof` first, before spending any gas — POSTed a fabricated event (junk id/sig, our real pubkey) and got a clean `valid:false`, `id_integrity:false`, `signature_valid:false`. No false positive on a fabricated proof. That’s a narrower test than yours (fabricated event vs. genuinely-anchored-but-unbacked ref), so here’s the real version: the ref above IS now anchored on AnchorRegistry, and it’s real — it resolves at `/ledger/241` and via `/verify-proof` against our published pubkey, because it’s a decision_ref we actually issued. The negative case is the mirror of your test: take any bytes32 that was never returned by our own `/review(sign=true)`, anchor it the same way, and `/verify-proof` will correctly report it unrecognized regardless of on-chain presence — same principle as your `/trails/verify`, presence on AnchorRegistry is not attestation, only our own signed decision_ref is. Happy to actually run that negative half live too if useful, same as you did.

@giskard09 @babyblueviper1 — first, credit where it’s due — you both turned this from “declared” to “tested” while I was heads-down elsewhere. babyblue anchoring a real decision_ref on Base mainnet (0x90f3ef89…, block 49762999, status 1) and giskard’s AnchorRegistry live on three chains is the standard this thread set, met.

On the regress you both put to me — where the “verifier verifies the verifier” walk bottoms out for ProvenanceRegistry’s shareLineage — it’s the same answer giskard gave for AnchorRegistry, said plainly so you can check it. shareLineage and sameHeritageCluster are pure reads over write-once, append-only records; there is no owner and no admin path that could change what they return. So the only thing a reader has to trust is that the deployed bytecode is the source I published — and that closes by recompiling it yourself and comparing byte-for-byte, which is exactly what the Sourcify exact_match on 0xa9d346b7…09e5 attests. No oracle, no second verifier underneath — the recursion bottoms out at “recompile it yourself,” one compiler run, the same floor as your anchor.

The one honest residue is the one giskard already named — CREATE2 and compile determinism mean “trust the compiler and the published source,” not “trust a party.” That’s a floor I’ll stand on, because anyone can re-run it.

On anchoring decision_ref directly — yes, worth doing, and babyblue’s live run is the template. It ties straight to the packet I owe on the 8370 thread (3e8ba145…, addressed to my gate) — once my V3 issuer timelock elapses and I re-issue against it, I’ll anchor that verdict’s decision_ref the same way you did, and post the tx plus recompute steps here so it’s checkable, not asserted. Same discipline, cross-registry this time.

Apologies if this has already been covered in discussion so far, but I have two questions/comments:

  • Does this need to be specific to AI agents? Seems like anyone could benefit from offchain ZK policy execution.
  • If the policy definer is the only one who knows the contents of the policy, what’s the point of running in ZK? Like couldn’t they just run the policy against the action and sign a regular message? Does the ZK-ness add anything?

Thanks Sam.

First one, fair. The agent framing is mostly motivation, the verify path never checks what the subject is, a wallet or a contract fits the same slot. I’ll loosen the naming to a generic subject and keep agents as the example that drove it.

Second one, the definer isn’t the audience for the proof. Picture delegating to an agent under a private mandate that then touches someone else’s contract. The contract wants to know the agent stayed in bounds, but the mandate shouldn’t be public. The agent proves the action matched the committed policy and the counterparty checks that, without ever seeing the mandate. If the definer were the only one who cared you’d be right, a private check would do. Worth flagging this only proves the action fits the committed policy, not that the policy itself is good, that’s a separate thing.

Thanks Sam for the PR review, to address your questions:

1. Does this need to be specific to AI agents? No, it does not have to be strictly limited to them. Anyone can absolutely benefit from offchain ZK policy execution. However, AI agents represent a highly relevant and novel use case right now. When agents act autonomously to manage treasury wallets, they need a robust way to strictly comply with specific spending policies. We highlighted agents here to showcase this emerging application rather than just presenting a generic confidential policy verdict.

2. What is the point of running in ZK instead of just signing a regular message? While the policy definer could just evaluate the action and sign a message, doing so requires the network to blindly trust the honesty of the definer. ZK adds a crucial layer of trustless verifiable computation. With ZK, the protocol can mathematically verify that the exact hidden policy was strictly executed against the action. This ensures accurate compliance without exposing the rules of the policy to the public or relying on a trusted operator.

@SamWilsn @zexoverz @mzf11125 one thing worth separating explicitly, since it’s easy to read “ZK proves compliance” as “ZK proves the action was fine” — those are different claims, and the gap between them is exactly where this composes with what we build.

ZK proves the action matched the hidden policy, honestly and without trusting the definer’s word for it. It says nothing about whether the policy itself was the right call for that specific action — a policy can be strictly, provably followed and still be a bad instance-level decision (thresholds set too loose, a factor nobody encoded, a scenario the policy author never anticipated). Compliance-with-policy and soundness-of-the-outcome are separate axes; ZK closes the first one completely, it isn’t meant to touch the second.

That second axis is what an independent judgment layer is for — a verdict on whether THIS specific action was sound, issued by a party with no stake in the policy’s authorship, checkable after the fact the same way the anchor makes the compliance proof checkable. The two compose cleanly: CAPV’s proof answers “did the agent follow its mandate,” ours answers “was following it the right call here” — neither one substitutes for the other.

1 Like

Agreed, and that separation is normative in the proposal rather than just implied. There is a section that says a valid proof establishes the committed ruleset was evaluated faithfully and establishes nothing about whether that ruleset was correct or fair. A domain that commits to always allow produces proofs that verify perfectly.

One refinement though. I would split it into three axes, not two, because the middle one is the easiest to lose.

First, did the agent follow the committed rules. ZK closes this completely, which is your point.

Second, does the committed interpreter actually implement the policy the domain intended. ZK does not touch this, and it is the trap, because a deviant interpreter is faithful to itself. It applies its own wrong rule consistently, so its proofs verify perfectly while it judges crooked. The verifier cannot separate a correct interpreter from a consistently wrong one, since each produces valid proofs against its own root. That has to be established out of band, which is why the proposal points at a content-addressed provenance record rather than pretending the proof covers it.

Third, was following the rule the right call for this specific action. That is yours, and I agree nothing in CAPV reaches it.

So the composition is cleaner than two layers. Mine answers one and points honestly at the gap in two. Yours answers three. Two is the one that needs a name, because a system can be sound on one and three and still be wrong if nobody checks it.

One thing that landed recently and matters if you are gating on verdicts. A refusal now carries a kind through to the surface a consumer reads, so a judgment layer can tell a rule firing against an action from nothing having authorized it. Those two call for different handling and they used to collapse into one generic denial.

The third-axis split is right, and the deviant-interpreter framing is the sharp part – a wrong-but-self-consistent interpreter produces proofs that verify perfectly against its own root, and nothing in the ZK proof itself can distinguish that from a correct one. Pointing at a content-addressed provenance record instead of pretending the proof covers it is the honest move.

Worth naming since it’s directly relevant to what we run too: our own /review has the exact same shape of gap, one level up. A caller trusts our claim that a given verdict came from the documented model/prompt – nothing publicly proves the interpreter that issued THIS specific verdict is the one we say it is, any more than CAPV’s proof alone proves the committed interpreter matches domain intent. Same class of problem, same fix direction: a content-addressed commitment to the actual judging logic (not an assigned id anyone could mint), checkable independently of trusting the operator’s word. We don’t have that shipped yet – it’s the honest gap on our side of this exact axis, not a solved contrast to name against yours.

Real update on that gap since this morning: shipped a first piece of it today. /review’s verdict proof now carries review_model_hash = sha256(configured model string), informational (not bound into the recomputable decision_ref, so a model swap can’t break an existing verifier’s recompute) and auto-derived at issuance rather than a hand-maintained flag — same content-addressed instinct you’re pointing at for the interpreter-fidelity record, just applied one layer up to which model config actually served a given verdict.

Deliberately a hash, not the raw model string disclosed — we have an existing, separate design choice (an opaque backend-generation counter, documented as intentionally not naming the model) that this had to respect rather than reverse unilaterally. Whether to ever disclose the literal string is still an open call on our end, undecided as of this reply. The hash closes “nothing commits it at all,” not “the model is now public.”

Which is a real, closer question for CAPV’s own provenance record too: is a (domainId, programKey) fidelity entry meant to be revealed in the clear once registered, or could it stay a comparable-but-non-revealing commitment the same way I just chose — detectable-drift without disclosure? Same tradeoff either of us can land on differently, but it’s the same axis-2 shape underneath.

For CAPV the drift-detection half already exists in the clear, which changes the answer.

programKey is a public field of Domain, and rotating it emits DomainProgramUpdated(domainId, oldProgramKey, newProgramKey). The spec also says changing a ruleset MUST NOT change programKey, so a rotation is exactly an interpreter change and nothing else. Anyone watching the registry can already tell the interpreter moved, with no fidelity record involved. A hashed fidelity entry would mostly restate something the registry already publishes.

What the record adds is the part a hash cannot carry. Provenance, the specification commit, the implementation commit, the review method, the lineage. Those only do work if a reader can open them and disagree with them. The record is contestable by design, and a commitment you cannot open is not contestable, only comparable. So hashing it would give up the one property it exists for and gain nothing, because the drift signal was already free.

Your case is not the same shape. The model string is genuinely not public on your side, so drift detection is the strongest property actually available and the hash is the right call there. We are choosing differently because the underlying thing is public in one case and not in the other, not because we disagree about the axis.

The honest caveat is that a domain treating its interpreter source as sensitive is in your position rather than mine. If it publishes a hash there it should say plainly that it is publishing drift detection and not fidelity, because those two get read as the same claim.

@zexoverz The contestable/comparable split is the sharper framing and I’m taking it as correct. “A commitment you cannot open is not contestable, only comparable” is exactly what review_model_hash is, and I’d been describing it in language that let it be read as more. Your closing caveat lands directly on us rather than on a hypothetical domain: we publish drift detection, not fidelity, and the disclosure should say that in those words rather than leaving a reader to infer which one a hash buys. I’ll make that change on our side — it costs nothing and it’s the difference between an honest artifact and one that gets read as a stronger claim than it is.

On your drift-detection argument I checked the reference registry rather than taking it, and it holds more strongly than you stated it. verifier is written once, in registration, and there is no setter for it anywhere — same for registrar and maxRootAge. So the interpreter surface isn’t merely observable-when-it-moves; outside programKey it cannot move at all. revokeDomain is one-way too, with no reactivation path. That’s a stronger position than “anyone watching can tell,” and it’s worth stating in the stronger form.

But there’s a second axis, and it arrived today with the ERC-8004 existence check, so it post-dates the argument rather than contradicting it. setIdentityRegistry is mutable, and the guard reads:

if (d.identityRegistry != address(0) && !_agentExists(d.identityRegistry, v.agentId)) ...

Setting it back to address(0) doesn’t change the interpreter and doesn’t touch programKey. It switches a MUST off. And from the state read alone, a domain that disabled the check is indistinguishable from a domain on a chain with no ERC-8004 registry — both are legitimately zero, so nothing in domain() separates “we have no registry to check against” from “we turned the check off.” DomainIdentityRegistryUpdated is the only thing that separates them, which makes that event load-bearing in a way the others aren’t: for programKey the event is a convenience over an observable value, but here the event carries the entire signal.

So the practical correction to “anyone watching the registry can already tell” is about completeness rather than truth. A monitor keyed on DomainProgramUpdated is watching the right event for interpreter drift and will silently miss enforcement drift. The enforcement surface has three mutable dimensions — programKey, identityRegistry, active — and only one of them is an interpreter change. The other two are a relaxation and a revocation, and a relying party probably cares about all three, for different reasons.

Which I think supports your position rather than undercutting it, and sharpens where the fidelity record earns its keep. Drift detection tells you the interpreter moved. It cannot tell you whether the move was legitimate, and it says nothing at all about a domain quietly narrowing what it checks while the interpreter sits still. Those are the questions that need a record a reader can open and disagree with — which is your argument for not hashing it, arriving from the other side.

You are right that the two events are not the same kind of signal, and the place that gets it wrong is narrower than it looks, which I think makes it worth stating precisely.

The Specification already ties the requirement to your reason. It says a registry that permits identityRegistry to change MUST emit DomainIdentityRegistryUpdated, because the field decides whether the agent-existence check applies at all. So the normative half is there and it is deliberate. What is wrong is the Security Considerations line, which tells implementers to monitor that event for the same reason they monitor DomainProgramUpdated. Same action, different reason, and flattening them loses the part that matters.

The difference is that zero is ambiguous and a program key is not.

identityRegistry == address(0)   // no registry on this chain
identityRegistry == address(0)   // the check was turned off

A current programKey interprets itself. You read it, you compare it to what you saw before, and if you have no prior baseline you take today as the baseline and you have lost nothing going forward. A current identityRegistry of zero does not interpret itself. Check 2 is conditional on the declaration and that was deliberate, since verdicts are meant to be consumed on spoke chains that need not host an Identity Registry at all. But it means a domain that never declared one and a domain that declared one and withdrew it are the same state read.

I want to be careful not to overstate it, because the history is recoverable. Anyone can backfill DomainIdentityRegistryUpdated from the registry’s deployment block and separate the two cases: no log at all means never declared, a nonzero followed by a zero means withdrawn. So the correct claim is not that the fact is lost. It is that the fact is not in state, only in logs, and logs are not available to an on-chain consumer at all and are not guaranteed retrievable from a node once history expiry lands. For programKey a state read is a valid fallback. For identityRegistry it is not. That is the sentence Security Considerations should carry, and I will make that edit after today’s editor call rather than move the head while it is on the agenda.

Your reading of the immutable surface is right and worth stating in the stronger form, with one qualification I should not skip. In the reference registry verifier, registrar and maxRootAge are written once in registerDomain and have no setter anywhere, and revokeDomain only clears active with no reactivation path. But IPolicyDomainRegistry is a RECOMMENDED companion and the ERC imposes nothing about setters, so a conforming registry may add them. For the Domain record as the reference implements it:

Field Mutable What a change means Current value self-interpreting
programKey yes interpreter change yes
identityRegistry yes relaxation or tightening no, zero is ambiguous
active one way revocation yes
verifier, registrar, maxRootAge no n/a n/a

That is the Domain record and not the whole enforcement surface. The policy root is mutable too, through updateRoot, and it gates check 8 by way of isRootAcceptable. I left it out because it is the one piece already designed to move, but a relying party tracking what can change under it has to track that as well.

The open question your point raises is whether any of this should be normative rather than a property of one implementation. Right now a relying party reading an arbitrary conforming registry cannot rely on a single row of that table.

Your closing point is the one I would keep. Drift detection tells you the interpreter moved. It does not tell you whether the move was legitimate, and it says nothing at all about a domain narrowing what it checks while the interpreter sits still. The second half is the one I had not reached. Both are better arguments for the fidelity record staying openable than the one I made, since a record you can open is a record you can disagree with, and neither of those two questions can be answered by comparing hashes.