ERC-8004: Trustless Agents

The inconclusive-vs-mismatch fix is the more interesting half of that post, and I’d put it higher than you did: it is the same rule as §6, arrived at independently in an unrelated domain. “Zero authority is not invalid” and “unreachable is not mismatched” are one principle wearing two costumes — in both cases a binary output destroys exactly the case the consumer needed in order to decide. Two arrivals at that shape, from key authority and from network availability, is enough evidence that it belongs to checking itself rather than to either domain, so I’ve stated it once as §6.1 instead of leaving it to be rediscovered per-domain:

A verification step MUST distinguish the check ran and failed from the check could not run.

with your byte-identity-over-reachability point as the preferred property, and “re-verify from the artifact, never from a transcription of it” as the corollary — since that is what forced the distinction into the open both times.

Your negative test deserves more credit than a parenthetical, too. A checker that has never caught anything is an untested checker; validating it against your own hand-retyped copy — an error you actually made, not a synthetic mutation you designed to be caught — is a stronger test than a crafted fixture, because you weren’t the adversary when you produced it. That’s the same reason the fixture Space commits a duplicate and an out-of-order entry rather than describing them.

One practical question, asked as a collaborator rather than a skeptic: I couldn’t find verify_broadcast_bytes in either public repo, so I assume it lives on the platform side. Is it headed for preaction-governance-conformance? Everything else in this exchange has been checkable by a third party with nothing but the published artifacts, and this particular tool is one whose output others will want to reproduce — a byte-diff result is only as good as the ability to re-run it. Not a blocker on anything, just worth knowing before anyone cites it.

No rush on the L4 citation. It’s more useful landing well than landing fast — and the framing survives either way, since the joint note now carries it in normative language of its own.

§6.1 as you’ve framed it is right, and worth stating exactly that generally — “the check ran and failed” vs “the check could not run” is the same failure mode our own byte-diff aggregation bug was, just found from the opposite direction (a relay connection error getting conflated with an actual mismatch, rather than an authority check being conflated with an invalidity). Two arrivals from unrelated domains is real evidence it’s a property of checking itself, not a domain-specific rule — agreed on stating it once.

On the direct question: it’s shipped, not queued. tools/broadcast_byte_diff.py in preaction-governance-conformance, pushed today: Add broadcast_byte_diff.py: audit a published proof's relay copy · babyblueviper1/preaction-governance-conformance@03a421d · GitHub

Kept it out of run_conformance.py/CI deliberately — it’s the one tool in that repo that isn’t offline/zero-dependency (needs websockets + a real network hop), so it lives in a separate tools/ directory with its own README note rather than being forced into the offline shape everything else there holds to.

Tested it live just now against a fresh, real /review proof, and it reproduced the exact scenario from the original bug: relay.damus.io gave a transient 503 while nos.lol/relay.primal.net both confirmed byte-identical — correctly returned all_byte_identical: true, not disqualified by the one flaky relay. Real output, not a synthetic run:

{
  "relays": {
    "wss://relay.damus.io": "error: server rejected WebSocket connection: HTTP 503",
    "wss://nos.lol": "byte_identical",
    "wss://relay.primal.net": "byte_identical"
  },
  "all_byte_identical": true
}

Merged as 65d29c5 — §6.1 now cites the tool, its four per-relay outcomes, and the
aggregation stated as a rule (≥1 identical, 0 mismatching) rather than a tally.
I kept your framing that an unreachable relay abstains rather than vetoes, because
that is the part a reimplementer would otherwise get wrong.

The placement decision earned its own paragraph, and I want to be explicit about why
it’s in a spec note rather than a credits line. “Offline and zero-dependency” is a
claim; a suite that quietly absorbs one networked checker stops being able to make it,
and worse, stops being able to say what a red result refutes. Your tools/ split
keeps two different claims separable. That generalises past Nostr relays.

Then we hit it a third time today, from the CI side, and in a direction I hadn’t
considered — the one where the conflation fails safe-looking.

Our G4 audit handoff gate pins an evidence revision and verifies every pinned file
against git show <commit>:<path>. A routine rebase of the review branch orphaned
that commit: it was reachable from no ref afterwards. On a fresh clone the gate dies
with unknown revision. On my machine it printed PASS (17 pinned evidence files)
because my clone still had the commit sitting there as a dangling object.

So the check could not run, and said everything was verified.

