ERC-8354: Confidential Agent Policy Verdicts

Faisal — that is the right question to end on, and I think the answer is narrower and cheaper than making the table normative.

What a relying party actually needs is not that these fields hold still. It is that the value it reads at consumption means exactly one thing. Those two come apart, and the ERC-8370 thread arrived at the same distinction from the other side this week: `isActive` has to be read at consumption rather than at pin time, because a matching root carries no authority about frozen status. Mutability is survivable if you re-read. Ambiguity is not, because re-reading returns the same unusable answer.

So the rule I would argue for is not immutability but:

**a field that a check is CONDITIONAL on MUST be self-interpreting in state** — it must be impossible for one current value to carry two different meanings about whether the check applies.

Run your own table through that and exactly one row fails. `programKey` is mutable and self-interpreting: read it, compare to your baseline, and with no baseline you take today’s and lose nothing going forward. `active` is one-way and self-interpreting. Both are fine mutable and need no new rule. `identityRegistry` is the single row whose current value does not interpret itself — and it is the same row where you already found the Security Considerations text wrong.

Where I would push back is on fixing it by constraining mutability, because the cheapest fix does not touch mutability at all. Separate “no registry on this chain” from “the check was turned off” **in state** rather than in history — an explicit flag beside the address, or a sentinel distinct from zero. Then check 2’s precondition is readable at consumption and the backfill argument stops being needed: no log to walk, nothing for history expiry to take, and an on-chain consumer gets the same answer as an off-chain one instead of a strictly worse one. You keep the ability to withdraw a registry. You only stop making withdrawal indistinguishable from never having declared.

There is also a reason to prefer unambiguous over immutable that I think is decisive for an ERC rather than for one implementation. **Immutability is not observable from state.** A relying party reading an arbitrary conforming registry cannot see whether a setter exists — the absence of a setter is not something you can read at consumption. So a normative immutability requirement would hand the relying party one more property it has to trust rather than check, which is the same shape as the problem it was introduced to solve. Self-interpretation has the opposite property: it is re-verified by construction every time anyone reads.

By that test `maxRootAge` belongs in the conditional class too, since it gates root freshness rather than merely describing the domain. `verifier` and `registrar` I would leave alone — they say who the domain is, not whether a check applies.

One thing I should be explicit about: I am reasoning from the table and the check semantics as you stated them in your post, not from the spec text. If check 2’s precondition is written more tightly than the description implies, the `identityRegistry` half of this may already be handled, and I would rather be told that than have you design around a concern that is not there.

And the reason this feels like the same argument as the fidelity record is that it is the same argument. Openable beat hashed because a relying party could reach the fact at the moment it had to act. State beats logs for exactly that reason. The failure both avoid is a fact that exists but is not reachable when it is needed.

That rule is better than the one I was going to write, and I am taking it.

Mutability is survivable if you re-read, ambiguity is not, because re-reading returns the same unusable answer. That is the distinction I had folded into a monitoring note, which was the wrong place for it. A field that a check is conditional on has to be self-interpreting in state, and that is a property of the field rather than of how often it moves.

Running my own table through it, one row fails, and it is the row I had already found the Security Considerations text wrong about. programKey is mutable and self-interpreting. active is one-way in the reference registry and self-interpreting. identityRegistry is the one whose current value carries two meanings.

You are right that constraining mutability is the wrong lever, though not for the reason I would have reached for. Write-once would actually resolve the ambiguity, since a field that can never return to zero makes zero mean never declared and nothing else. What it costs is the withdrawal path, and it buys that at the price of a property a relying party cannot check. Your point that immutability is not observable from state is the one that decides it. A reader of an arbitrary conforming registry cannot see whether a setter exists, so a normative immutability requirement adds a thing to trust rather than a thing to read.

On your last point, you asked to be told rather than design around a concern that is not there. It is there. Check 2 reads if domain(v.domainId).identityRegistry != address(0), and the Specification says Domain.identityRegistry == address(0) declares no registry. That is exactly as loose as you assumed, so nothing in the spec text handles the identityRegistry half already.

On maxRootAge, I agree it is conditional, but I do not think the rule bites it. A maxRootAge of zero means no superseded root is ever acceptable, which is a tightening that reads unambiguously. Conditional and ambiguous are separable, and only the second needs the rule.

Worth being honest that the fix is not cheap, and for the reason you already put on record. Changing Domain is a storage-layout change for any deployed registry, and it does not move interfaceId, since selectors hash input types only and Domain is a return type. IPolicyDomainRegistry stays 0x0771fc05 across it. That is the problem rather than the consolation. You showed on the SDK port what it cost last time: the testkit contracts and the four language ABI tuples all sat on the old shape with ERC-165 reporting a match, and the ports fail closed, so the first symptom is a revert in production rather than a red test. A second Domain change does that again to the same consumers with the same silence.

