---
eip: 8354
title: Confidential Agent Policy Verdicts
description: A pre-execution allow/deny interface for agent actions, proven in zero knowledge against a policy never disclosed on-chain.
author: Muhammad Zidan Fatonie (@mzf11125), Faisal Firdani (@zexoverz), Maulana Asykari Muhammad (@WeissCurry)
discussions-to: https://ethereum-magicians.org/t/erc-8354-confidential-agent-policy-verdicts/29088
status: Draft
type: Standards Track
category: ERC
created: 2026-07-16
requires: 165, 712, 721, 1271, 7812, 8004
---

## 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](./eip-7812.md) 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](./eip-8004.md) agent identity, the [ERC-7812](./eip-7812.md) 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](./eip-8004.md) 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:

- **Put the policy on-chain.** This discloses the ruleset to the adversaries it exists to stop, and leaks the operator's commercial and compliance posture.
- **Trust an off-chain oracle to say "allowed".** This gives no evidence that any policy was applied, so the verdict is indistinguishable from an arbitrary signature.

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](./eip-7812.md) 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](./eip-8004.md) for who the agent is, [ERC-7812](./eip-7812.md) 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

- **Policy Domain**: an entity that maintains a ruleset and operates a policy engine. Identified by a `domainId`.
- **Ruleset**: the private policy. Never published on-chain.
- **Policy Commitment**: a hash of the ruleset, registered as a statement in the [ERC-7812](./eip-7812.md) Evidence Registry through a Registrar.
- **Policy Root**: the [ERC-7812](./eip-7812.md) `EvidenceDB` SMT root containing the current Policy Commitment.
- **Verdict**: a proof-attested assertion that a specific action, by a specific agent, under a specific Policy Root, is permitted.
- **Guard**: the contract that consumes a Verdict and gates execution.

### Hash functions

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

- `actionCommitment` and the on-chain event/struct hashing MUST use **keccak256**, so a Guard recomputes the same digest the EVM produces natively.
- The [ERC-7812](./eip-7812.md) statement key/value, the policy-inclusion proof, and the `nullifier` MUST use the field-friendly hash `H` of the target `EvidenceDB`. Because ERC-7812 requires keys and values to fit the underlying curve's prime field and RECOMMENDS a ZK-friendly hash, `H` is **Poseidon** for the deployed singleton registry.

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

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

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

```solidity
/// @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](./eip-712.md) 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)

```solidity
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](./eip-7812.md) 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](./eip-7812.md)'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](./eip-7812.md) 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](./eip-8004.md) 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](./eip-721.md) 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](./eip-8004.md) 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:

```solidity
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:

- `artifactHash` is a **content-addressed reference to the specific action judged**, not a class of actions. It is exactly `Verdict.actionCommitment` (the canonical `PolicyAction` hash), so a consumer can confirm the attestation is about one concrete action. Implementations already carry this value. They MUST surface it in the attestation.
- `mechanism` is a **source-class tag describing how the verdict was reached** (self-attested, independent-mediator, ZK-against-secret-policy, `public-recomputable`). Verdicts of structurally different guarantees written into one registry without this tag can be silently conflated into a single pass/fail signal downstream. A Guard for this standard MUST set `mechanism` to `keccak256("zk-secret-policy")`.

### Guard reference behaviour

```solidity
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](./eip-7812.md) 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](./eip-7812.md) 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`](../assets/eip-8354/test/ConfidentialPolicyVerdict.t.sol), which runs against the shared fixture:

```solidity
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));
   }
   ```

2. **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();
   }
   ```

3. **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");
   }
   ```

4. **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);
   }
   ```

5. **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");
   }
   ```

6. **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");
   }
   ```

7. **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);
   }
   ```

8. **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`](../assets/eip-8354/src/PolicyAction.sol) 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");
   }
   ```

9. **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");
   }
   ```

10. **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");
    }
    ```

11. **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"));
    }
    ```