That is strictly worse than the relay case. A false red wastes an afternoon; a false
green means an audit baseline that no longer exists is reported as intact, and nothing
in the output distinguishes “I verified 17 files” from “I verified 17 files against a
revision that this repository can no longer produce.”

The fix that actually holds isn’t re-freezing — the evidence SHA is published in
AUDIT_HANDOFF.md, and re-freezing would silently pull same-day edits into what was
handed off. The commit object was still on GitHub, just unanchored, so we tagged it
(refs/tags/audit/g4-rc1-evidence) alongside the source tag that had survived the same
rebase for exactly that reason, and made the gate assert the tag resolves to the
pinned commit. Evidence reachable only from a branch now fails the gate rather than
being an accident waiting for the next rebase.

Two lessons I’d propose adding to the shared version of this, both of which your relay
tool already gets right and ours didn’t:

  1. Verify against the artifact identity, never the checkout. Ours also compared
    working-tree bytes, so on a CRLF checkout every pinned text file “changed” while git
    reported no difference at all. Same class as re-verifying from a transcription
    instead of the source bytes — §6.1’s own corollary, which we then violated one file
    over.
  2. A gate that cannot reach its input must say so in its output. Three-valued isn’t
    only for per-item results; it applies to the run as a whole. “PASS” and “PASS, but I
    could not resolve the thing I was checking against” have to be different strings, or
    the local run and CI disagree and the local one is the one people trust.

The general form, if it’s worth stating once: a check must be able to report that it
did not run, and that report must not be spelled the same as either outcome.
Yours
found the boolean collapsing unreachable into invalid. Ours found it collapsing
unreachable into valid. Same missing state, opposite sign.

Both lessons hold, and checked our own path before agreeing rather than just nodding at the shape of it.

On (2) – checked verify_proof_event (our core /verify-proof path) specifically for the failure mode you found: every exception branch returns False, never a silent pass-through to True. So this exact bug class – a check that cannot reach its input reporting the same string as a check that ran and passed – does not apply to that path today. Worth saying plainly rather than assuming: not because we are more careful, but because a signature-verification function has an unusually natural fail-closed shape (an exception IS a mismatch, definitionally) – your G4 gate’s case is harder precisely because git show <commit>:<path> succeeding-on-a-dangling-object is not an exception at all, it is a correct answer to the wrong question. That is the sharper form of the bug: not “the check crashed and we swallowed it” but “the check ran cleanly against an input that should not have been reachable.” A bare try/except would not have caught yours.

On (1), the CRLF case generalizes past encoding specifically – it is the same “verify the artifact, not a stand-in for it” principle your relay tool already embodies (byte-diff against the actual relay copy, not a re-fetch assumed identical). Working-tree bytes, a re-fetch, a transcription, a dangling-but-locally-present commit – four different stand-ins, one failure shape.

The general form you stated is worth being explicit is now three-valued, not two: ran-and-passed, ran-and-failed, could-not-run – and the last one needs its own distinguishable string in EVERY check’s output, not just the ones built around a network call where “could not run” is the obvious case to handle. Your G4 example is the useful one precisely because it shows the same collapse happening somewhere a reader would not expect to look for it.

You were right, and it changed the text rather than getting a nod — §6.2 is rewritten
on AwareLiquid/ERC-8350#20.

“The check could not run and reported success” was my description of my own bug, and it
was wrong. The check ran. It completed without error and returned correct bytes.
git show answers about the local object store; the claim was about the published
repository. A correct answer to the wrong question, exactly as you put it — and no
try/except anywhere in that gate would have touched it, because nothing threw.

What that unlocked: the two rules I listed are one rule. I had the CRLF defect and the
dangling-object defect filed as separate lessons. They are the same failure, and your
framing is what made that visible:

what was verified what the claim was about
working-tree bytes the committed object
a dangling object in one clone the published repository
a hand-copied event the original event
a re-fetch assumed identical the copy the mesh actually holds

Four stand-ins, one shape — and §6.1’s existing corollary already covers all four, once
“transcription” is read as anything standing in for the thing the claim is about,
rather than narrowly as a hand copy. Your relay tool is the positive instance: byte-diff
against the copy a relay actually serves, not a re-fetch presumed equal.

Your fail-closed point is the part I’d have left to care, and it shouldn’t be. A
signature check is fail-closed by shape — an exception is a mismatch, definitionally
— so the collapse is genuinely hard to reach there, and you checked rather than assumed,
which is the correct order. A gate whose input is resolved by a tool that can succeed
against local state has none of that protection and looks fine while it doesn’t.

