ERC-8354 - Confidential Agent Policy Verdicts

Created 2026-07-16
Status Draft
Category ERC
Type Standards Track
Authors
Requires

Abstract

This proposal defines a minimal interface for consuming a confidential policy verdict: a zero-knowledge proof that a proposed agent action was evaluated against a committed policy and permitted, where the policy itself is never revealed on-chain.

A Policy Domain registers a commitment to its ruleset as a statement in the ERC-7812 Evidence Registry. An off-chain policy engine evaluates a candidate action against the ruleset and emits a proof whose public inputs bind the verdict to an ERC-8004 agent identity, the ERC-7812 policy root the decision was made against, a commitment to the action, the address permitted to execute it, an expiry, and a single-use nullifier. A guard contract on any EVM chain verifies that proof locally and gates execution on it.

This standard does not define the policy language, the proving system, or the transport. It defines only the verdict envelope and the verification interface.

Motivation

The agent standards landing on Ethereum authorize agent behaviour in one of two ways.

The first is retrospective: ERC-8004 records identity, reputation, and validation attestations after an agent has acted. This is useful for relying party selection and useless for interdiction.

The second is mandate-based: a verifier confirms, before execution, that an agent's batch matches an intent the user signed in advance. A bounded mandate meters how much authority an agent has spent. Both approaches derive authority from a principal who signs a specific grant.

A large class of real deployments fits neither. Consider a corporate expense card. The cardholder does not pre-sign each purchase, and the card network's fraud rules are not shown to the cardholder or to merchants. Authority comes from a standing ruleset held by a third party, applied to every transaction, updated without anyone re-signing anything, and deliberately kept secret, because a published fraud rule is a published evasion guide.

Agent deployments in regulated settings have the same shape. An operator maintains screening rules. Agents are subject to those rules whether or not any relying party signed them. The rules change weekly. Publishing them defeats them.

No current standard covers this, and the two obvious workarounds both fail:

Zero knowledge resolves the tension: the verifier learns that some committed policy was correctly evaluated and returned allow, and learns nothing about the policy's contents. ERC-7812 already supplies the missing half, since it defines a registry of blinded statements whose state can be proven in zero knowledge, and it is written abstractly so that later ERCs can build specific use cases on top of it. Nothing in the agent cluster has taken it up.

This ERC is that bridge: ERC-8004 for who the agent is, ERC-7812 for what the policy commits to, and a verdict envelope in between.

Scope of the privacy claim

This standard hides the policy, not the action. A permitted action executes on a public chain and is public. The confidentiality guarantee is that no observer, including the executing agent, learns the rules that permitted it.

A denied action is never submitted and therefore never disclosed. This is a side effect of the design, not a guarantee it offers.

Specification

The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in RFC 2119 and RFC 8174.

Normative core and recommended companion

The normative core of this standard is the Verdict envelope, the actionCommitment definition, and the IConfidentialPolicyVerdict interface. Any compliant Guard MUST implement these.

A Verdict is an integrity property only: it proves the committed interpreter was evaluated over the action and returned ALLOW. It says nothing about whether the underlying policy is correct, fair, or non-malicious. Implementers MUST NOT treat a Verdict that satisfies verify or consume as evidence that the policy behind it is safe. See Security Considerations.

IPolicyDomainRegistry (below) is a RECOMMENDED companion interface for managing domains and roots. Implementations MAY substitute an equivalent mechanism, provided it exposes the semantics that the core interface's ordered checks depend on: identityRegistry for check 2, active for check 3, isRootAcceptable for check 8, and programKey for check 9. It is specified here so that independent Guards and Domains interoperate by default, not to bind the core to a single registry design.

Terminology

Hash functions

Two hash functions appear in this standard, and each MUST be used only where specified:

A proving backend that exposes an efficient keccak256 primitive can compute both cheaply — whether that is a zkVM precompile (e.g. SP1, RISC Zero) or a circuit DSL's standard-library black-box function (e.g. Noir's std::hash::keccak256, backed by Barretenberg). This standard does not mandate a proving backend; IVerifier is deliberately backend-agnostic. Implementations MUST NOT submit a keccak256 digest as an EvidenceDB value without reducing it into the field.

Verdict envelope

struct Verdict {
    uint256 agentId;           // ERC-8004 Identity Registry token id
    bytes32 domainId;          // policy domain
    bytes32 policyRoot;        // ERC-7812 EvidenceDB root the decision was made against
    bytes32 actionCommitment;  // commitment to the action being authorized
    address executor;          // the only address permitted to consume this verdict
    uint64  expiry;            // unix seconds, exclusive
    bytes32 nullifier;         // single-use, domain-scoped
    uint8   decision;          // 0 = DENY, 1 = ALLOW
    uint8   policyKind;        // which of the four states this verdict carries (see Policy kind)
}

Every field of Verdict MUST be a public input of the proving program. Implementations MUST NOT place any field of Verdict in the private witness.

Policy kind

decision is one bit, so on its own it collapses every refusal into a single "denied". policyKind keeps the four states the standard distinguishes separable at the surface a relying party actually reads:

policyKind Meaning Valid decision
0 (ALLOWED) the policy authorized the action 1
1 (DENIED) a rule fired against the action 0
2 (NOT_PERMITTED) nothing authorized the action 0
3 (COULD_NOT_EVALUATE) the policy could not be evaluated 0