12. **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));
    }
    ```

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

- [`ConfidentialPolicyVerdict.sol`](../assets/eip-8354/src/ConfidentialPolicyVerdict.sol) -- the Guard (`verify` / both `consume` overloads / `isConsumed`), checks in the order specified, and [`PolicyDomainRegistry.sol`](../assets/eip-8354/src/PolicyDomainRegistry.sol), the companion registry (root rotation with `maxRootAge` grace, immediate revocation). It measures each superseded root's grace against the moment that root stopped being current, not against the current root's timestamp, so rotating twice inside one window does not drop the older generation. It retains eight superseded generations per domain, which bounds both its storage and the scan `isRootAcceptable` performs; a domain that rotates more often than that inside its own window loses its oldest roots early, rejected sooner than `maxRootAge` rather than later, so the bound fails closed. A registry that wants the rule without approximation can retain more.
- [`IIdentityRegistry.sol`](../assets/eip-8354/src/IIdentityRegistry.sol) -- the single [ERC-8004](./eip-8004.md) Identity Registry call the Guard makes, an [ERC-721](./eip-721.md) `ownerOf` read, declared minimally rather than imported, with [`MockIdentityRegistry.sol`](../assets/eip-8354/src/mocks/MockIdentityRegistry.sol) as the test double for the conditional agent-existence check.
- A proving-system-agnostic [`IVerifier.sol`](../assets/eip-8354/src/IVerifier.sol) boundary (`verifyProof(programKey, publicInputs, proof)`), with [`MockVerifier.sol`](../assets/eip-8354/src/mocks/MockVerifier.sol) as the test double the Guard suite runs against.
- [`PolicyAction.sol`](../assets/eip-8354/src/PolicyAction.sol) -- the canonical, chain- and domain-separated action-commitment preimage, hashed identically on-chain and in-circuit.
- Cryptographic executor binding: direct submission, or an [EIP-712](./eip-712.md) signed relay verified via `SignatureChecker` (ECDSA and [ERC-1271](./eip-1271.md)) -- see `verdictDigest` and the second `consume` overload in the Specification.
- [`GuardedExecutor.sol`](../assets/eip-8354/src/GuardedExecutor.sol) -- an example guarded contract that recomputes the canonical commitment before dispatching the action.
- [`IPolicyAttestation.sol`](../assets/eip-8354/src/IPolicyAttestation.sol) -- the `VerdictAttestation` payload, with [`MockValidationRegistry.sol`](../assets/eip-8354/src/mocks/MockValidationRegistry.sol) as the ERC-8004 handoff's test double.
- [ERC-165](./eip-165.md) support. `IConfidentialPolicyVerdict` `interfaceId` is `0xd6da8150`.
- An interpreter circuit written in [Noir](../assets/eip-8354/circuits/src/main.nr), proven with Barretenberg/UltraHonk (`nargo test` passes). It enforces the action-commitment, nullifier, and in-circuit executor-binding obligations from **Proving program contract**.

<!-- Still in progress, in the reference circuit only: its policy-membership check is a
     directly-computed allowlist Merkle root rather than an ERC-7812 `EvidenceDB` inclusion proof of
     a `policyCommitment`, and its in-circuit hashing (the allowlist tree and the nullifier) uses
     Pedersen rather than the Poseidon this specification names for the deployed ERC-7812 registry
     (see **Hash functions**). Wiring the circuit to the real ERC-7812 root and aligning its hash
     choice are open work on this particular interpreter, not a difference in design intent. -->

### Verdict envelope

The [`Verdict`](../assets/eip-8354/src/IConfidentialPolicyVerdict.sol) 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](./eip-712.md) relayed-consume path:

```solidity
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`](../assets/eip-8354/src/PolicyAction.sol) 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:

```solidity
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`](../assets/eip-8354/src/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](./eip-712.md) signature by `v.executor` for relayed submission. The excerpt below elides the custom errors and the nonce lookup helper present in the full contract:

```solidity
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`](../assets/eip-8354/src/IPolicyAttestation.sol) struct and `PolicyAttestation` library produce the canonical payload:

```solidity
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](./eip-7812.md) and [ERC-8004](./eip-8004.md), 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](../LICENSE.md).