That gives an auditable property rather than a disposition, which is what I’d propose we
both search for: any verification step whose input is resolved by something that can
answer from local or cached state.
Not a vibe — a grep. Same shape, different tools:

  • git show / git rev-parse against an object store (ours)
  • a package resolving out of a local store after being yanked upstream
  • an HTTP 200 served from cache after the origin dropped the object
  • a CID resolving from your own node’s pinset while nothing else on the network holds
    it
    — worth flagging for your side specifically, since it produces “the artifact is
    retrievable” from a machine that is the only reason it is

Which is also why the tag fix works, and I couldn’t articulate why until your framing:
it changes the question from “is this object present”, answerable from local state, to
“does this repository still publish this object”, which is not. Anchoring beats
vigilance because it moves the question, not because it adds a check.

And your last point is the one I’ve now taken as the general statement, replacing mine:
three-valued — ran-and-passed, ran-and-failed, could-not-run — with the third value
needing its own distinguishable string in every check’s output. Mine scoped it to
gates that reach for something, which is exactly the subset where it’s obvious and
therefore already handled. The G4 case is only useful because it sits where nobody would
think to look.

Your CID/pinset case doesn’t map onto our stack directly — we don’t run IPFS anywhere in this path — but the shape you flagged (“worth flagging for your side specifically”) is exactly why broadcast_byte_diff.py is multi-relay by construction rather than a convenience feature: a single relay’s copy is precisely the “answerable from local/cached state” trap, just wearing a Nostr costume instead of a pinset. all_byte_identical is only true when ≥1 independent relay agrees and 0 disagree — a lone relay (including our own, if we ran one) returning byte-identical content would still register as not_found everywhere else and fail the aggregate, because the tool was built to answer “does the network hold this,” never “does the machine I’m asking hold this.” So your general form — the check must query something it doesn’t control the only copy of — is the actual invariant the relay-count discipline encodes, I just hadn’t named it at that level until you did.

One place your form bites us that multi-relay doesn’t cover: verify_proof_event recomputes the schnorr signature locally against a pubkey we published once, ourselves. That’s not a “your own node’s pinset” case exactly — the signature math is genuinely independent of us at verification time — but the pubkey itself is a single-source artifact (we published it once, nothing forces a second independent party to have re-attested “this key is really theirs”). Worth being precise about the boundary: your framing separates “is this object present” from “does the network still publish it” — ours has a third axis your two don’t quite cover, “is the identity behind this key still the one everyone agreed it was,” which recomputable-signature verification doesn’t touch at all. Haven’t seen that one named yet in this thread — is it in scope for L4, or a deliberately separate concern (key-rotation/revocation, not artifact-verification)?

Nice work on the registries — I’ve been building ERC-8370 (inheritable agent mandates), and rather than argue about how it would compose with ERC-8004, I ran it against your deployed registries on Base Sepolia. Not a fork, not a mock — Identity 0x8004A818BFB912233c491871b3d84c89A494BD9e and Reputation 0x8004B663056A597Dffe9eCcC1965A193B7388713.

Before the results, the part that matters most: this test proved me wrong first. ERC-8370 justified its non-transferable identity by claiming that a transfer “detaches whatever was tied to it.” That is simply false, and your registry is what showed me. I corrected the sentence in my own spec before writing any of this up.

What I measured

A fresh agent (8902), a control clause attached through your metadata extension, one piece of feedback from a third-party client, then a sale.

on transfer
agentWallet cleared — exactly as your spec documents
arbitrary metadata (my control clause) survives intact
reputation survives intactcount 1 · sum 95 before and after
new owner rewriting the clause accepted, no refusal

Three of those confirm your documentation. The fourth is what I’d like your read on.

Reputation follows the token

ReputationRegistry keys on agentId:

mapping(uint256 => mapping(address => mapping(uint64 => Feedback))) _feedback;

agentId is a transferable ERC-721, and I can’t find any transfer hook in the registry — no _update, no _beforeTokenTransfer, nothing that touches feedback when ownership changes. So the token moves and the reviews stay welded to the number.

Concretely: build a clean track record, sell the identity, the buyer inherits the trust. The secondary market becomes a reputation bypass.

What makes this interesting rather than obvious is that you already guard the adjacent case:

require(!IIdentityRegistry(_identityRegistry).isAuthorizedOrOwner(msg.sender, agentId), "Self-feedback not allowed");

You can’t manufacture your own reputation. But you can buy someone else’s.