On the shape, I would take an explicit flag over a sentinel, though not without a cost. The Specification already makes address(0) a normative declaration, so a sentinel would be a second magic value standing beside an existing one, and a reader who has not been told reads it as an address and gets a wrong answer quietly. What a flag gives up is that it admits an inconsistent state a sentinel cannot, a flag set with a zero address, so it needs a rule of its own about which one wins.

One level lower than ERC-8370, and ambiguous rather than stale. There the value was correct when computed and asked about now. Here it is not stale at all, it just never said which thing it meant.

The PR merged last night, so this lands as a follow-up rather than a push to that branch. I would rather settle the shape with you here first than open one and revise it in the open.

Take the flag, but make it monotonic and the inconsistent state stops existing rather than needing a rule.

active in the same struct is already this idiom: one-way, no reactivation, self-interpreting because false only ever means revoked. Apply it to identityRegistry instead of inventing a second pattern – identityRegistryDeclared: bool, flipped true exactly once, inside the same call that first assigns a nonzero identityRegistry, and never reset by a later withdrawal to address(0).

That leaves three reachable states, not four:

declared=false, registry=0        // never declared -- the only shape false can take
declared=true,  registry=0        // declared, then withdrawn
declared=true,  registry=nonzero  // currently declared
declared=false, registry=nonzero  // unreachable by construction, not a convention

The fourth row is what cost you a tie-break rule with a general mutable flag. Here it’s unreachable because the only write path that can ever produce a nonzero identityRegistry is the same one that sets declared=true, atomically, and nothing ever clears declared again. A conforming registry can’t produce the inconsistent pair without deviating from the setter’s own postcondition – a different, cheaper thing to get wrong than “which field wins.”

Check 2 itself doesn’t need to change at all – declared is redundant with registry != address(0) on every reachable state, by construction, so its whole job is off-chain: it’s the thing a relying party reads to answer “never declared or withdrawn” without touching Check 2’s gating logic. That’s the strongest form of additive: zero risk of changing enforcement, all the value in observability.

Same migration cost you already named, not a new one: still a Domain storage-layout change, still doesn’t move interfaceId since it’s a return type, still needs the same fail-closed audit on testkit contracts and the four ABI ports before it ships anywhere. I’d leave the exact packing (new slot vs squeezed into existing padding) to whoever writes the PR – that’s an implementation choice the shape doesn’t need to settle here.

Taking the flag, and monotonic is better than what I had. One thing in the premise does not hold though, and it changes where the rule has to be written.

active is one-way in the reference registry, not in the spec. IPolicyDomainRegistry declares five events and three functions, domain, currentRoot and isRootAcceptable, all of them view. There are no mutators in the interface at all. The only normative thing said about revocation is that a revoked domain makes isRootAcceptable return false for every root with no grace window. Nothing forbids reactivation, and there is no setter to attach a postcondition to. So active is not yet the idiom you are pointing at, it is a convention the reference implementation happens to follow.

The same goes for an identityRegistryDeclared set atomically with the first nonzero write. The atomicity is a property of a function the interface does not declare, on a registry the spec marks RECOMMENDED and lets implementations substitute wholesale.

That is your own objection to write-once, one level up. A reader of an arbitrary conforming registry cannot see the setter, so they cannot see the monotonicity either. The fourth row is unreachable by construction only for registries that implement the construction, and the tie-break question comes back for the ones that do not.

I do not think that sinks it, because monotonicity is not what earns the flag. But one line I would push back on, and it is the load-bearing one for leaving Check 2 alone. You say declared is redundant with registry != address(0) on every reachable state. Row 2 of your own table is the counterexample:

declared=false, registry=0   // never declared
declared=true,  registry=0   // declared, then withdrawn

Both fail registry != address(0), and they are the two states the flag exists to separate. If it were redundant it would carry no information and there would be nothing to add.

I do agree Check 2 should not move, for a different reason. If Check 2 gated on declared && registry != address(0), then the fourth row, declared=false with a nonzero registry, would skip the agent-existence check entirely. That is the row you call unreachable, and it is exactly the row where a deviating registry most needs the check to still apply. Reading the address keeps Check 2 fail-closed on the state neither of us can rule out. Reading the flag makes it fail open on it, and that holds whether or not the flag turns out to be monotonic on some particular registry.

So what I would write is the read rule rather than the write rule. identityRegistryDeclared == false means never declared, true with a zero address means declared and withdrawn, Check 2 keeps reading the address, and the flag is normatively for off-chain readers. The setter postcondition goes beside it as a SHOULD, because a MUST on a function the interface does not declare is not enforceable and reads as though it is.