policyKind MUST be a public input of the proving program, on every program, so a verdict cannot assert a kind its proof did not establish. A decision of 1 MUST carry policyKind == 0; a decision of 0 MUST carry a policyKind in {1, 2, 3}. An implementation MUST reject a verdict whose decision and policyKind disagree, before any other check that could make the disagreement unobservable.

A consumer gating an irreversible action needs this distinction: "a rule refused this" and "no rule authorized this" call for different handling, and a companion that anchors refusals MUST carry the kind through to its read surface rather than recording a generic denial.

Action commitment

actionCommitment = keccak256(
    abi.encode(
        block.chainid,
        domainId,     // bytes32, cross-domain replay separation
        agentId,
        target,       // address
        value,        // uint256
        keccak256(callData),
        actionNonce   // uint256, agent-scoped, monotonic
    )
);

The Guard MUST recompute actionCommitment from the action it is about to execute and MUST compare it to Verdict.actionCommitment. A Guard MUST NOT accept an actionCommitment supplied by the caller.

actionNonce MUST be strictly increasing per agentId within a domain. It prevents two identical actions from sharing a commitment. domainId binds the commitment to one policy domain, so a verdict minted under one domain's programKey cannot be replayed as authorization for the identical action under a different domain sharing the same Guard.

Core interface

/// @dev interfaceId is the ERC-165 XOR of this interface's own function selectors (verify,
///      verdictDigest, both consume overloads, isConsumed); inherited IERC165.supportsInterface
///      is excluded per the language rule. Value: 0xd6da8150.
interface IConfidentialPolicyVerdict is IERC165 {
    event VerdictConsumed(
        bytes32 indexed nullifier,
        uint256 indexed agentId,
        bytes32 indexed domainId,
        bytes32 policyRoot,
        bytes32 actionCommitment
    );

    error AgentUnknown(uint256 agentId);
    error VerdictExpired(uint64 expiry);
    error VerdictReplayed(bytes32 nullifier);
    error ExecutorMismatch(address expected, address actual);
    error ExecutorAuthInvalid();
    error PolicyRootRejected(bytes32 root);
    error DomainInactive(bytes32 domainId);
    error VerdictDenied();
    error VerdictKindMismatch(uint8 decision, uint8 policyKind);
    error InvalidProof();

    /// @notice Verify a verdict without state change.
    /// @dev MUST NOT revert on a well-formed but invalid verdict; returns false instead.
    /// @dev MUST return false (not revert) if `proof` is malformed. It MAY revert only on out-of-gas.
    function verify(Verdict calldata v, bytes calldata proof)
        external
        view
        returns (bool);

    /// @notice The EIP-712 digest an executor signs to authorize a relayer to submit this exact
    /// verdict on their behalf. The verdict's single-use nullifier gives the signature replay
    /// protection for free, so no separate signature nonce is needed.
    function verdictDigest(Verdict calldata v) external view returns (bytes32);

    /// @notice Verify and burn a verdict's nullifier. Direct submission: the caller must be the executor.
    /// @dev MUST revert with the specific error above on any failure.
    /// @dev MUST require v.executor == msg.sender. Implementations MUST NOT use `tx.origin`
    ///      for this check: the binding is to the executor the proof commits to, not to the
    ///      transaction's originator.
    /// @dev MUST require v.decision == 1.
    /// @dev MUST emit VerdictConsumed on success.
    function consume(Verdict calldata v, bytes calldata proof) external;

    /// @notice Verify and burn a verdict's nullifier via a relayer. `msg.sender` MAY be any
    /// address if `executorAuth` is a valid EIP-712 signature (ECDSA or ERC-1271) by v.executor
    /// over `verdictDigest(v)`. Because the action is committed and the executor is bound
    /// cryptographically, front-running the submission is neutral: any submitter causes the
    /// identical committed execution.
    /// @dev MUST revert with the specific error above on any failure.
    /// @dev MUST require v.decision == 1.
    /// @dev MUST emit VerdictConsumed on success.
    function consume(Verdict calldata v, bytes calldata proof, bytes calldata executorAuth) external;

    function isConsumed(bytes32 domainId, bytes32 nullifier)
        external
        view
        returns (bool);
}

consume MUST perform its checks in this order, and MUST revert on the first failure:

  1. v.decision and v.policyKind agree, else VerdictKindMismatch. This is first because every later check can make the disagreement unobservable.
  2. If domain(v.domainId).identityRegistry != address(0), v.agentId exists in that registry, else AgentUnknown. A domain that declares no identity registry skips this check. It is second for the same reason the first is first: a later check would make an unknown agent unobservable.
  3. domain(v.domainId).active is true, else DomainInactive.
  4. v.decision == 1, else VerdictDenied.
  5. The caller is authorized as executor: v.executor == msg.sender directly, or executorAuth is a valid EIP-712 signature by v.executor over verdictDigest(v). Else ExecutorMismatch (no signature and msg.sender != v.executor) or ExecutorAuthInvalid (a signature was given but does not validate).
  6. block.timestamp < v.expiry, else VerdictExpired.
  7. isConsumed(v.domainId, v.nullifier) is false, else VerdictReplayed.
  8. isRootAcceptable(v.domainId, v.policyRoot) is true, else PolicyRootRejected.
  9. The proof verifies against domain(v.domainId).programKey with v as public inputs, else InvalidProof. A verifier that reverts MUST surface as InvalidProof, not as the verifier's own error.

Check 5 is not optional. See Security Considerations.