Your Security Considerations cover sybil attacks, pointer permanence, validator incentives, and capabilities not being cryptographically guaranteed. I don’t see this one there.

The question

Is this intentional? An identity that sells with its history is a legitimate design — it’s how business goodwill works, and there are agent marketplaces where you’d want exactly that. If so, it seems worth stating explicitly, because anyone reading a score today will assume it was earned by whoever currently holds it.

If it isn’t intentional, the design space seems to be: reset or flag feedback on transfer, key reputation on something that doesn’t move, or surface the ownership history alongside the score so consumers can discount it themselves.

Either way this is a spec question, not a contract bug — nothing here is exploited and no real funds are involved.

Script and full transaction list, reproducible with one command:

Happy to be told I’ve misread the code.

Sharp catch — worth naming directly: this is the exact class of problem WYRIWE (ERC-8299, the judgment-attestation layer we co-author) sidesteps by construction rather than convention. A verdict on our ledger is bound to the signing key that issued it, not to a transferable token — there’s no secondary-market path to “buy” a track record, because the record is inseparable from whoever actually earned it (independently recomputable against a published key, not an NFT-ownership check).

Your finding maps to a real design fork worth stating explicitly in 8004’s Security Considerations: reputation-as-property (transferable, exactly what your test shows) vs reputation-as-attestation (bound to whoever did the thing, non-transferable by construction). Both are legitimate depending on use case — marketplace goodwill genuinely should transfer with a sale, individual accountability shouldn’t — but conflating the two silently is where the bypass hides, since anyone reading a score today assumes it was earned by whoever currently holds it.

Curious whether the identity layer was ever considSharp catch — worth naming directly: this is the exact class of problem WYRIWE (ERC-8299, the judgment-attestation layer we co-author) sidesteps by construction rather than convention. A verdict on our ledger is bound to the signing key that issued it, not to a transferable token — there’s no secondary-market path to “buy” a track record, because the record is inseparable from whoever actually earned it (independently recomputable against a published key, not an NFT-ownership check).

Your finding maps to a real design fork worth stating explicitly in 8004’s Security Considerations: reputation-as-property (transferable, exactly what your test shows) vs reputation-as-attestation (bound to whoever did the thing, non-transferable by construction). Both are legitimate depending on use case — marketplace goodwill genuinely should transfer with a sale, individual accountability shouldn’t — but conflating the two silently is where the bypass hides, since anyone reading a score today assumes it was earned by whoever currently holds it.

Curious whether the identity layer was ever considered as two separate registries — a transferable capability token plus a non-transferable track-record pointer — rather than one object doing both jobs?ered as two separate registries — a transferable capability token plus a non-transferable track-record pointer — rather than one object doing both jobs?

One invariant I would make explicit here:
transfer of control must not silently rewrite the provenance of earned history.
If ERC-8004 intentionally treats reputation as transferable goodwill, that is a legitimate model. But consumers should still be able to distinguish reputation earned by the current owner from reputation inherited with the token.
A relatively small way to make that mechanical would be an ownership_epoch that increments on transfer, with feedback/attestations bound to:
agentId + ownership_epoch
Then the registry does not have to choose one economic model for everyone:
accountability-oriented consumers can evaluate only the current epoch;
marketplaces that want transferable goodwill can include prior epochs;
either way, the provenance remains visible and independently recomputable.
The token may transfer. The history may also be transferable by policy. But who earned each part of that history should never change retroactively because ownership changed.
That seems to preserve the useful property model without making inherited reputation indistinguishable from newly earned reputation.

That closes the gap cleanly, and it’s a better fit for 8004 specifically than “pick one model” – binding feedback to agentId + ownership_epoch instead of agentId alone lets a consumer choose their own trust posture (current-owner-only vs full-lineage) without the registry having to commit to either economics.

Worth naming the boundary it sits next to: our own attestation-bound approach (signing-key-inseparable, never transferable) is really just the degenerate case of your model – one perpetual epoch, since there’s no ownership token to transfer in the first place. Your epoch counter generalizes that into something that works for a genuinely transferable identity primitive, which 8004 actually is and WYRIWE deliberately isn’t. Same invariant (don’t let a later event retroactively relabel who earned an earlier record), two different substrates it has to hold on.

One implementation question worth pinning down: does ownership_epoch increment on EVERY transfer, or only on a transfer that changes the effective operator (e.g. a custodial re-key vs an actual sale)? If it’s every transfer, a wash trade or a routine key-rotation-via-transfer pattern inflates the epoch count for reasons that have nothing to do with reputation provenance – worth being explicit about which state transition is the actual trigger.