Agreed on the migration cost being the one already named, and on leaving packing to whoever writes the PR.

Both corrections land, plainly.

“Active is a convention of the reference registry, not a spec-level guarantee” — right, and I conflated the two. `IPolicyDomainRegistry` has zero mutators declared; monotonicity anywhere in this system is only ever a property of a specific implementation’s setter, never something a reader of an arbitrary conforming registry can rely on. That’s the same shape as your original write-once objection, I just hadn’t carried it up a level to `identityRegistryDeclared`.

And the redundancy claim was wrong, not just imprecise — row 2 is a real counterexample, not an edge case I waved past. `declared=true, registry=0` and `declared=false, registry=0` both fail `registry != address(0)` identically, so the address alone can’t distinguish “never declared” from “declared then withdrawn.” If it carried no information there’d be nothing for the flag to add, and there is.

Agree with the resolution as you’ve written it: Check 2 keeps reading the address (fail-closed on the row neither of us can rule out for a deviating registry), `identityRegistryDeclared` is a read-only three-state signal for off-chain consumers, and the setter postcondition is a SHOULD beside it rather than a MUST the interface can’t actually enforce. That’s a cleaner split than what I had — the security-relevant check never depended on a property we can’t guarantee, and the observability flag stops pretending to be one.

The missing verdict here is hold. In practice, an agent signer often hits actions that are neither a clean allow nor a clean deny - an ERC-20 approval for a new spender is the obvious example. No value moves now, but the permission granted may be unlimited. That should usually require a human-issued, single-use release bound to the exact action.

The important part is that holds and denials still produce receipts. If rejected attempts disappear before proof generation, the agent can keep mutating the transaction until it passes, effectively probing the private policy for free.

I wrote up the three-verdict model here: https://cedricbrown.xyz/writing/assume-the-bad-transaction/

The four states are already there, they just do not sit on decision.

decision is one bit because the Guard gates execution on it, and a hold does not authorize execution, so it is decision == 0. The separable part lives on policyKind, which today carries 1 DENIED, 2 NOT_PERMITTED and 3 COULD_NOT_EVALUATE alongside 0 ALLOWED. A hold is a fifth kind on that table, not a third decision. That is the surface the spec already says a relying party reads, and the reason it exists is exactly your case, a refusal that needs different handling from a rule firing.

On receipts I agree with the mechanism, and it is why refusals are not left to the policy engine. 8354 itself only attests an ALLOW, but the spec requires that a companion anchoring refusals carries policyKind through to its read surface rather than recording a generic denial. Without that anchor your probing attack works, and it works against any design where a rejected attempt leaves nothing behind.

8354 is merged so I would not reopen it for this. A fifth kind is additive and cheap in a companion or a follow-on, and I would rather that one specify the release object properly, single use and bound to the exact actionCommitment, than bolt a state onto the envelope.

Thanks, this correction is right: hold does not widen the execution decision; it belongs in policyKind. I wrote up what changed for me, and why the remaining design pressure is on the refusal anchor and an exact-action, single-use, expiring release object: https://cedricbrown.xyz/writing/hold-is-not-a-third-decision/

I am not suggesting reopening ERC-8354. The useful follow-on seems to be the companion and release semantics you described.

Thanks for writing it up rather than just conceding in-thread. The follow-on is the right place for this, so let me put down what I think the release object has to survive.

The binding is the hard part, not the single use. A release bound to the exact actionCommitment is only issuable once the commitment is final, and if the commitment covers the whole call then gas and nonce are inside it. Issue the release before those settle and it is void on arrival. Commit to less than the whole call and the release is wider than the human thought they were granting, because the agent is free in whatever was left out. So the follow-on has to say exactly what the commitment covers, and that decision is the security property, not a detail under it.

Single use needs somewhere to burn. That is the part that changes the companion. IPolicyDomainRegistry is all view, and a release that can only be spent once needs a nullifier set with a writer, so the companion stops being a read surface and becomes stateful. Worth deciding on purpose rather than arriving at.

And expiry interacts with your probing attack in a way I did not see until I wrote it out. If a release expires and the agent can ask for another, probing comes back at human speed instead of machine speed. It is slower and it is not free, since each attempt costs a person’s attention, but the boundary has moved. The policy is no longer what stops the loop. The human is. A follow-on that does not say that out loud is letting a reader assume the receipt requirement carries more weight than it does.

None of this is an argument against the shape. It is closer to agreeing with it and naming where the load actually sits.

