ERC-8366: Zero-Knowledge Spending Policies

,

(Updated Aug 5: revised per implementation feedback and the discussion below; changelog in the replies. Canonical text: Add ERC: Zero-Knowledge Spending Policies by junbeomlee · Pull Request #1929 · ethereum/ERCs · GitHub)

Abstract

This ERC standardizes zero-knowledge spending policies: a composable function set that any contract holding a user’s funds (a dedicated escrow, a smart wallet, an ERC-4337 account, or an EIP-7702-delegated EOA) can implement to release those funds only against a zero-knowledge proof that the payment satisfies a spending policy the owner registered in advance. A policy is registered once as a commitment, so its parameters (for example a price cap) can stay private. The proof travels as the implementing contract’s signature: any spender, contract or EOA, that presents a payment-authorization digest to its ERC-1271 isValidSignature receives the magic value exactly when the accompanying proof satisfies the registered policy. On the settlement rail, the implementing contract is the payer. The core of this standard is the policy check itself, verifyPolicy, together with the registration semantics that make a policy single-use; on signature-carrying rails an ERC-1271 adapter exposes that same check as the contract’s signature validation. Rails that already route contract signatures, such as ERC-3009 with the ERC-7598 bytes overload (and HTTP payment flows such as x402 above them), can spend from a conforming contract with no changes, but they are instantiations, not the standard.

Motivation

Autonomous agents are becoming payment clients, but sending money was never the hard part of agent payments; trusting an agent to spend it is. Today an operator picks one of two bad options: a human approves each payment (safe, but the human is the bottleneck and the autonomy is gone), or the agent holds a key or a blanket allowance (autonomous, but unbounded: one bug or prompt injection and the funds can go anywhere, for anything).

What is missing is delegation by constraint: the owner states what a payment must satisfy, the agent acts freely within that envelope, and the settlement layer itself refuses anything outside it. Concretely, this standard targets the property one policy = one payment: each registered policy authorizes exactly one settlement, so worst-case damage is capped at a single pre-authorized payment.

Existing standards do not provide this:

  • ERC-7715 and ERC-7710 (wallet permissions, session keys) enforce spending limits in the account, but the policy is public, is evaluated against on-chain state only, and cannot bind off-chain facts such as a merchant-signed quote.
  • ERC-8183 standardizes escrow for agent jobs (release on delivery evaluation), not a spending policy on the payer.
  • ERC-8004 is a trust and discovery layer and explicitly excludes payments.
  • Among proposals still under review: ERC-8150 verifies agent payments with ZK proofs against a per-batch user-signed intent by calldata matching, so it requires a fresh signature per batch, keeps no parameters private, and cannot bind off-chain facts; ERC-8354 gates arbitrary agent actions behind a confidential third-party ruleset, the reverse trust topology of this standard (there the prover is a trusted policy engine and the secret is the rules; here the prover is the untrusted agent itself and the secret is only the policy’s parameters); ERC-8312 meters how much of a bounded mandate an agent has consumed but enforces nothing, and is complementary to the multi-payment budget extension discussed in Rationale.

This function set differs on all three axes: the policy is registered once (no per-payment signature), the proof shows constraint satisfaction rather than calldata equality (so off-chain facts such as signed quotes become provable inputs), and private parameters stay private behind a commitment.

Specification

The key words “MUST”, “MUST NOT”, “SHOULD”, and “MAY” in this document are to be interpreted as described in RFC 2119 and RFC 8174.

Interface

interface IZKSpendingPolicy {
    /// A policy was registered for `nonce`.
    event PolicyAllowed(bytes32 indexed nonce, bytes32 paramsCommit, address verifier);

    /// The policy for `nonce` was revoked before settlement.
    event PolicyRevoked(bytes32 indexed nonce);

    /// Register a single-use policy.
    /// `nonce`: the authorization nonce this policy is bound to; doubles as
    /// the policy id.
    /// `paramsCommit`: commitment to the policy's (possibly private) parameters.
    /// `verifier`: proof-system verifier contract for this policy's circuit.
    function allowPolicy(bytes32 nonce, bytes32 paramsCommit, address verifier) external;

    /// Revoke a policy that has not settled. MUST revert if already settled.
    function revokePolicy(bytes32 nonce) external;

    /// The registered policy for `nonce`, or zero values if none.
    function allowedPolicy(bytes32 nonce)
        external view returns (bytes32 paramsCommit, address verifier);

    /// The core check of this standard: does the payment described by
    /// `authorization`, with `proof`, satisfy the policy registered for its
    /// nonce? View: consumes nothing, so anyone can pre-flight a settlement
    /// with a static call before submitting it.
    function verifyPolicy(bytes calldata authorization, bytes calldata proof)
        external view returns (bool);
}

/// Optional extension for rails without contract-signature signed transfers.
/// Implementations that expose direct settlement declare this interface in
/// addition to `IZKSpendingPolicy`. See "Direct settlement".
interface IZKSpendingPolicySettlement {
    /// Emitted by direct settlement.
    event Settled(bytes32 indexed nonce, address to, uint256 value);

    /// Permissionless: the proof, not the caller, is the authorization.
    function settle(bytes calldata authorization, bytes calldata proof) external;
}

allowedPolicy returns zero values both for a nonce that was never registered and for one that was revoked; where the distinction matters, it is available from the events.

isValidSignature is deliberately not part of this interface: it is ERC-1271’s function, not this standard’s. A conforming contract that settles over contract-signature rails MUST also implement ERC-1271 as the rail adapter: its isValidSignature MUST decode the proof envelope, perform the digest-binding step below, and return the magic value exactly when verifyPolicy holds for the decoded fields. The two entry points MUST NOT be able to disagree.

Policy registration

  • allowPolicy and revokePolicy MUST be restricted to the owner.
  • nonce MUST be single-use: it is the authorization nonce of the one payment this policy can authorize. Replay protection MUST exist at settlement: with a rail that consumes the nonce atomically with the transfer (as ERC-3009 does) the implementation MAY rely on the rail; on any other path, the settlement component that spends against the escrow MUST consume the nonce itself. Registering a nonce that already has an unrevoked policy MUST revert.
  • A policy is settled when its nonce has been consumed at the settlement component (for ERC-3009, when authorizationState(address(this), nonce) is true). Where that state is readable on-chain, allowPolicy MUST reject an already-consumed nonce (such a policy could never settle, yet would read as live to any observer), and revokePolicy MUST revert for a settled policy; where it is not readable, implementations SHOULD document that both requirements are unenforceable and registrations against consumed nonces are unspendable.
  • Because isValidSignature cannot write state, the policy record survives settlement. A non-zero allowedPolicy result therefore means “registered”, not “spendable”; consumers MUST consult the settlement component’s nonce state to distinguish the two.
  • paramsCommit is opaque to the implementation. The commitment scheme (for example a Poseidon hash over a circuit version, a cap, and a quote-signer key) is defined by the policy circuit, not by this standard.
  • The implementing contract holds the funds: the owner funds it with the token being spent. This standard imposes no relationship between the balance and registered policies (see Rationale). The functionality MAY live in a dedicated escrow contract or inside a general-purpose wallet (for example as a module of a modular smart account); what conforms is the function set and its semantics, not the contract’s shape.

Payment authorization and proof encoding

verifyPolicy takes two arguments. authorization is the cleartext description of the payment. For the reference ERC-3009 schema it MUST decode as:

abi.encode(address to, uint256 value, uint256 validAfter, uint256 validBefore, bytes32 nonce)

from is deliberately absent: implementations bind the payer as address(this) wherever the payer matters (the adapter’s digest recomputation, settle), so a from field would be redundant input. Any other digest schema MUST likewise fix its authorization layout normatively; leaving it implementation-defined would make two conforming implementations of the same schema wire-incompatible, and a facilitator could not construct the payload from the specification alone.

proof is the proof-system-specific encoding defined by the policy’s registered verifier (for the reference Groth16 verifier, abi.encode(a, b, c)).

Neither argument carries public inputs. Every public input is anchored to a source the implementation already trusts (the registered policy, the authorization, the environment), so the implementation constructs the vector itself. Accepting a prover-supplied vector and checking it field-by-field would be sound too, but it turns each forgotten equality check into a critical bug; constructing removes that failure mode.

Verification

verifyPolicy(authorization, proof) MUST return true only if every one of the following succeeds:

  1. Policy lookup. Require an unrevoked policy registered for the authorization’s nonce.

  2. Public-input construction. Construct the public input vector, at minimum [to, value, paramsCommit, account, chainid]: to and value from authorization, paramsCommit from the registered policy, account as address(this) (the implementing contract), and chainid as the executing chain id. The last two scope the proof to this contract and chain. Public inputs MUST NOT be taken from the prover except through authorization, and verifyPolicy answers for whatever authorization the caller presents; binding that authorization to an actual settlement is the consuming path’s job (digest binding on the ERC-1271 adapter, nonce consumption at settle). The minimum vector is what every conforming implementation can anchor; a policy circuit MAY extend it, but every additional public input MUST arrive through one of three channels:

    • From the authorization. Fields of the payment authorization beyond to/value (a validity window, a category field in a richer digest schema): they arrive in authorization and are anchored by the digest-equality check.
    • From chain state. Values the implementation reads during the view call (an oracle feed, an allowlist root, block.timestamp for time-window policies): the policy registration fixes where to read, and the read anchors the value.
    • From registration. Values fixed when the policy is registered: committed in, or registered alongside, paramsCommit.

    A value that fits none of these channels cannot be anchored by the verifier, and an unanchored public input is semantically a witness, so it MUST be a witness: authenticated inside the circuit against something that is anchored, as a merchant-signed quote is authenticated against a quote-signer key committed in paramsCommit.

  3. Proof verification. Verify proof against the policy’s registered verifier and the constructed public input vector.

On signature rails the two arguments travel packed into the ERC-1271 signature slot as the proof envelope, which MUST decode as:

abi.encode(bytes proof, bytes authorization)

The cleartext authorization rides along because hash is one-way: ERC-1271 hands the implementation only the digest and these bytes, so without the fields it could neither recompute the digest, nor look up the policy, nor construct the public inputs. It is untrusted input; digest binding is what ties it to the settlement.

On isValidSignature(hash, signature) the ERC-1271 adapter MUST decode the envelope and then perform digest binding: recompute the EIP-712 typed-data digest of the payment authorization from authorization, under a digest schema the implementation supports, and require it to equal hash. A digest schema is any typed-data layout that binds at least a payee, an amount, and a single-use nonce; ERC-3009’s TransferWithAuthorization is the reference schema. The adapter MUST NOT accept a digest it cannot decompose into those fields, and MUST return the magic value 0x1626ba7e exactly when digest binding succeeds and verifyPolicy holds for the decoded fields, 0xffffffff otherwise.

isValidSignature MUST NOT modify state (per ERC-1271). These requirements apply to envelopes that decode; a malformed envelope MAY revert during decoding, which ERC-1271 callers already treat as an invalid signature.

What the circuit proves beyond the bound public inputs (a private price cap, a merchant-signed quote, a category restriction, a time window) is the policy’s business and is out of scope for this standard. The standard only fixes where the proof is checked, what it is bound to, and the single-use coupling between a policy and a payment.

Reference pseudocode (informative)

Groth16 is used as the example proof system; the proof encoding and the verifier interface are whatever the policy’s registered verifier defines.

function verifyPolicy(bytes calldata authorization, bytes calldata proof)
    external view returns (bool)
{
    (address to, uint256 value,,, bytes32 nonce) =
        abi.decode(authorization, (address, uint256, uint256, uint256, bytes32));

    // 1. Policy lookup. An unrevoked single-use policy must exist for `nonce`.
    Policy storage p = policies[nonce];
    if (p.paramsCommit == bytes32(0)) return false;

    // 2. Public-input construction. Nothing is taken from the prover except
    //    through `authorization`; the registered policy and the environment
    //    supply the rest.
    uint256[5] memory publicInputs = [
        uint256(uint160(to)),            // authorization
        value,                           // authorization
        uint256(p.paramsCommit),         // registered policy
        uint256(uint160(address(this))), // scopes proof to this contract
        block.chainid                    // scopes proof to this chain
    ];

    // 3. Groth16 verification against the verifier registered for this
    //    policy. For Groth16 the proof bytes decode as the (a, b, c) points.
    (uint256[2] memory a, uint256[2][2] memory b, uint256[2] memory c) =
        abi.decode(proof, (uint256[2], uint256[2][2], uint256[2]));
    return IGroth16Verifier(p.verifier).verifyProof(a, b, c, publicInputs);
}

// The ERC-1271 rail adapter: digest binding, then the same check. The token
// calls this; verifyPolicy above is what it is really asking.
function isValidSignature(bytes32 hash, bytes calldata signature)
    external view returns (bytes4)
{
    (bytes memory proof, bytes memory authorization) =
        abi.decode(signature, (bytes, bytes));

    // Digest binding. Recompute the EIP-712 digest from the authorization
    // fields under a supported schema (reference: ERC-3009
    // TransferWithAuthorization), with from = address(this), and require it
    // to equal `hash`. Equality pins the payer, since `from` is part of the
    // typed data and only this contract's address is ever used.
    bytes32 recomputed = eip712Digest(tokenDomainSeparator, authorization);
    if (recomputed != hash) return 0xffffffff;

    return this.verifyPolicy(authorization, proof)
        ? bytes4(0x1626ba7e)  // ERC-1271 magic value
        : bytes4(0xffffffff);
}

Direct settlement (optional)

The required interface contains no function that moves funds, deliberately. On a signed-transfer rail the spend function already exists on the token (transferWithAuthorization), any EOA or contract may call it, and the implementation’s role is validation only; that separation is what lets the functionality attach to deployed rails unchanged.

On a rail with no such spend path, an implementation MAY expose settle(authorization, proof) and act as the settlement component itself. settle MUST require verifyPolicy(authorization, proof); MUST enforce the authorization’s validity window; MUST consume the nonce before transferring (it is the nonce-consuming settlement component required under Policy registration); MUST be permissionless; and SHOULD emit Settled. Unlike isValidSignature, settle is a state-changing call, which is exactly why it can own the nonce consumption.

Instantiation: ERC-3009 / ERC-7598 / x402 (informative)

Nothing in this section is normative; it is one deployment of the function set over rails that are live today. With an ERC-3009 + ERC-7598 token (for example USDC v2.2 and later), settlement is the standard flow: a facilitator submits transferWithAuthorization(from, to, value, validAfter, validBefore, nonce, bytes signature) with the proof envelope as signature. Because from is a contract, the token routes the bytes to from.isValidSignature, which performs the verification above. In an x402 deployment the envelope travels in the X-PAYMENT header of the standard exact scheme; the merchant and facilitator require no changes.

x402              HTTP 402 envelope                      (how the ask travels)
 └─ ERC-3009      transferWithAuthorization              (gasless signed transfer)
     └─ ERC-7598   bytes-signature overload              (the slot that carries the proof)
         └─ ERC-1271  isValidSignature                   (adapter: verify the policy proof)

Rationale

The verifier is the account, not the rail. Everything upstream of the implementing contract treats the proof as an opaque signature. This is what makes “no protocol changes” hold, and it is the portability argument: the EVM instantiation is ERC-3009/7598/1271, but any settlement system that routes an opaque signature blob to a self-verifying account can host the same pattern.

Settlement-time constraint satisfaction, not pre-execution calldata matching. Verifying “the payment satisfies the policy” rather than “the calldata equals the signed intent” is what lets the proof bind off-chain facts such as a merchant-signed quote, and what removes the per-payment signature: the owner’s one registration covers whichever concrete payment the agent finds, as long as it satisfies the constraints.

Commitment, not plaintext policy. Registering paramsCommit instead of the parameters keeps negotiation-sensitive values (the cap) off-chain while still enforcing them.

Privacy model. The public input vector contains no secrets by construction: policy parameters live behind paramsCommit, and off-chain facts such as the merchant’s quote are private witness. to and value are public inputs precisely because they are inherently public at settlement: a transparent ERC-20 token emits Transfer(from, to, value) regardless, so hiding them from the verification call would gain nothing. Hiding them for real requires a private settlement rail, which replaces to/value with the rail’s own commitments; that is an instantiation concern, not a change to this interface. Moving the digest opening into the circuit (making the EIP-712 hash the only public input) was considered and rejected: it puts keccak in the circuit for zero privacy gain.

One policy = one payment. Binding the policy id to the authorization nonce reuses the settlement path’s own replay protection as the single-use mechanism and keeps the implementation stateless in the signature path. Multi-payment budgets (one commitment authorizing a decrementing budget across N settlements) require monotonic spent-state advanced outside the view-only signature check; they are deliberately out of scope for this ERC and are expected to build on it (the implementing contract, as the verifier, is the natural home for that counter).

No balance/policy coupling. The held balance bounds aggregate loss across all outstanding policies; per-payment bounds come from each policy. Coupling the two (reserving balance per policy) is an implementation choice, not a standard requirement.

Backwards Compatibility

No changes to any deployed contract or protocol. Any settlement path that routes a bytes signature to the payer’s ERC-1271 check can spend from a conforming contract; ERC-20 tokens implementing ERC-3009 with the ERC-7598 bytes overload (USDC FiatTokenV2_2 and later) are the deployed example, and x402-style HTTP flows carry the envelope unchanged.

Security Considerations

  • Circuit soundness is the policy. A bug in the policy circuit is a bug in the spending control. Policy circuits SHOULD be small, auditable, and versioned inside paramsCommit (a circuit-version field in the commitment prevents proofs from a retired circuit shape).
  • Proof-system choice. Groth16 requires a per-circuit trusted setup; the per-policy verifier field keeps the proof system swappable per policy.
  • The verifier is part of the policy. A verifier contract embeds one circuit’s verifying key, and paramsCommit is only meaningful relative to that circuit, so the pair is registered together in allowPolicy; this structurally prevents validating a proof for one circuit against a commitment meant for another. The verifier address is owner-set and therefore owner-trusted (registering a bad verifier is the same class of mistake as approving a bad policy), and implementations MUST call it as a stateless view function. Verifiers are shared infrastructure: one deployment per circuit per chain serves every policy and every implementing contract, since parameters vary through paramsCommit, not through the circuit. Where the verifier is universal (one deployment verifying many circuits, as with zkVM verifiers that take a program id), the circuit identity MUST still be fixed at registration, either inside paramsCommit or registered alongside it; the verifier address alone no longer pins the circuit in that case.
  • View-only verification. ERC-1271 verification cannot write state, so nothing in the signature path may be relied on to record spending. Single-use comes from the settlement path’s nonce consumption, not from the implementation.
  • The envelope is not a script. Fields the circuit does not constrain are the agent’s discretion: two payments satisfying the same policy are interchangeable. Owners MUST understand that the policy, not the natural-language task, is the entire enforcement boundary.
  • Quote-signer trust. Policies that bind merchant-signed quotes make the merchant’s signing key a trust anchor; a compromised quote signer can attest prices that defeat the cap’s intent (never the payee binding).
  • Revocation races. revokePolicy before settlement MUST take effect for any later isValidSignature call, but a settlement already in flight in the same block may still verify; owners needing hard cancellation SHOULD also use the settlement path’s own cancellation where it exists (for example ERC-3009 cancelAuthorization).

Copyright

Copyright and related rights waived via CC0.

Clean comparison table, and worth adding a fourth trust topology since the framing (who is the prover, what’s the secret) is exactly the right axis to compare on. This standard’s one policy = one payment bounds worst-case damage to a single payment – but it doesn’t establish that THIS specific payment, within the policy envelope, was actually the right call given the current situation. A policy can be satisfied and the payment can still be a bad idea: a merchant-signed quote that’s technically within budget but for a compromised/wrong counterparty, or a payment that clears every registered constraint but happens at a moment the agent’s own reasoning was manipulated.

We build the fourth piece: an independent, signed verdict on the specific proposed action – not a policy match, a judgment call – checkable after the fact by anyone without trusting the party that produced it. It composes at a different point in the pipeline than any of the five you’ve named: not gating the agent’s execution (8354’s job), not enforcing at settlement (this standard’s job), but sitting between “the payment satisfies the policy” and “the payment executes” as an optional extra check for cases where policy-satisfaction alone isn’t enough confidence.

Real question on the spec: does IZKSpendingPolicy’s registered commitment leave room for a policy to require an EXTERNAL verdict reference (not just a constraint on the payment’s own parameters) as one of the provable inputs – i.e. could “this payment was independently reviewed and approved” itself become part of what the ZK proof establishes, or is the standard deliberately scoped to only constraints the payer contract can evaluate on its own?

Thanks, good question. Short answer: yes, this is already in scope.

The standard does not limit what a policy can require. It only fixes where each provable input must come from: the payment authorization itself, chain state the contract reads on its own, or values fixed when the policy is registered. A verdict fits in two of those ways today:

  • Register the reviewer’s key with the policy. The circuit then verifies the reviewer’s signature over the payment’s own fields (to, value, nonce). “Independently approved” becomes a provable constraint, the same way the reference policy proves a merchant-signed quote.
  • Or, if verdicts are posted to an on-chain registry, the policy registers that address and the contract reads it during verification.

The only shape ruled out is a bare “approved” flag supplied by the prover, with nothing anchoring it.

So the interesting part is composition. Three concrete things worth building together:

  1. A common verdict format: the payment fields, an expiry, and the signer. Any judgment layer emits it, any policy circuit consumes it. Review becomes a pluggable constraint instead of a per-project integration.
  2. Private escalation: below some threshold no verdict is needed, above it the circuit requires one, and the threshold itself hides behind the commitment like the cap does. Nobody outside can tell which payments needed review, or where the line sits. Neither layer can build this alone.
  3. Cross-layer audit, your after-the-fact angle: if the verdict and the settlement bind the same (to, value, nonce), anyone can later confirm that this settled payment is the one that was reviewed, without trusting the reviewer or the payer. The single-use nonce is the join key.

If you have a draft of your verdict envelope, happy to check it against the reference circuit’s input layout.

Update (Aug 5): the draft has been revised, based on the discussion here and on what we learned building the reference implementation, which is now public: GitHub - fractalyze/erc-8366: Reference implementation of ERC-8366: Zero-Knowledge Spending Policies · GitHub (Foundry suite of 33 tests running against a real Groth16 proof, not a mock).

Main changes:

  • verifyPolicy(authorization, proof) is now the interface’s first-class check: a view function anyone can call to pre-flight a settlement with a static call before submitting it. This is the check the standard exists for, and it now has its own name.
  • isValidSignature is no longer declared in the interface. It is ERC-1271’s function, not this standard’s; the spec now defines it as the rail adapter, which must perform digest binding and then agree with verifyPolicy. The two entry points cannot disagree.
  • The authorization tuple is now normative for the ERC-3009 schema: abi.encode(to, value, validAfter, validBefore, nonce), with from omitted because digest recomputation pins the payer. Without this, two conforming implementations could be wire-incompatible.
  • “Settled” is defined (nonce consumed at the settlement component); registering an already-consumed nonce must be rejected where that state is readable, and policy records survive settlement, so a non-zero allowedPolicy means registered, not spendable.
  • The optional settle/Settled pair moved to its own IZKSpendingPolicySettlement interface (OPTIONAL members do not compile in a single Solidity interface).

I have updated the opening post to the current text; the canonical version is the PR: Add ERC: Zero-Knowledge Spending Policies by junbeomlee · Pull Request #1929 · ethereum/ERCs · GitHub

@babyblueviper1 the pre-flight verifyPolicy call should also make the verdict composition we discussed cleaner: a reviewer can statically check the exact authorization they are approving before signing anything.

Real draft, not hypothetical – here’s our actual envelope. decision_ref = sha256(JCS({artifact_hash, artifact_type, policy_version, verdict, source_class, vantage_limitation, related_decision_ref, intended_audience, confidentiality_tier, disclosed_summary})), published in every proof alongside its own preimage-fields list so a verifier never has to guess the construction. artifact_hash is a hash of whatever we were asked to judge – for a payment-review case that would need to BE (or commit to) the same (to, value, validAfter, validBefore, nonce) tuple your circuit consumes, or the join breaks silently: two records that look bound by the nonce but were actually computed over different bytes.

Honest gap on our side, surfaced by your cross-layer-audit point specifically: artifact_hash is deliberately generic today (works for a code diff, a trade, an on-chain action – not payment-schema-specific), so nothing in our envelope currently ENFORCES that it was computed over the same authorization bytes your verifyPolicy checks. That’s a caller discipline right now, not something decision_ref itself guarantees.

Concrete fix for your #1 (common verdict format): if the payment fields your circuit reads ARE the artifact judged, artifact_hash = keccak256(authorization) – matching your own digest, not a separate hash – closes that gap cleanly and makes the nonce-join real instead of assumed.

Happy to build a small reference adapter: our decision_ref computed with artifact_hash pinned to your exact authorization encoding, checked against a real fixture pulled from your test suite (verified live, 33/33 passing against a real Groth16 proof – nice work). That would make #1 and #3 concrete rather than just compatible-in-principle.

Agreed that artifact_hash = keccak256(authorization) is the fix. Without pinning it, the proof is about a hash the caller asserts rather than the bytes verifyPolicy actually checks, and that seam is exactly what an attacker works.

One implementation note that might save you time if verifyPolicy computes the commitment in-circuit. I did this with keccak in Noir and the digest exceeds the BN254 field, so it cannot be carried as a single Field. Taking the authorization as [u8; 32] raw bytes into the keccak preimage and committing over that keeps the in-circuit keccak byte for byte identical to Solidity keccak256, and public inputs stay the same because the raw bytes are a private witness. After that, the commitment the circuit proves and the one the contract recomputes are the same 32 bytes, which is the property you want.

One design question on decision_ref. You carry policy_version in the clear, which makes sense for an auditable spending policy. Is the intent that the policy contents are always public, or do you see a mode where only the version and verdict are public and the policy itself stays private? That changes what artifact_type has to cover, and it is worth nailing down before the envelope format sets.

Honest answer, and it connects to a gap I named on a different thread earlier today: today, policy_version is always public/clear (a version string like invinoveritas.review.v9, present as plaintext in the signed content) — but it’s currently a trusted label, not a cryptographic commitment to the actual policy content behind it. There’s no “policy stays private, only version+verdict public” mode yet, because the label doesn’t yet commit to anything checkable in the first place. confidentiality_tier in our envelope controls disclosure of the reviewed artifact (hash_only/partial_disclosure/full_disclosure), not the policy itself — different axis.

The real fix, which I’m building now (found the same gap independently on t/29088 with helmymekaoui-web this morning): policy_version should commit to a content hash of the published rubric/decision-boundary spec text, not just carry a bare label. Once that lands, the honest answer to your question becomes real rather than aspirational — “policy contents always public” and “only version+verdict public with policy private” both become representable, because the version string itself becomes a real commitment either way (hash of the public spec text, or hash of a spec kept private with only the commitment disclosed).

For your envelope specifically: I’d lean toward always requiring the policy_version→commitment binding regardless of whether the underlying policy text is public, same as our own plan — an auditor should always be able to confirm “this verdict claims to follow policy X,” whether or not X’s contents are disclosed to them. Keeping policy fully private with zero commitment defeats the audit trail; keeping the commitment public while the text stays private (proof-of-consistent-policy without revealing it) seems like the shape worth designing toward, not an either/or with “always public.”

Same BN254/keccak field-overflow gotcha you flagged is worth pinning in any spec text on this thread too — real, not hypothetical (confirmed independently: BN254’s field is ~254 bits, keccak256 is 256, so ~81% of real digests actually wrap when cast naively).

Committing policy_version to a content hash of the rubric is the right move, and it is what unlocks the private mode cleanly. Once the version is a commitment rather than a label, the public case is here is the rubric and here is its hash, and the private case is here is the hash, the rubric stays off-chain, and the proof shows the decision was made against the thing that hashes to it. Same field, two disclosure levels, and the verifier checks the same commitment either way.

That private case is the tier I have been building as ERC-8354, a verdict proven against a policy only ever published as a root commitment. If useful I am happy to share how the commitment and nullifier discipline line up, so 8366 and it can be siblings that share the binding rather than two separate shapes. And agreed, the BN254 keccak gotcha deserves an explicit line wherever the commitment gets computed in-circuit.