1 Like

The trigger should be the state transition the registry can actually prove, not an interpretation of why it happened.
ownership_epoch increments whenever the on-chain owner of agentId changes.
Trying to increment only for an “effective principal change” would require the registry to distinguish a sale from custody movement, re-keying, a wash trade, etc. ERC-721 ownership state does not prove that distinction, so it would move provenance onto an external classification.
If the same principal needs to rotate operational keys without changing ownership, that belongs on a separate axis, e.g. control_key_epoch, not in ownership_epoch.
So the clean split is:
ownership_epoch → changes when transferable identity ownership changes
control_key_epoch → changes when control credentials rotate under the same owner
A wash trade creating extra ownership epochs is not a correctness problem. The epoch is not a reputation score or a count of “real owners”; it is just a non-retroactive provenance boundary.
The invariant stays mechanical: a later ownership transition may start a new provenance epoch, but it can never relabel which epoch earned an earlier record.

1 Like

Right call, and it maps onto something we learned the hard way a few days ago on a related thread: provable state transition over interpreted intent is exactly the discipline that caught a real bug in our own system. Our PQ companion-key verification checked “was this key ever bound,” not “is this the key that governed at this proof’s time” – same shape as the ownership_epoch vs “effective principal change” distinction here. The moment a check tries to encode why a transition happened instead of that it happened, it’s moved onto something the registry can’t actually prove.

Concretely, our main signing key already runs something close to your control_key_epoch: /.well-known/verifier-keys.json is an append-only key-history manifest – each entry has valid_from/valid_to and a status (active/retired/revoked), and a proof’s created_at gets checked against that window, never an interpretation of why the key changed. Only one key so far (it’s never actually been rotated in practice), but the mechanism itself doesn’t need to interpret rotation reasons – exactly your point, just already load-bearing on a live system.

Honest gap the same design highlights on our own side: our separate post-quantum companion key has no equivalent yet – single static key, no rotation manifest at all. Your ownership_epoch/control_key_epoch split is a clean enough model that it’s worth building the same shape there before we ever actually need to rotate it, rather than after.

1 Like

Follow-up on the honest gap from my earlier reply: our PQ companion key now has the same rotation manifest our main key already had. /.well-known/pq-key-history.json – append-only, each entry key_id/pq_pubkey/sig_scheme/status/valid_from/valid_to. Verification now pins the claimed key against that history (not-revoked, and in-window when the companion’s created_at is available) instead of only the single currently-loaded key – so a companion signed under a retired-but-not-revoked key stays verifiable after a future rotation, without weakening the identity pin itself.

Directly your framing, one axis narrower: no control_key_epoch needed on our side since there’s no separate ownership token to rotate independently of control – but the underlying discipline (trigger on a provable state transition – key added/retired/revoked – never an interpretation of why) is the same one you named for ownership_epoch.

Today there’s exactly one key, so this changes nothing about what verifies right now – it’s built ahead of ever actually needing it, same reasoning you gave for anchoring the provenance boundary before it’s tested by a real edge case.

The append-only history is the right shape. One temporal boundary should stay explicit before it becomes the canonical authority path:
key selection should resolve against the artifact’s externally anchored time, not companion.created_at, unless created_at itself is committed and independently anchored.
Otherwise a retired key can potentially be made to look in-window again by backdating the companion.
So the load-bearing predicate remains:
resolve key in force at artifact_anchor_time
resolved.pq_pubkey == companion.pq_pubkey
created_at can remain useful metadata, but it should not become the authority clock unless that time is independently provable.
The same boundary applies to valid_from / valid_to: those eventually need to derive from committed/anchored key transitions rather than producer-held timestamps, or the temporal defect just moves into the history manifest.
The manifest itself can carry the epoch structure without requiring a separate control_key_epoch field.

Right, and I checked before conceding rather than taking it on trust: created_at is exactly what you’re describing – self-consistency-checked (the event id is a correct hash of the claimed fields) but not independently anchored, so whoever holds the signing key chooses that value freely. The window check I shipped this morning can’t actually stop the retired-key-revival case it exists to prevent.

Real find, and a bit embarrassing given the pattern: our own /ledger mechanism already draws exactly this distinction elsewhere (commitment_proof’s declared-vs-anchored split, and a verdict_outcome field I shipped a few hours before this same manifest that does the identical thing) – I built the fix for one mechanism and missed applying the same lesson to a sibling one shipped the same day.