verify MUST apply the same identity condition as check 2, as a boolean short-circuit: it MUST return false, rather than revert, for an agentId that does not exist in a registry the domain declares.

Domain registry interface (recommended companion)

interface IPolicyDomainRegistry {
    struct Domain {
        address registrar;        // ERC-7812 Registrar that owns this domain's statements
        address identityRegistry; // ERC-8004 Identity Registry; address(0) declares none
        address verifier;         // proof verifier for this domain's program
        bytes32 programKey;       // verification key / program commitment
        uint64  maxRootAge;       // seconds a superseded root remains acceptable
        bool    active;
    }

    event DomainRegistered(bytes32 indexed domainId, address registrar, address verifier, bytes32 programKey);
    event DomainRootUpdated(bytes32 indexed domainId, bytes32 newRoot, uint64 version, uint64 updatedAt);
    event DomainProgramUpdated(bytes32 indexed domainId, bytes32 oldProgramKey, bytes32 newProgramKey);
    event DomainIdentityRegistryUpdated(bytes32 indexed domainId, address oldRegistry, address newRegistry);
    event DomainRevoked(bytes32 indexed domainId);

    function domain(bytes32 domainId) external view returns (Domain memory);

    function currentRoot(bytes32 domainId)
        external
        view
        returns (bytes32 root, uint64 version, uint64 updatedAt);

    /// @notice A root is acceptable if it is current, or superseded less than maxRootAge ago.
    function isRootAcceptable(bytes32 domainId, bytes32 root) external view returns (bool);
}

A revoked domain MUST cause isRootAcceptable to return false for all roots immediately, with no grace window. Revocation is the emergency path and MUST NOT be subject to maxRootAge.

identityRegistry MAY be declared at registration or set later by whoever administers the domain, and a registry that permits it to change MUST emit DomainIdentityRegistryUpdated, because the field decides whether the agent-existence check applies at all.

Policy registration via ERC-7812

A Policy Domain MUST register its Policy Commitment through an ERC-7812 Registrar as a key/value statement. A Registrar MUST write each version to a distinct key and MUST NOT overwrite a historical statement, because an auditor verifying a past decision needs the ruleset that was live at that version and every proof against it keeps verifying regardless:

key   = H(domainId, version)
value = policyCommitment = H(ruleset)

where H is the field-friendly hash defined under Hash functions.

policyCommitment MUST NOT be blinded with a commitment key. It is a plain hash of the ruleset.

This is a deliberate departure from ERC-7812's blinding pattern. Blinding would make the commitment unverifiable even to a party holding the ruleset, which destroys the audit path described below. Preimage resistance alone provides the required confidentiality, because a ruleset has enough entropy to resist enumeration. Domains whose rule sets are low-entropy MUST pad them with a high-entropy salt before hashing.

Proving program contract

The program proven MUST be a policy interpreter, not a compiled policy. It takes the ruleset as private witness and the Verdict fields as public inputs, and MUST enforce:

  1. The private ruleset hashes to a policyCommitment that is included in policyRoot, verified by an ERC-7812 inclusion proof against the EvidenceDB structure under H(domainId, version).
  2. The private action preimage (chainId, target, value, callData, actionNonce), combined with the public domainId and agentId, hashes to actionCommitment under the definition above.
  3. Evaluating the ruleset over the action preimage and the agent context yields decision.
  4. nullifier is derived deterministically as H(domainId, agentId, actionCommitment, actionNonce). Because actionCommitment is a full 256-bit keccak digest and does not fit in H's underlying field on its own, implementations MUST reduce it into two field-sized limbs (e.g. high/low 128-bit halves) before hashing, consistent with the reduction rule in Hash functions.

The program MUST NOT accept decision as an input to be attested. It MUST compute it.

Changing a ruleset MUST NOT change programKey. See Rationale.

Composition with ERC-8004

A domain MAY declare the ERC-8004 Identity Registry its agent ids live in, as Domain.identityRegistry. Where a domain declares one, agentId MUST be a valid token id in that registry, and a Guard MUST reject a verdict whose agentId is not, with AgentUnknown. Existence is an ERC-721 ownership read: a Guard MUST treat agentId as existing if and only if ownerOf(agentId) on the declared registry returns a non-zero address without reverting, and MUST treat a reverting registry as an absent agent rather than propagating its error. A declared address holding no code names no agents, so a Guard MUST reject against it with AgentUnknown rather than reverting without data.

Domain.identityRegistry == address(0) declares no registry. The Guard then performs no existence check, and agentId is an opaque public input this standard binds cryptographically but does not resolve. This is the conditional form of the requirement rather than a universal one because the registry a Guard reads is the one on the chain where the verdict is consumed, cross-chain identity resolution is out of scope here and is deferred to a future ERC, and this standard's hub-and-spoke design (see Rationale) expects verdicts to be consumed on spoke chains where an Identity Registry need not be deployed at all. A domain that declares a registry gets the enforced binding; a domain that cannot gets a standard that stays usable and does not silently claim an identity check it never made.

A Guard SHOULD, after a successful consume, write an attestation to the ERC-8004 Validation Registry recording that agentId satisfied domainId at policyRoot. This exposes the fact of compliance to the public reputation layer while disclosing nothing about the policy. The attestation payload MUST NOT contain the ruleset or any part of it, and SHOULD have the following shape:

