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.