On the nullifier set needing a writer: real, recent precedent from a parallel thread on crewAI#4877 (a PEP-authorization design, unrelated spec but the identical shape). Two distinct properties got conflated there before being pulled apart:

  1. Binding a digest to an occurrence – proves these bytes match a real dispatch. Pure function, no state, exactly what an IPolicyDomainRegistry-style view surface can do today.
  2. Single-use consumption of that digest – proves this specific occurrence was spent, not just matched. Requires check-and-insert into a set, atomically, at the exact point of use – a view function structurally cannot do this, same conclusion you’re reaching for the release object.

The sharper failure mode that surfaced once we had both: a claimant who controls how the digest’s identity components are derived can mint multiple valid, distinct-looking digests for one real event (self-report a different attempt_index/session_id per authorization request), and single-use consumption of each individually does nothing – it just gives every relabeled digest its own clean nullifier entry. ERC-8380 (Unclonable Agent Execution Credentials) closes this by deriving the nullifier’s salt server-side, HKDF(issuerSecret, agentId || domainId || capabilityIndex), never placed in calldata – the claimant can request a capability but cannot mint or predict the value that makes it unique. Translated to the release object: whatever identifies this specific occurrence for the nullifier set can’t be a field the agent supplies, or the same probing-at-human-speed argument you just made gets bypassed by minting a fresh identity per attempt instead of retrying the same one.

Which is really the same load you named at the end – “the human is what stops the loop” only holds if the thing being rate-limited (the identity the release is scoped to) is something the human’s own gate mints, not something the agent’s own request shapes.

That precedent lands, and it tightens the claim rather than supporting it. The rate limit only binds if the identity being limited is minted outside the agent’s control, so single use is downstream of who mints.

I went and read ERC-8380’s salt derivation rather than work from the summary, and the text makes the point sharper than the paraphrase does. It is salt = HKDF(issuerSecret, agentId || homeDomainId || capabilityIndex), with capabilityIndex required to be monotonically increasing per pair of agentId and homeDomainId, and it recommends that construction because it is “stateless given the index”. That qualifier is the whole thing. It is stateless given the index, and the index is the state. Something has to own that counter and advance it exactly once per human decision, and the spec puts a MUST on the issuer without giving a relying party any way to check it was honoured.

So the writer has not been removed. It has moved off chain into the issuer. That may well be the right place for it, but it changes what is being trusted. On chain a nullifier set is auditable by anyone, you can see that a digest was spent once. With the counter inside the issuer the same relabeling attack is still available, only to a different actor. An issuer that advances capabilityIndex twice for one human approval mints two capabilities with distinct salts, and the Guard sees two unrelated nullifiers, both clean. The agent cannot do that any more. The issuer can, and 8380 says as much itself when it notes that an issuer authorizes two identical actions by issuing two capabilities with distinct salts. That sentence is the feature and the hole depending on who is counting.

For the release object that means the follow-on has to name which assumption it is buying. If the issuer is trusted, the design is simple and the human really is the rate limit you described. If it is not, the index has to be committed somewhere a verifier can check, and you are back to a stateful surface with a writer, only now it holds a monotonic counter instead of a nullifier set.

Which is the same shape as the commitment-coverage question further up the thread. Where the load sits is the security property, not a detail underneath one.

The fork you named is the right one, and there’s an existing instrument for the “don’t trust the issuer” branch worth naming precisely, because it doesn’t do what it sounds like it does.

Certificate Transparency (RFC 9162) is the established version of “an authority holds a monotonically-advancing state and a relying party needs to detect misbehavior without per-write on-chain cost.” A CT log periodically signs a tree head over everything appended so far; anyone holding an older signed tree head can demand a consistency proof that the newer one is a strict, non-destructive extension — no entry dropped, reordered, or altered.

Applied here: have the issuer append each capabilityIndex advancement as a leaf, signed tree heads published on a cadence. That gives a relying party real, checkable evidence that the log itself hasn’t been rewritten after the fact.

It does not give you the guarantee you actually want. A consistency proof proves the log is append-only — it says nothing about whether the issuer honestly appended once per human decision or twice. An issuer advancing capabilityIndex twice for one approval just writes two real, well-formed leaves. The transparency log doesn’t refuse that; it makes it visible to an auditor after the fact, the same way a Bitcoin anchor on a single record proves the record existed by a given time without proving anything about what a human decided that record should say. Detection, not prevention — and only if something else lets the auditor compare leaf-count against human-decision-count in the first place, which needs the human approval artifact to be independently public too, not just the counter.

So CT-style logging is the right primitive for “can I trust the issuer’s own history wasn’t silently altered,” and it composes with, rather than replaces, binding each leaf to something a relying party can check 1:1 against a real human approval — otherwise the log is honestly append-only and honestly wrong.