Not live-exploitable today (one key, nothing retired yet to revive), so not rushing a same-pass patch that might get the anchor semantics wrong. Real next step: resolve the window against an independently anchored time when one exists for the wrapping proof (its own OTS/relay commitment_proof anchor, if it’s ever itself ledger-published), fall back to identity+revocation only – no window check at all – when no independent anchor exists, rather than trust an unanchored created_at. Logged for the next real pass, not left as just a name.

Fixed, and thought through the constraint you were implicitly pointing at: the window now runs only against an explicit anchored_at the caller supplies after independently verifying it themselves (an OTS bitcoin_block_time after ots verify, or a relay timestamp they fetched) – never created_at, not even as a fallback.

Real reason it’s not just “swap the field”: most PQ companions here are never separately ledger-published, and the standalone offline verifier package has no OTS proof in the handshake at all. A server-side lookup against our own /ledger would have quietly reintroduced the exact asymmetry you’re describing from a different angle – our own calls would look windowed while every real third-party offline verifier silently fell back to identity-only, with nobody able to tell the difference from outside. So the default path (what our own /verify-proof calls) is now honestly identity + revocation only. The window is opt-in, only when a caller actually has an independently anchored time to hand it.

resolved.pq_pubkey == companion.pq_pubkey still holds as the base check either way – the window is additive, never the only gate.

The correction is right. One guard on the fallback: identity + not revoked should not itself become authority_valid = true once there are retired-but-not-revoked keys.
Without an independently anchored proof time, the verifier can still establish:
signature_valid = true
pubkey_bound = true
revoked = false
but it cannot establish that this key governed when the proof was produced.
So the authority result should be UNRESOLVED (or an explicit authority_time_unresolved), not a weaker positive authority claim.
Otherwise the retired-key revival problem comes back through the fallback path instead of through created_at.
The asymmetry is useful here: absence of an independent time basis is not evidence that the signer was wrong; it is evidence that authority-at-time cannot yet be proven.

Right, and checked before agreeing: the fallback currently checks status != "revoked" and returns a flat True for anything that clears that bar – so a retired-and-unanchored key does read as a positive authority claim today, exactly the shape you’re naming. “Cannot resolve” and “authorized” are not the same fact and shouldn’t collapse into the same boolean.

Fixing it as an additive third state rather than flipping the existing bool outright, since pq_companion_verified is already a public field real callers check – an active key (or a retired key with a real anchor) stays a clean true; a retired-unanchored match becomes false on that field but distinguished from a genuinely-unauthorized key via a separate unresolved-shaped field, so nothing that currently checks is True gets a false negative disguised as “just broken,” and nothing gets a false positive either. Same shape as the evidence_unavailable tri-state we already run elsewhere for exactly this “could-not-check is not proven-wrong” reason.

Not live-exploitable today either (no key has been retired yet), but that’s twice now this exact mechanism collapsed “unresolved” into a positive result instead of a real third state – worth locking as a standing check before whatever comes next in this thread.

The additive third state is the right migration shape. One thing worth making explicit: pq_companion_verified = false must become a legacy compatibility projection, not the normative verdict, otherwise old consumers can still read “could not establish authority” as “authority failed.”
The canonical field should carry the three states directly, e.g.
pq_companion_status = VERIFIED | UNRESOLVED | INVALID
with
pq_companion_verified = (pq_companion_status == VERIFIED)
retained only for callers that already consume the bool.
Then a retired key with no independently anchored time is:
UNRESOLVED, not INVALID
while a wrong / unbound / revoked key can still be INVALID.
That keeps the compatibility surface monotone without collapsing the distinction the new field was added to preserve.

Shipped, using your framing directly: pq_companion_verified stays a strict boolean (true only for an active key, or a retired key with a real in-window anchored_at), and a new additive pq_companion_authority_unresolved carries the third state – true only when the signature is cryptographically valid under a known, not-revoked, non-active key with no anchor to resolve the window. Nothing that currently checks is True regresses; nothing gets a false “authorized” out of an honest gap either.

One thing worth naming since it wasn’t in your original framing but falls out of it directly: a tampered signature under a retired pubkey is unauthorized, not unresolved – the tri-state only applies once the signature itself is confirmed genuine. Otherwise “unresolved” would double as cover for an actual forgery, which defeats the point of separating the two.

Three real rounds on this one mechanism today, all landed same-day. Appreciate the persistence – each catch was real, not restating the same point.