struct VerdictAttestation {
    uint256 agentId;      // ERC-8004 Identity Registry token id
    bytes32 artifactHash; // == Verdict.actionCommitment
    bytes32 policyRoot;   // committed (undisclosed) policy the decision used
    bytes32 domainId;
    bytes32 nullifier;    // single-use verdict id
    uint8   decision;     // 1 = ALLOW
    bytes32 mechanism;    // source-class tag; this standard uses keccak256("zk-secret-policy")
    uint64  expiry;
}

Two fields make the attestation composable and unambiguous in a shared registry:

Guard reference behaviour

interface IPolicyGuarded {
    function policyDomain() external view returns (bytes32);
}

A guarded contract MUST expose policyDomain() and MUST call consume before dispatching the action. It MUST revert the whole transaction if consume reverts.

Rationale

Why a fixed interpreter rather than a circuit compiled per policy

This is the load-bearing choice.

If the policy is compiled into a circuit, every policy update produces a new verification key, which means a new verifier deployment, on every chain, for every rule change. For a ruleset that changes weekly this is not an operational inconvenience, it is a disqualification.

By proving a fixed interpreter and passing the ruleset as witness, programKey stays constant across policy updates. Only the ERC-7812 root moves. This is why the specification insists the program be an interpreter, and it is why the standard is realistic to operate at all. The interpreter can be written for a zkVM (a fixed guest program, ruleset as data) or directly in a circuit DSL (a fixed circuit that takes the ruleset as private witness, as the reference implementation's Noir circuit does); either way, programKey is a commitment to the interpreter, not to any one ruleset.

Whether keccak256 (used for actionCommitment, matching the EVM's native hash) is cheap to compute in-circuit depends on the backend, not on the zkVM-vs-DSL choice itself: a zkVM precompile and a circuit DSL's black-box keccak256 (e.g. Noir/Barretenberg, used by the reference implementation) are both efficient; a backend without such a primitive would push a domain toward computing keccak256 the hard way or toward a field-friendly-hash-only design.

The cost is asymmetric: the interpreter proves a superset of any single policy, so proofs are more expensive than a bespoke circuit for the same rule. That is the correct trade. Proving cost is paid off-chain by the domain, verification key churn is paid on-chain by everyone.

Why the registry is a companion, not the standard

The reusable, chain-agnostic contribution is the verdict envelope and its verification. Domain and root management is deployment policy, and reasonable operators will differ (a single hub, a per-tenant registry, an existing access-control system). Binding the core to one registry would force those operators to fork the standard. So IPolicyDomainRegistry is specified as the default that makes independent implementations interoperate, while the core interface depends only on the three semantic hooks it actually calls.

Why ERC-7812 rather than a new registry

ERC-7812 is deployed on Mainnet and Sepolia at a deterministic address and was designed as a singleton specifically so that only a single bytes32 root needs to cross chains to prove registry state. A hub-and-spoke policy deployment needs exactly that and nothing more. It also states that it was written abstractly to let subsequent ERCs build specific use cases on top. This is one. Defining a parallel registry would fragment the trust anchor for no benefit.

Why commitments are not blinded

A blinded commitment is confidential and not auditable, which is a worse trade than preimage resistance alone. The non-blinded commitment is what enables selective disclosure to auditors (see Security Considerations).

Why single-use nullifiers rather than a signature

A signature over a verdict can be replayed by anyone who observes it. The nullifier plus executor binding makes a verdict a bearer instrument with exactly one bearer and exactly one use.

Why decision exists at all if only ALLOW is consumable

consume rejects DENY, but the envelope carries decision because a DENY verdict is a useful off-chain artifact: it is evidence an agent can present to its operator, or an operator to an auditor, that a specific action was refused under a specific root. Keeping one envelope for both avoids a second format. Because decision is computed in-circuit (never attested as an input), carrying it on-chain costs one byte and grants no authority a DENY could abuse.

Backwards Compatibility

No backwards compatibility issues. This standard is purely additive and introduces no changes to existing interfaces. Contracts that do not implement IPolicyGuarded are unaffected.

Verdicts are inert without a Guard. Deploying the registry and verifier does not alter the behaviour of any existing agent.

Test Cases

A conformant implementation passes at least the following, expressed against consume unless noted. Each case below is excerpted, unmodified, from the Foundry suite at ConfidentialPolicyVerdict.t.sol, which runs against the shared fixture:

PolicyDomainRegistry registry;
ConfidentialPolicyVerdict guard;
MockVerifier verifier;

bytes32 constant DOMAIN = keccak256("acme-compliance");
bytes32 constant ROOT = keccak256("root-v1");
bytes32 constant PROGRAM = keccak256("interpreter-vkey");
address constant EXECUTOR = address(0xE0);

function setUp() public {
    vm.warp(1_700_000_000);
    registry = new PolicyDomainRegistry();
    verifier = new MockVerifier();
    guard = new ConfidentialPolicyVerdict(registry);
    registry.registerDomain(DOMAIN, address(0xA11CE), address(verifier), PROGRAM, 1 hours);
    registry.updateRoot(DOMAIN, ROOT);
}

function _verdict() internal view returns (Verdict memory v) {
    v = Verdict({
        agentId: 1,
        domainId: DOMAIN,
        policyRoot: ROOT,
        actionCommitment: keccak256("action"),
        executor: EXECUTOR,
        expiry: uint64(block.timestamp + 1 hours),
        nullifier: keccak256("nf-1"),
        decision: 1,
        policyKind: PolicyKind.ALLOWED
    });
}
  1. Happy path -- a valid ALLOW verdict from the current root, submitted by v.executor, succeeds, burns the nullifier.

solidity function test_HappyPath() public { Verdict memory v = _verdict(); vm.prank(EXECUTOR); guard.consume(v, "proof"); assertTrue(guard.isConsumed(DOMAIN, v.nullifier)); }

  1. Replay -- re-submitting a consumed verdict reverts VerdictReplayed.

solidity function test_Replay() public { Verdict memory v = _verdict(); vm.startPrank(EXECUTOR); guard.consume(v, "proof"); vm.expectRevert(abi.encodeWithSelector(IConfidentialPolicyVerdict.VerdictReplayed.selector, v.nullifier)); guard.consume(v, "proof"); vm.stopPrank(); }

  1. Expiry -- block.timestamp >= v.expiry reverts VerdictExpired.

solidity function test_Expired() public { Verdict memory v = _verdict(); vm.warp(v.expiry); // block.timestamp >= expiry vm.prank(EXECUTOR); vm.expectRevert(abi.encodeWithSelector(IConfidentialPolicyVerdict.VerdictExpired.selector, v.expiry)); guard.consume(v, "proof"); }

  1. Executor authorization -- direct submission by any address other than v.executor, with no executorAuth, reverts ExecutorMismatch; a relayed submission with an invalid signature reverts ExecutorAuthInvalid; both even with an otherwise valid proof.

```solidity function test_ExecutorMismatch() public { Verdict memory v = _verdict(); vm.prank(address(0xBAD)); vm.expectRevert( abi.encodeWithSelector(IConfidentialPolicyVerdict.ExecutorMismatch.selector, EXECUTOR, address(0xBAD)) ); guard.consume(v, "proof"); }

function test_RelayedConsumeBadSignature() public { uint256 pk = 0xA11CE; Verdict memory v = _verdict(); v.executor = vm.addr(pk);

   (uint8 sv, bytes32 sr, bytes32 ss) = vm.sign(uint256(0xB0B), guard.verdictDigest(v)); // wrong key
   bytes memory sig = abi.encodePacked(sr, ss, sv);

   vm.prank(address(0xBEEF));
   vm.expectRevert(IConfidentialPolicyVerdict.ExecutorAuthInvalid.selector);
   guard.consume(v, "proof", sig);

} ```

  1. DENY not consumable -- a well-formed refusal, decision == 0 carrying a refusal kind, reverts VerdictDenied.

solidity function test_DenyNotConsumable() public { Verdict memory v = _verdict(); v.decision = 0; v.policyKind = PolicyKind.DENIED; // a well-formed refusal, not a malformed envelope vm.prank(EXECUTOR); vm.expectRevert(IConfidentialPolicyVerdict.VerdictDenied.selector); guard.consume(v, "proof"); }

  1. Decision / kind disagreement -- a verdict whose decision and policyKind disagree is refused by ordered check 1, in both directions, and before any check that could mask it:

```solidity function test_DecisionKindMismatchRefused() public { Verdict memory v = _verdict(); // decision 1, kind ALLOWED v.decision = 0; // claims a refusal while still carrying the ALLOWED kind vm.prank(EXECUTOR); vm.expectRevert( abi.encodeWithSelector(IConfidentialPolicyVerdict.VerdictKindMismatch.selector, uint8(0), PolicyKind.ALLOWED) ); guard.consume(v, "proof"); }

function test_KindMismatchBeatsInactiveDomain() public { registry.revokeDomain(DOMAIN); Verdict memory v = _verdict(); v.policyKind = PolicyKind.NOT_PERMITTED; vm.prank(EXECUTOR); vm.expectRevert( abi.encodeWithSelector( IConfidentialPolicyVerdict.VerdictKindMismatch.selector, uint8(1), PolicyKind.NOT_PERMITTED ) ); guard.consume(v, "proof"); } ```

  1. Action binding -- a verdict whose actionCommitment does not match the action a guarded contract is about to execute reverts at the guarded contract, before consume is ever called:

```solidity function test_GuardedExecutorCommitmentMismatch() public { GuardedExecutor gx = new GuardedExecutor(guard, DOMAIN); Sink sink = new Sink(); bytes memory cd = abi.encodeWithSignature("ping()");

   Verdict memory v = _verdict();
   v.executor = address(gx);
   v.actionCommitment = bytes32(uint256(1)); // wrong
   bytes32 expected = gx.actionCommitmentOf(v.agentId, address(sink), 0, cd);

   vm.expectRevert(
       abi.encodeWithSelector(GuardedExecutor.ActionCommitmentMismatch.selector, expected, v.actionCommitment)
   );
   gx.execute(v, "proof", "", address(sink), 0, cd);

} ```

  1. Cross-chain / cross-domain replay -- given identical (agentId, target, value, callData, actionNonce), the commitment computed with chainId = 1 differs from the one computed with chainId = 2, and the commitment computed under one domainId differs from the one computed under another, because PolicyAction.commit carries both as leading fields of the preimage. A verdict minted for one chain or domain therefore never matches the commitment a guarded contract recomputes on the other.

```solidity function test_CrossChainAndCrossDomainCommitmentsDiffer() public pure { PolicyAction memory a = PolicyAction({ chainId: 1, domainId: DOMAIN, agentId: 1, target: address(0x51E), value: 0, callDataHash: keccak256(abi.encodeWithSignature("ping()")), actionNonce: 0 }); bytes32 onChainOne = PolicyActionLib.commit(a);

   a.chainId = 2; // same action, different chain
   assertTrue(PolicyActionLib.commit(a) != onChainOne, "chainId must separate the commitment");

   a.chainId = 1;
   a.domainId = keccak256("other-compliance"); // same action, different policy domain
   assertTrue(PolicyActionLib.commit(a) != onChainOne, "domainId must separate the commitment");

} ```

  1. Stale-root grace -- a verdict against a root superseded less than maxRootAge ago succeeds; one older than maxRootAge reverts PolicyRootRejected.

```solidity function test_StaleRootGraceThenReject() public { Verdict memory v = _verdict(); // against ROOT registry.updateRoot(DOMAIN, keccak256("root-v2")); // ROOT becomes previous vm.prank(EXECUTOR); guard.consume(v, "proof"); // within grace → ok

   vm.warp(block.timestamp + 2 hours); // past maxRootAge
   Verdict memory v2 = _verdict(); // built after warp → fresh expiry, still points at old ROOT
   v2.nullifier = keccak256("nf-2");
   vm.prank(EXECUTOR);
   vm.expectRevert(abi.encodeWithSelector(IConfidentialPolicyVerdict.PolicyRootRejected.selector, ROOT));
   guard.consume(v2, "proof");

} ```

  1. Revocation -- after DomainRevoked, every verdict against the domain reverts DomainInactive immediately, with no grace window.

    solidity function test_RevocationImmediate() public { registry.revokeDomain(DOMAIN); Verdict memory v = _verdict(); vm.prank(EXECUTOR); vm.expectRevert(abi.encodeWithSelector(IConfidentialPolicyVerdict.DomainInactive.selector, DOMAIN)); guard.consume(v, "proof"); }

  2. Malformed proof -- verify returns false (does not revert) for malformed proof bytes.

    solidity function test_VerifyMalformedReturnsFalse() public { verifier.setRevert(true); Verdict memory v = _verdict(); assertFalse(guard.verify(v, "garbage")); }

  3. ERC-8004 identity binding -- when the domain declares an Identity Registry, an agentId that does not exist there is refused by ordered check 2, ahead of anything that could mask it, and verify returns false on the same condition. When the domain declares none, the same agentId is consumable, because the check is conditional on the declaration.

    ```solidity function test_UnknownAgentRefusedWhenDomainDeclaresIdentityRegistry() public { MockIdentityRegistry identity = new MockIdentityRegistry(); identity.register(1, address(0xA6E7)); // agent 1 exists; agent 2 was never registered registry.setIdentityRegistry(DOMAIN, address(identity));

    Verdict memory unknown = _verdict();
    unknown.agentId = 2;
    assertFalse(guard.verify(unknown, "proof"), "verify must refuse an unknown agent");
    vm.prank(EXECUTOR);
    vm.expectRevert(abi.encodeWithSelector(IConfidentialPolicyVerdict.AgentUnknown.selector, uint256(2)));
    guard.consume(unknown, "proof");
    
    // The registered agent is unaffected.
    Verdict memory known = _verdict(); // agentId 1
    vm.prank(EXECUTOR);
    guard.consume(known, "proof");
    assertTrue(guard.isConsumed(DOMAIN, known.nullifier));
    

    }

    function test_AgentUnresolvedWhenDomainDeclaresNoIdentityRegistry() public { assertEq(registry.domain(DOMAIN).identityRegistry, address(0), "fixture declares no registry"); Verdict memory v = _verdict(); v.agentId = 999_999; // no registry anywhere minted this id vm.prank(EXECUTOR); guard.consume(v, "proof"); assertTrue(guard.isConsumed(DOMAIN, v.nullifier)); } ```

  4. Generation-agnostic root grace -- two rotations inside maxRootAge keep every superseded root acceptable until its own window closes. Each retained root is measured against the moment it stopped being current, not against the current root's timestamp, so roots age out on separate schedules.

    ```solidity function test_TwoRapidRotationsKeepEveryRootInsideItsOwnWindow() public { uint256 t0 = block.timestamp; // ROOT became current here, maxRootAge is 1 hour

    vm.warp(t0 + 10 minutes);
    registry.updateRoot(DOMAIN, keccak256("root-v2")); // ROOT superseded at t0 + 10m
    vm.warp(t0 + 20 minutes);
    registry.updateRoot(DOMAIN, keccak256("root-v3")); // root-v2 superseded at t0 + 20m
    
    // ROOT was superseded 10 minutes ago. It is two generations back, but still inside
    // its own grace window, so it is still acceptable.
    assertTrue(registry.isRootAcceptable(DOMAIN, ROOT), "ROOT is inside its own maxRootAge");
    assertTrue(registry.isRootAcceptable(DOMAIN, keccak256("root-v2")), "root-v2 is inside its own window");
    
    // And it is acceptable end to end, through the guard.
    Verdict memory v = _verdict(); // built after the warp, so expiry is fresh; still points at ROOT
    vm.prank(EXECUTOR);
    guard.consume(v, "proof");
    assertTrue(guard.isConsumed(DOMAIN, v.nullifier));
    
    // One second past ROOT's own window, ROOT is rejected while root-v2 — superseded
    // 10 minutes later — is still inside its own.
    vm.warp(t0 + 10 minutes + 1 hours);
    assertFalse(registry.isRootAcceptable(DOMAIN, ROOT), "ROOT is past its own maxRootAge");
    assertTrue(registry.isRootAcceptable(DOMAIN, keccak256("root-v2")), "root-v2 has 10 more minutes");
    
    Verdict memory v2 = _verdict();
    v2.nullifier = keccak256("nf-2");
    vm.prank(EXECUTOR);
    vm.expectRevert(abi.encodeWithSelector(IConfidentialPolicyVerdict.PolicyRootRejected.selector, ROOT));
    guard.consume(v2, "proof");
    

    } ```

The suite additionally covers the relayed-consume happy path, supportsInterface, and the ERC-8004 attestation handoff; see the full file for those.

Reference Implementation

A reference implementation is provided alongside this proposal (CC0), implemented and tested with Foundry and Noir. The Test Cases above, the relayed-consume path, supportsInterface, and the ERC-8004 attestation handoff all run as an executable suite:

Verdict envelope

The Verdict struct and the IConfidentialPolicyVerdict interface together form the normative core, and the implementation carries the exact interface described in the Specification, including the EIP-712 relayed-consume path:

struct Verdict {
    uint256 agentId;
    bytes32 domainId;
    bytes32 policyRoot;
    bytes32 actionCommitment;
    address executor;
    uint64  expiry;
    bytes32 nullifier;
    uint8   decision;
    uint8   policyKind;
}

interface IConfidentialPolicyVerdict is IERC165 {
    function verify(Verdict calldata v, bytes calldata proof) external view returns (bool);
    function verdictDigest(Verdict calldata v) external view returns (bytes32);
    function consume(Verdict calldata v, bytes calldata proof) external;
    function consume(Verdict calldata v, bytes calldata proof, bytes calldata executorAuth) external;
    function isConsumed(bytes32 domainId, bytes32 nullifier) external view returns (bool);
}

Action commitment

The canonical PolicyAction struct and PolicyActionLib library define the commitment preimage that BOTH the on-chain guarded contract and the proving program hash with keccak256 over the identical field ordering:

struct PolicyAction {
    uint256 chainId;
    bytes32 domainId;
    uint256 agentId;
    address target;
    uint256 value;
    bytes32 callDataHash;
    uint256 actionNonce;
}

library PolicyActionLib {
    function commit(PolicyAction memory a) internal pure returns (bytes32) {
        return keccak256(
            abi.encode(a.chainId, a.domainId, a.agentId, a.target, a.value, a.callDataHash, a.actionNonce)
        );
    }
}

Guarded consumer

GuardedExecutor.sol recomputes the canonical commitment, consumes the verdict, and executes. The executor question is resolved cryptographically: pass executorAuth = "" for direct submission (v.executor == this), or pass an EIP-712 signature by v.executor for relayed submission. The excerpt below elides the custom errors and the nonce lookup helper present in the full contract:

contract GuardedExecutor is IPolicyGuarded {
    IConfidentialPolicyVerdict public immutable guard;
    bytes32 public immutable domainId;
    mapping(uint256 => uint256) public actionNonce;

    function execute(
        Verdict calldata v,
        bytes calldata proof,
        bytes calldata executorAuth,
        address target,
        uint256 value,
        bytes calldata callData
    ) external returns (bytes memory) {
        bytes32 expected = PolicyAction({
            chainId: block.chainid, domainId: domainId, agentId: v.agentId,
            target: target, value: value,
            callDataHash: keccak256(callData), actionNonce: actionNonce[v.agentId]
        }).commit();
        require(expected == v.actionCommitment, "wrong action");
        actionNonce[v.agentId] += 1;
        guard.consume(v, proof, executorAuth);
        (bool ok, bytes memory ret) = target.call{value: value}(callData);
        require(ok, "call failed");
        return ret;
    }
}

Writing the attestation to the Validation Registry

After a successful consume, a guarded contract can write an attestation to the ERC-8004 Validation Registry. The VerdictAttestation struct and PolicyAttestation library produce the canonical payload:

struct VerdictAttestation {
    uint256 agentId;
    bytes32 artifactHash; // == Verdict.actionCommitment
    bytes32 policyRoot;
    bytes32 domainId;
    bytes32 nullifier;
    uint8   decision;
    bytes32 mechanism;    // keccak256("zk-secret-policy")
    uint64  expiry;
}

library PolicyAttestation {
    bytes32 internal constant MECHANISM_ZK_SECRET_POLICY = keccak256("zk-secret-policy");
    function attestationFor(Verdict memory v) internal pure returns (VerdictAttestation memory) { ... }
}

Security Considerations

Executor binding is mandatory

Off-chain proof generation is decoupled from on-chain submission, so a verdict transits a public mempool before it lands. Without executor as a public input, any observer can lift a valid verdict from a pending transaction and front-run its consumption. The binding must be enforced inside the proving program, as a public input the proof commits to. Checking msg.sender in the Guard alone is insufficient, because a Guard is not the only possible consumer of a proof whose public inputs omit the executor. The Specification forbids using tx.origin for this check.

Relayed submission (a signature by v.executor authorizing an arbitrary msg.sender, see Specification) does not weaken this: the proof still commits v.executor as a public input, so front-running the relay only changes who pays gas, not what executes or on whose authority.

Identity binding is conditional

Domain.identityRegistry is what makes the ERC-8004 side of the composition enforceable. A domain that declares one gets an agent-existence check on every consume. A domain that declares none gets no such check, and agentId is then only as meaningful as the proof that binds it: a verdict can name an id no registry ever issued, and a VerdictAttestation written after it lands in the public reputation layer keyed on an identity that does not resolve. Relying parties reading attestations are advised to read the issuing domain's identityRegistry before treating agentId as a resolvable identity, and domains on a chain that hosts an Identity Registry are encouraged to declare it. The check is conditional rather than universal because this standard's hub-and-spoke design expects verdicts to be consumed on spoke chains where no Identity Registry need be deployed, and cross-chain identity resolution is deferred to a future ERC. An obligation that the design's own target chains cannot discharge would be enforced nowhere.

A declared registry is trusted at the level of the domain that declared it, and the domain's admin can rotate it. Implementers are advised to monitor DomainIdentityRegistryUpdated for the same reason they monitor DomainProgramUpdated.

Root staleness is a revocation delay

maxRootAge exists because a spoke chain's mirrored root lags the hub. Setting it to zero makes verdicts unusable during any sync gap. Setting it high means a rule removed from the policy keeps authorizing actions for maxRootAge seconds on every spoke. Domains are best served treating maxRootAge as their revocation service-level agreement and documenting it. Domains handling adversarial agents do well to keep it under one block time on the slowest spoke, and accept the liveness cost. DomainRevoked is the escape hatch and bypasses the window entirely. It is coarse by design, stopping the whole domain rather than one rule.

Zero knowledge proves execution, not judgment

A valid proof establishes that the committed ruleset was evaluated faithfully. It establishes nothing about whether the ruleset is correct, fair, or non-malicious. A domain that commits to always allow produces proofs that verify. A verdict is not a safety property (see Specification); it is an integrity property over a policy whose merit is a separate question, answered socially rather than cryptographically.

Action-level integrity and interpreter-level fidelity are distinct

There are two guarantees an implementer might want, and this ERC carries only the first. Action-level integrity is that the interpreter committed at programKey was evaluated over this action and returned ALLOW, bound to agentId, policyRoot, actionCommitment, executor, and a single-use nullifier. This is what a verdict proves. Interpreter-level fidelity is that this interpreter actually implements the policy the domain intends. This ERC does not prove it. The two look identical on-chain, which is the trap: a deviant interpreter is faithful to itself, applying 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, because each produces valid proofs against its own policyRoot. Fidelity is therefore established out of band. Implementers that need it can publish the interpreter's provenance, its specification commit, implementation commit, review method, and lineage, in a companion registry that is content-addressed to the interpreter hash and append-only in the ERC-7812 pattern, kept beside this standard rather than inside it for the same reason the domain registry is a companion. Such a record makes a claimed lineage permanent, signed, and attributable. It does not make it true. A fabricated ancestor stays possible, only visible and imputable to a name. A fidelity record is best read as a contestable, content-addressed assertion, not as a proof.

Confidentiality and accountability are in tension

A rejected agent cannot see why it was rejected, and cannot tell a correct application of a harsh rule from an incorrect application of a fair one. This is inherent, and it is a real cost of the design rather than an implementation gap. The non-blinded commitment is the mitigation: a domain can disclose its ruleset to a specific party out of band, and that party can verify it hashes to the commitment that was live at a given root and version. This yields selective disclosure to auditors and regulators without public disclosure. Domains are encouraged to publish their disclosure policy: who can compel the ruleset, on what grounds, and within what period. A domain that commits to a ruleset it will disclose to nobody should be treated by implementers as an unaccountable oracle with extra steps.

Version pinning across the disclosure path

Because the commitment key is H(domainId, version), an auditor verifying a historical decision needs the ruleset that was live at that version, not the current one. The Specification requires Registrars to write each version to a distinct key and never overwrite a historical statement. Overwriting destroys the audit trail while leaving every proof still verifying.

Action commitment collisions

actionCommitment binds block.chainid, so a verdict for one chain cannot be replayed on another. It binds domainId, so a verdict minted under one policy domain cannot be replayed as authorization for the identical action under another domain sharing the same Guard. It binds actionNonce, so two identical actions do not collide. Omitting any of these fields reintroduces replay across chains, across domains, or across repeats, which is why the Specification requires all of them.

Nullifier derivation must be in-circuit

If the nullifier is supplied rather than derived, a domain can mint many nullifiers for one action, defeating single use. The Specification requires the program to compute it from (domainId, agentId, actionCommitment, actionNonce), reducing the 256-bit actionCommitment into field-sized limbs first.

Verifier key rotation

DomainProgramUpdated allows a domain to fix a bug in its interpreter. It also allows a domain to silently swap the semantics of every future verdict. Implementers are advised to monitor DomainProgramUpdated and to treat an unannounced rotation the same as a revocation.

Liveness

An agent cannot act if the domain's policy engine is offline. This standard makes the policy engine a hard dependency in the execution path. Domains benefit from issuing verdicts with expiration times long enough to survive short outages, at the cost that a long expiry widens the window in which a since-revoked permission remains usable. There is no configuration that avoids both.

Dependency maturity

This ERC requires ERC-7812 and ERC-8004, both recent and pre-Final at the time of writing. A Standards Track ERC can sit in Draft or Review atop pre-Final dependencies, but it cannot advance to Final until they do. Implementers are advised to pin the exact versions of the dependencies they rely on.

Copyright

Copyright and related rights waived via CC0.