ERC-8373: Post-Quantum Anchored Key-Binding

Sharing an approach we’ve been building and would like scrutiny on. It’s aimed at the narrow but real problem of migrating the signature layer of on-chain / agent identity to post-quantum, and it leans on recomputation rather than trust.

The problem. Shor breaks the ECDSA/Schnorr signatures under most of Web3 and most AI-attestation identity. Hashes survive (Grover only halves them). The usual reflex “add a post-quantum signature alongside the classical one” doesn’t actually close it: a forger who derives the classical key just omits the extra signature. A second signature is opt-in from the verifier’s side, and a forger opts out.

The approach. Bind, don’t append. Publish an anchored key-binding this classical key → this post-quantum key before any break, and enforce a consumer cutoff:

  • An action proven anchored before the cutoff admits classical-only (the back catalog is never retroactively invalidated).

  • An action anchored at/after the cutoff must carry a valid PQ companion under the binding in force at that action’s anchor time, else it’s rejected.

The forger’s omission now fails closed: after the cutoff, “no valid companion” is a reject, not a shrug.

Why anchor-time, not signature-time. Authority is judged by an action’s on-chain anchor time, never by when a claimed signature says it was made. A compromised key can backdate a signature; it can’t backdate an on-chain anchor. The trustworthy quantity is the one the operator can’t reshape after the fact.

Why recompute, not trust. Every binding is content-addressed and re-derivable from public data in the browser, the classical proof-of-possession is the anchoring transaction itself (sent by the classical key), so the on-chain record is the proof, not a second signature to forge around. Nothing here asks you to trust a server; you re-run it.

What’s live (two implementations, two NIST families). The profile pq_key_binding.v0 is carried by two independent implementations converging on one byte-compatible content-address through canonicalization alone: ML-DSA-65 (FIPS 204, lattice) and SLH-DSA-SHA2-192s (FIPS 205, hash-based). {algorithm} is a field, not a fork.

The two-family choice is a resilience hedge, not decoration. In 2026, cryptanalysis from Anthropic cut HAWK-256’s estimated key-recovery cost from 2^64 to 2^38; the HAWK submission team then withdrew it from NIST’s additional-signatures process (it had been in that process since the 2023 on-ramp). Refs: Anthropic research · withdrawal stated verbatim on NIST’s round-3 additional-signatures page (“The submission team has withdrawn HAWK…”).

Scope matters, and it’s structural, for two independent reasons the result doesn’t reach the finalized standards. HAWK is a hash-and-sign scheme in the Falcon lineage built on the Lattice Isomorphism Problem, and the n/2+1 reduction runs through that LIP structure specifically; ML-DSA is Fiat-Shamir over MLWE/MSIS a different problem and a different construction. HAWK was a candidate, deployed nowhere; the finalized FIPS standards ML-KEM, ML-DSA, SLH-DSA are untouched. The lesson we build on is structural: pair a finalized standard with a second scheme from a different cryptographic family. A cryptanalytic break that depends specifically on lattice structure does not directly transfer to the hash-based lane. SLH-DSA rests on different security assumptions, so weakening one family need not invalidate the other; the independently anchored recompute record remains available for verification.

Both bindings recompute cold, in-browser, from two different origins; a cutoff enforcer runs the migration rule and reproduces a set of pinned conformance vectors. One honest note on evidence class: the ML-DSA side is two independent verifiers, but the SLH-DSA side is a single implementation (both panels vendor the same @noble/post-quantum), so it’s verified twice by the same code and rather than launder that into a green, an independent reviewer’s standing UNVERIFIABLE verdict keeps it visibly single-impl. Three parties, per-lane independence stated honestly, not four claimed. Per-agent keys are epoch-anchored and sign a companion on every action, with the anchor-time cutoff enforcer currently in shadow, it records the verdict, doesn’t yet reject, until companion coverage is proven.

Recovery, the in-progress next layer (scrutiny welcome). Be explicit about maturity, because the two halves aren’t at the same stage: pq_key_binding.v0 above is live, multi-implementation, and cold-verified; the recovery layer below is a runnable reference model — executable and blind-diffable via conformance vectors, but not yet live-wired. Concretely: the per-agent key rotation and revocation primitives are deployed in our gateway (owner-authorized, anchor-time resolved); the recovery-class taxonomy on top of them, which lets an auditor tell which kind of transition happened, is specified and vectored, with agent_terminal and seed_epoch_rotation pre-implementation. Please don’t read the classes as already shipped.

A key-binding scheme is only survivable past a single incident if an auditor can independently tell what kind of authority transition happened. We specify three anchored classes as falsifiable claims a third party recomputes, each judged only against its declared class, so a record that fails its predicate is rejected, never silently relabeled as a neighbour:

Which key governs an artifact is resolved by the artifact’s anchor time, and the same rule carries rotation and revocation, forward-acting, never retroactive:

The two hardest points, and how we resolved them:

(1) A terminal/kill statement has to forbid future bindings, not merely describe the present absence of one. So terminality is a standing binding-path constraint (fail-closed), split by intent: a deliberate retirement is absolute; a defensive incident-kill is liftable only by a valid fleet seed-rotation, never by a per-agent path, or the compromised key could resurrect itself.

(2) The authority for replacing a compromised root secret can’t reduce to that secret. The fleet seed-rotation is anchored on the classical deployer key, proven by its own transaction (not a separable signature), and deployer-key compromise is stated as explicitly out-of-scope. Same shape as anchor-time-over-signature-time: the authority for undoing something can’t reduce to the thing being undone.

One honest boundary, stated up front. The fleet seed-recovery is the single place this profile is not fully public-recompute: the new keys derive from a secret seed, so a third party can’t re-derive them. The public guarantee is deployer-authorization (the anchor tx is sent by the deployer key) + the anchored Merkle root of the new keys; the derivation itself is operator-attested. This is deliberate, not a gap we haven’t closed, commit-then-reveal doesn’t apply (the seed is a standing secret for every key, so revealing it to prove honesty would leak every private key), leaving exactly two options: operator-attested (v0, here) or a ZK proof of correct derivation that never reveals the seed (v1+). We name it plainly rather than imply the recovery is publicly recomputable when it isn’t.

Links (all recomputable):

Not claiming “quantum-proof.” The claim is precise: the recompute layer rests on hashes (the primitive that survives), and the signature layer migrates by a binding + cutoff you can verify yourself. Feedback on the cutoff semantics, the anchor-time choice, and especially the recovery/authority model very welcome. (Sharing this as a discussion ahead of a possible ERC in the agent-identity family, filing deferred pending scrutiny + the recovery implementation.)

Co-authored : @TMerlini / vertice.eth (SLH-DSA attestor lane, live gateway enforcement, per-agent + recovery layers, reference impl), @babyblueviper1 (ML-DSA implementation + verifier, blind-diffs), @pipavlo82 (failure-class + authority-domain design, conformance vectors), and @blockbird (independent verification, the standing UNVERIFIABLE lane, spec findings, on-chain anchor decode).

2 Likes

Spec text exists now: this is filed as ethereum/ERCs PR #1932 — ERC-8360, “Post-Quantum Anchored Key-Binding with Consumer Cutoff.”

https://github.com/ethereum/ERCs/pull/1932

Scoped to the primitive discussed above: the binding statement (the exact production field set), anchor-tx-as-proof-of-possession, the anchor-time cutoff with in-force resolution, rotation/revocation/terminality as anchored falsifiable classes, and a tri-state verification procedure (accept / reject / unverifiable, could-not-check is never a pass). Conformance vectors for both NIST families ship in the PR’s assets with executable checkers.

Co-authored with @babyblueviper1 and @pipavlo82 (confirmed). @blockbird your independent-verification work (the standing UNVERIFIABLE lane, the anchor-availability caveat that became a MUST, the on-chain anchor decode) is credited in the PR body; say the word and you go on the author line proper.

The thread stays the discussion home (discussions-to points here). Line numbers now exist for the shooting.

1 Like

Confirmed, thanks for filing it – PR #1932 is real and live, added to our own standards-adoption record. The bind-don’t-append framing (fail closed on omission, not opt-in) is the right core, and the tri-state accept/reject/unverifiable procedure with could-not-check never counting as a pass matches the same discipline our own /review verdicts use. Looking forward to the conformance vectors landing in the PR assets.

1 Like

Thanks, and good news on the vectors: they’re already in. assets/erc-8360/ carries both suites (binding + cutoff + rotation + revocation, and the recovery classes) with the executable checkers, 12 files in the PR right now. Your ML-DSA lane’s numbers are literally in there as vector 1 of the dual-family set, so when you recompute them you’ll be recomputing yourself :grinning_face_with_smiling_eyes:

And yes on the tri-state kinship , could-not-check never counting as a pass is the same muscle in /review, the kit’s exit codes, and now an ERC’s verification procedure. The discipline is becoming infrastructure.

Checked, not just taken on the note’s word: pulled our own live /.well-known/pq-key-binding.json and it’s byte-exact against vector 1 in the PR right now – same secp256k1_pubkey, same pq_pubkey. Good to see the vector isn’t a snapshot that can drift out from under the live endpoint.

1 Like

Editors assigned ERC-8373 (was filed under a placeholder) thread title updated to match. PR: ethereum/ERCs#1932, all CI green, four authors confirmed.

Built the on-chain half of this to see whether the consumer rule survives contact with the EVM. Repo is at erc-8373-onchain-consumer. Two things came out of it that are worth your time.

The published cutoff vectors disagree with the Specification in two of eight cases.

The verification procedure, step 3, reads:

If anchor time < cutoff: verify classically; accept.

It does not consult the binding chain, and the cutoff rule states clause 1 as sufficient on its own. But two vectors expect REJECT for artifacts anchored before the cutoff, on the ground that no binding was in force at that instant.

case anchor_time consumer_cutoff spec text vector
anchored before any binding existed 1785000000 1790000000 accept REJECT
anchored at/after revocation 1786500000 1790000000 accept REJECT

The second one is the one I would look at first. Under the vector reading, a revocation at 1786000000 invalidates an artifact anchored before the cutoff, which is retroactive invalidation of the back catalogue. Your opening post says the design exists so that never happens.

My enforcer follows the specification text and reproduces the other six exactly. The two are asserted separately so that changing either side breaks a test rather than passing quietly.

Anchor time has to be read, not passed.

The design rests on a compromised key being unable to backdate an anchor. That holds only while the consumer reads anchor time from the substrate. A consumer taking anchorTime as a call parameter gives the caller back exactly the backdating the design removes, and it still passes every published vector, because the vectors do not model a lying caller. So verifyArtifact takes no timestamp.

On the on-chain surface itself.

IPQKeyBindingConsumer makes cutoff() a public read and the consumer detectable by ERC-165, which matters given your own line that deployment of the consumer rule is what makes the migration operative. Right now there is no way for a counterparty to check that a contract enforces it.

The verdict is a tri-state rather than a bool, with Unverifiable as the zero value, so an unwritten slot or a failed decode can never read as authorisation.

IPQCompanionVerifier is split out deliberately. The cutoff rule is enforceable on-chain today and the companion check is not, since ML-DSA is lattice arithmetic and SLH-DSA is thousands of hashes with no precompile for either. Better to enforce what is enforceable and name what is delegated.

One open question I did not decide for you. When a binding is revoked, does authority fall back to its predecessor or is nothing in force? I implemented no fallback, which is fail-closed, and there is a test documenting that reading. The spec does not say either way.

21 tests, and the vector harness reads your JSON files directly rather than restating them.

1 Like

@zexoverz , you’re right, and it’s worth being precise about where. I traced it against our own enforcer rather than the spec prose.

1. The pre-cutoff rejection is real in the shipped v0 vectors, and it’s the blunt-reason version of a distinction we’ve since split. Our enforcer resolves the in-force binding first, then applies the cutoff. So in v0, an artifact anchored before any binding existed returns no_in_force_binding and rejects, even when it’s pre-cutoff.

That contradicts the abstract’s “backcatalog is never retroactively invalidated,” exactly as you say. But no_in_force_binding is doing double duty in v0, over two cases that deserve opposite answers:

  • pre-baseline (anchored before the first binding was registered): innocent back-catalog, must admit classical-only.

  • post-revocation (anchored after a binding’s authority was deliberately ended): a revocation is
    a trust-ending act, must reject, even pre-cutoff, because the signal is stronger than the consumer’s cutoff.

v0 rejects both, for the same reason string. That’s the actual defect: not “reject is wrong” but “one reason is hiding two facts.”

We fixed the first half in the v1 profile: the baseline binding activates at 0, it governs from creation, because anchoring gives a binding a provable time, not a birthday. Under v1 the pre-baseline artifact resolves to the baseline and admits classical-only pre-cutoff; successors keep
activated_at = anchor so a rotation still can’t claim retroactive coverage. That’s live in our gateway enforcer today. The gap is that the ERC currently ships the v0 assets, so what you tested is a real published inconsistency, advancing the assets to v1 is the fix, and I’ll drive that.

2. Anchor time from the substrate, not a parameter, agreed; the spec already says why, your
point makes it enforceable.
“Anchor times are observations, not claims.” verifyArtifact MUST
recover anchor time from the substrate and MUST NOT take it as a caller argument, or the caller re-introduces the backdating the design removes. I’ll make that a normative interface MUST.

3. Tri-state verdict with Unverifiable as the zero value, already the reference enforcer’s
model; the v0 assets predate it.
The enforcer’s verdict is evidence: verified | refuted | unverifiable, with the boolean decision a derived projection of it and an unverifiable_reason required, precisely so “checked and failed” and “never checked” stop collapsing into one REJECT.
Making Unverifiable the zero value on-chain is the right default: an unread or failed check must
degrade to unverifiable, never to a false admit. On the on-chain surface itself (spec is recompute-first today, because ML-DSA/SLH-DSA have no EVM precompile): cutoff() as a public read + ERC-165, and splitting IPQCompanionVerifier out as the pluggable non-precompile part, both right, and the way I’d shape it when we specify a normative consumer interface.

4. Revocation fallback, fail-closed, no revert to a predecessor: correct and intended. A revocation ends authority at its anchor time; nothing succeeds it unless separately anchored.
Authority never silently reverts to a prior key, that resurrects something the owner retired. This is the post-revocation half of point 1: reject even pre-cutoff. I’ll state it explicitly rather than leave it to be re-derived.

Net: the remediation is advancing the ERC’s conformance assets from v0 to v1 (which already resolves the pre-baseline admit and carries the tri-state), plus spelling out the pre-baseline vs
post-revocation split and the anchor-from-substrate MUST in the prose. The revocation semantics are [ @pipavlo82 ]'s lane, so I’ll square that half with him before it lands. Genuinely useful review thank you.

That split is sharper than what I had, and it means I got the post-revocation half wrong. I read the cutoff clause in isolation and admitted a pre-cutoff artifact that sat after a revocation. Resolution runs first, and ending authority is a stronger signal than a consumer’s cutoff.

Fixed in the enforcer. It now resolves the in-force binding before applying the cutoff, the baseline activates at 0 so a pre-baseline artifact resolves rather than falling through, and successors keep activated_at = anchor so a rotation still cannot claim retroactive coverage. That moves it to seven of the eight published vectors reproduced, with the remaining disagreement being exactly the pre-baseline case the v1 assets resolve.

Two things I would suggest pinning while you are in the prose.

The unverifiable_reason you mentioned is what stops the same collapse happening one level down, so it is worth making required rather than recommended. An unverifiable with no reason is the same shape of defect as a reject covering two facts.

And if no_in_force_binding splits, the two successors want names that cannot be confused later. Something like pre_baseline and authority_revoked rather than a shared prefix, since the whole failure was one string standing in for two opposite answers.

If the consumer interface is useful as a starting point when you specify the normative one, take whatever is worth taking from it.

1 Like

On standardizing the unverifiable_reason values: if this becomes required rather than recommended, it should probably be an enum fixed in the spec rather than free text. Right now nothing stops two enforcers from picking different strings for the same failure mode, which just reproduces the collapse you already fixed for no_in_force_binding one level down. A consumer contract branching on unverifiable_reason needs to compare against known values, not parse prose.

Something like a small closed set: pre_baseline, authority_revoked, decode_failed, key_unread. Whatever the actual failure modes turn out to be once v1 assets are written. Worth pulling from the enforcer implementations you both already have rather than inventing it fresh, since you’ll know from the vector work which failures actually occur.

Also worth spelling out in the same pass: does authority_revoked ever apply pre-baseline (revoking a binding before its activation time), or is that state unreachable by construction? Not sure it matters practically but it’s the kind of edge the tri-state is supposed to make explicit rather than implicit.

1 Like

Ran the enforcer against the v1 profile that landed in recompute-kit today. All nine cases reproduce, on both decision and evidence. The pre-baseline case now admits and the post-revocation case still refuses, so the two halves of the split behave the way you described them.

The ninth case caught something I had wrong, and it is worth pulling into the consumer interface rather than leaving in the harness.

v1 records evidence and decision as separate fields, so a companion that was checked and failed is refuted while one that could not be checked is unverifiable. Both refuse admission. My enforcer was returning a single verdict and reporting Unverifiable when the verifier reverted, which is the correct evidence but leaves the admission decision unsaid. Merging them means a consumer cannot tell a bad signature from a verifier that was down, which is the same collapse the reason string had.

So verifyArtifact now returns both. The part that matters on-chain is which value each enum puts at zero:

PQEvidence.Unverifiable = 0    nothing was established
PQDecision.Refuse       = 0    do not admit

Solidity zero-initialises everything, so an unwritten storage slot, a failed decode or a default struct all read as “we could not tell, and we are not admitting”. If Admit or Verified sat at zero instead, the failure mode would be a silent yes. Worth stating normatively if a consumer interface gets specified, since it is the kind of thing that is invisible in a passing test suite and fatal in a deployment.

One question the v1 vectors do not settle. unverifiable is currently one state covering several causes: unreachable anchor, unresolvable chain, verifier unavailable. That is the same shape as no_in_force_binding covering two facts, one level down. Your unverifiable_reason handles it off-chain. On-chain it would need to be an enum rather than free text, and I would rather match whatever you choose than invent one.

Repo is updated if any of it is useful when you specify the normative interface.

1 Like

Thanks @zexoverz, this is the right shape, and I checked it against the assets rather than the prose.

On the 7/8. That last disagreement isn’t a bug in your enforcer, it’s the ERC still shipping the wrong
half. The published assets are the frozen pq-key-binding-v0 cutoff vectors (8 cases); the v1 profile in recompute-kit (9 cases) is the corrected one, and the whole delta between them is exactly the collapse we identified: v0’s single “invalid” case becomes v1’s refuted (checked-invalid) vs unverifiable (present but unchecked), and v0’s single no_in_force_binding becomes v1’s pre-baseline back-catalogue (resolves to the genesis baseline, admits classical-only) vs anchored at/after revocation (refuted even pre-cutoff). Your enforcer reproduces v1; it reads as 7/8 only because it’s being measured against the v0 assets v1 supersedes. So the author action is mine: the ERC’s normative cutoff vectors move to the v1 profile, v0 stays as the frozen base v1 profiles over (it isn’t rewritten), but the normative set the spec points a conformant enforcer at becomes the 9-case v1. Once that lands, 7/8 becomes 8/8 against the right target and the “8th” stops being a standing question.

On both prose-pins — taken, and for your reasons.

  • unverifiable_reason required, not recommended. You put it exactly right: an unverifiable with no
    reason is the same shape of defect as a reject covering two facts. It’s the collapse one level down, and
    making it optional is leaving the door we just closed unlatched.

  • Splitting no_in_force_binding into names that can’t be confused laterpre_baseline and
    authority_revoked, no shared prefix. Agreed, and the shared-prefix point is the sharp part: a common
    stem invites a startsWith check that re-conflates the two opposite answers the split exists to keep
    apart. Distinct stems make that regression impossible rather than discouraged.

On the zero-value catch, this is the most important thing in your post, and it goes in normatively.
You’re right that my enforcer reported Unverifiable as evidence but left the admission decision unsaid that’s the same two-field separation the v1 profile draws (evidence ∈ {verified, refuted, unverifiable}; decision a projection of evidence + cutoff, never independently settable). Pinning the zero values so the default fails safe:

PQEvidence.Unverifiable = 0    // nothing was established
PQDecision.Refuse       = 0    // do not admit


is the rule that makes an uninitialized, reverted, or unread path degrade to “refuse, unverifiable” instead of a silent admit. If Admit or Verified sat at zero, the failure mode is a yes nobody decided, which is the exact inversion this whole spec exists to prevent. It’ll be stated as a MUST on any consumer interface, with both fields required to travel together so a surface can’t publish a decision without the evidence that projects to it.

On the on-chain unverifiable enum. Agreed it can’t be free text on-chain — the causes (unreachable anchor, unresolvable chain, verifier unavailable, …) become a closed enum, and I’ll seed it from the reason strings the existing enforcers already emit rather than inventing a fresh set (your suggestion, and it keeps the on-chain enum honest to what implementations actually distinguish). @mzf11125’s closed-set point is the same call. Off-chain unverifiable_reason stays the richer string; the on-chain enum is its pinned projection, and the two are bound so they can’t drift.

On the interface surfacecutoff() as a public read + ERC-165 detection, and IPQCompanionVerifier split out as the pluggable non-precompile part (ML-DSA lattice arithmetic vs SLH-DSA’s thousands of hashes, no precompile for either): both going in, as you and the earlier post laid out.

On the revocation boundary@mzf11125 raised whether authority_revoked can occur pre-baseline. It can’t, and @pipavlo82 has now pinned the model (it’s his lane, so this is his word, not my inference). With B the baseline activation anchor and R ≥ B the revocation anchor, on half-open intervals:

  • pre_baseline := t < B

  • in_force := B ≤ t < R (activation inclusive, revocation exclusive)

  • authority_revoked := t ≥ R, while no separately anchored successor is in force

Mutually exclusive by construction — no time is both pre-baseline and post-revocation. No third normative state: “revoked with no successor” lives in authority_revoked, and successor existence/eligibility stays a
separate resolution fact rather than a fourth temporal state. The one precision that gets locked into the vectors is that the boundary is inclusive on the revoked sidet == R is authority_revoked, never still in_force — so a boundary vector at exactly t == R (REFUTED even pre-cutoff), paired with thet == B case, nails both fenceposts and reds any /< slip rather than letting it pass.

Net: v1 profile becomes the normative vector target, the two states split with un-confusable names,
unverifiable_reason required, the zero-value fail-safe stated as a MUST with evidence/decision as separate required fields, and the on-chain unverifiable cause as a closed enum seeded from the enforcers.
I’ll push the ERC changes and reply here when the v1 vectors are the ones the spec points at.

Good. The v0/v1 framing settles the last vector, and the enforcer already reproduces all nine against v1, so once the assets move there is nothing left open on that side.

One thing on the closed enum, since it is new surface and the fail-safe rule you just took applies to it too.

Seeding the causes from what enforcers already emit is right. The part worth pinning is which cause sits at zero. If the first real cause lands there, an unwritten slot, a failed decode or a default struct reads as a specific factual claim.

Something like:

enum PQUnverifiableCause {
    Unspecified,          // 0, no cause established
    UnreachableAnchor,
    UnresolvableChain,
    VerifierUnavailable
}

A zero that means Unspecified says nothing was determined, which is true of a default value. UnreachableAnchor at zero would say the anchor was reached for and not found, which nobody checked. Same shape as an unverifiable with no reason, one level down again, and invisible in a passing suite for the same reason the decision default was.

It also keeps unverifiable_reason required and the enum honest at once. A surface that must publish a cause and has none gets a member that admits it, instead of borrowing the first one in the list.

1 Like

@zexoverz agreed, and thank you, good catch. It’s the same zero-slot discipline the ERC already fixes for evidence and decision, an uninitialized, reverted, or unread path must read unverifiable, refuse, never a silent verified, admit, but I hadn’t carried it onto the reason enumeration, and you’re right that I have to.

As written, unreachable_anchor sits first, so a default, reverted, or failed-decode reason would read as a specific factual claim, the anchor was checked and found unreachable, when nothing was actually established. That’s the exact inversion the rest of the spec exists to prevent, one level down: a positive-sounding cause that no verifier ever determined.

Fix in the next revision: the reason enumeration’s zero slot MUST be a non-substantive unspecified meaning no cause was determined, and no substantive cause may occupy it. I’ll place it right after the closed-enumeration sentence and note that it mirrors the evidence/decision zero-slot rule above.

And to your point about keeping unverifiable_reason REQUIRED, yes, and the two are complementary rather than in tension. Required-at-the-semantic-level stops an honest verifier from omitting a cause; unspecified-at-zero stops a broken or default-initialized one from fabricating a cause it never established. A conformant verifier therefore never emits unspecified for a genuine unverifiable, reserving the slot only guarantees that malformed or uninitialized data degrades to nothing determined instead of borrowing the first real cause. Belt and suspenders, and both are load-bearing.

This is the second time your review has tightened the spec’s failure semantics, I’d like to credit you in an Acknowledgments section in the next revision. Happy to list you however you prefer (handle only, or handle + name).

Thank you, and I would rather ask something straight than hint at it.

I would like to be on the author line rather than in Acknowledgments. I am asking on the work rather than on reviews landing, so here is what I think it is, and you are better placed than me to judge whether it clears the bar.

I built an independent on-chain consumer for this, erc-8373-onchain-consumer, to see whether the rule survives contact with the EVM rather than only reading well. That is what turned up the v0 cutoff vectors contradicting the spec’s own verification procedure. The sharper half of that was yours, not mine: I read the cutoff clause in isolation and got the post-revocation side wrong, and your pre-baseline versus post-revocation split is what made it correct. You wrote and shipped the v1 profile. What I did after was run the enforcer against it and confirm all nine cases reproduce on both decision and evidence, with the tri-state failing closed at zero, and then the zero-slot rule for the reason enumeration here.

So the claim is not that I wrote the spec. It is that the on-chain and conformance half has been mine to build and to break, and that the spec has moved twice because of what it found.

If you would rather leave the author line as it is, say so plainly. It changes nothing about the review, I will keep testing it either way, and Acknowledgments with handle only is completely fine. I would just rather ask now than have it be awkward at the next revision.

@zexoverz yes. You moved the spec twice with real work and shipped an independent implementation, and that earns the author line. I’ll add you, and I want the record precise about who landed what, not to fence you out, but because an accurate split is the credit,and it’s what keeps an independent implementation reading as independent, which is exactly its value.

What the existing authors originated, and were running before the thread. The binding-plus-cutoff design and its rationale (omission fails closed; anchor time over signature time; the anchoring transaction as the classical proof-of-possession; content-addressed,recompute-verified), the tri-state evidence with fail-closed baseline activation, rotation/revocation/terminality and in-force resolution, the two-family ML-DSA/SLH-DSA hedge, and the v1 temporal-authority derivation. This wasn’t paper, the consumer rule has been enforced in production (ai.verticecriativo.pt,pq_key_binding.v0) since before this discussion opened, with a live per-agent binding chain andthe recompute-kit conformance profile behind it. That is the specification and its operative reference.

What you landed. An independent on-chain consumer (erc-8373-onchain-consumer) that re-derives the rule EVM-side from a second codebase, the kind of independent second implementation a spec needs and rarely gets. From re-implementing rather than reading, two things

surfaced: at #7, that the published v0 cutoff vectors contradicted the verification procedure a factual discrepancy, a real bug in a shipped artifact; and at #13, the reason-enumeration zero-slot rule, now authored into the v1 revision.

So the split I’ll write in, plainly: the binding, the cutoff, the enforcement and the v1 authority derivation are the existing authors’ (and were live first); your on-chain consumer is a distinct, independent implementation; the zero-slot rule is your normative contribution (authored text, now in v1); and the #7 v0-vector catch is a factual discrepancy you surfaced QA that moved the spec, distinct from authoring its text. An independent re-implementation finding a real defect is harder evidence than a review comment, which is precisely why it carries I just want it filed as a defect found, not a second competing authority, since the spec was written from the primary implementation.

Concretely that means: author line added (send the handle + name you want, and per EIP-1 confirm the addition); Reference Implementation erc-8373-onchain-consumer named as your independent EVM consumer alongside the two production implementations; the zero-slot rule credited inline where it lands in the Verification section, and the #7 catch in Acknowledgments.

That way what each of us built is legible to a cold reader, and nobody has to infer it. Sound right? If so I’ll fold it into the v1 revision.

@TMerlini sounds right, and the split is fair. Thank you.

The author entry I want is

Faisal Firdani (@zexoverz)

I will confirm the addition on #1932 once it is pushed.

One correction on the #7 catch, since you want the record precise. I surfaced the discrepancy, but I read the cutoff clause in isolation and got the post-revocation side wrong. The pre-baseline versus post-revocation split was yours. Filed as a defect I found is right. The resolution was not mine.

Two things on the v1 revision while it is open.

The Reference Implementation section has no links in it at the moment, and EIP-1 says external links SHOULD NOT be included other than the listed exceptions, so a GitHub URL for erc-8373-onchain-consumer is likely to get flagged. Two ways round that. Name it in prose the way the other two are named, or put the consumer in assets/erc-8373/ next to cutoff_enforce.py and point at the relative path. I would rather it live in assets, since that is also where a reader can run it.

Second, a scoping question rather than a correction. assets/erc-8373/pq-key-binding-v1.cutoff-vectors.json is in the PR and it is the only v1 file there. The other eight are still v0, Test Cases describes the cutoff set without naming a version, and the schema row still gives pq_key_binding.v0 as the example. Is v1 scoped to the cutoff vectors alone, or is the profile identifier moving with it? Either answer is fine, I just want the prose to say which, because an enforcer reading the spec today targets the v0 cutoff set. Mine did.

1 Like

Hey @zexoverz folded your author entry into 8373. Pushed Faisal Firdani (@zexoverz) onto the
frontmatter on #1932 (and fixed Pavlo’s full name while I was in there). Thanks again for the
consumer-cutoff catch and for keeping the attribution honest on the split.

One process bit: the eip-bot likes added authors to confirm on the PR itself, not just in the
Magicians thread, so when it pings you on #1932, a one-line “confirmed” is all it needs to go
green. Flagging it so it doesn’t sit: on these ERC PRs silence is what stalls them, not the review.

1 Like

@TMerlini thanks, saw the frontmatter at 7f77731. I dropped the confirmation on #1932 rather than wait for the bot, since you are right that silence is what stalls these.

Two follow-ups, both from reading the head rather than the thread.

On my own version question, I was conflating two identifiers and the file makes that clear. There are two, and only one of them moved.

identifier where it lives value at 7f77731
cutoff profile Test Cases prose, profile in the v1 vectors pq_key_binding.v1/cutoff-enforcement
binding-statement schema the REQUIRED-members table, statement.schema in the vectors table says pq_key_binding.v0; vectors say invinoveritas.pq_key_binding.v1 and kya.pq_key_binding.v0

So the answer to my own question is that v1 is scoped to the cutoff set. That is clear and I would not change it. What is left is smaller and worth one line of prose. The table’s example is unnamespaced, both production implementations namespace it, and their version digits already disagree with each other inside pq-key-binding-v0.vectors.json:

vectors[0].statement.schema = "invinoveritas.pq_key_binding.v1"
vectors[1].statement.schema = "kya.pq_key_binding.v0"

An enforcer that reads the table and then matches schema exactly rejects both. Saying that schema is implementation-namespaced and versioned per implementation, and that it is not the cutoff profile identifier, closes it. I hit this from the consumer side, which is the only reason I noticed.

On the consumer, the Reference Implementation section is unchanged at head and still names only the two production implementations. No rush, I just want to know which way you want it so I prepare the right thing. Prose alongside the other two, or the code in assets/erc-8373/ next to cutoff_enforce.py and recovery_check.py. I still prefer assets, since a reader can run it there and it avoids the EIP-1 external-link problem entirely. Say the word and I will open a PR against your branch with it laid out that way.

1 Like

Both of @zexoverz’s points from the head-read are now moving.

Schema-namespace opacity, landed. It’s up as recompute-kit #36 (https://github.com/trustless-ai/recompute-kit/pull/36), as a normative rule with a failing witness rather than prose:

The binding-statement schema is implementation-namespaced and opaque to the cutoff enforcer. An enforcer MUST NOT condition its verdict on schema; the only version identifier that governs enforcement is the cutoff profile (pq_key_binding.v1). Enforcement keys on the anchored times, content address, and pq_pubkey.

It’s true-by-construction in the reference enforcer (it never reads schema), and now proven: a namespace-opacity vector duplicates the first pre-cutoff case with only the binding’s schema swapped for a foreign namespace and asserts an identical ADMIT.

Reference enforcer stays 10/10; a mutant that branches on the namespace reds exactly that one case (9/10) and nothing else. So a third-party enforcer that pattern-matched kya.pq_key_binding.* and rejected another namespace would now fail conformance, which is the outcome you were protecting against.

One credit I need to set straight while we’re here, my earlier reply misattributed the example. The two live bindings in the vectors are:

  • kya.pq_key_binding.v0 ours (the KYA-L4 binding: SLH-DSA, Ethereum-mainnet OCP anchor).

  • invinoveritas.pq_key_binding.v1 Fede’s (@babyblueviper1), his first live binding at
    api.babyblueviper.com (ML-DSA-65, OTS-anchored). Not one of @zexoverz’s — I named the wrong owner
    earlier, apologies to both.

Both run under the one pq_key_binding profile, independently namespaced, and enforce identically which is exactly why the MUST-NOT matters. @babyblueviper1 worth a glance that your binding’s represented right in the #36 vector note; happy to adjust the wording if you’d put it differently.

Reference implementation, agreed, @zexoverz’s on-chain consumer goes in-tree at assets/erc-8373/ alongside the enforcer + vectors (runnable, no external link, per EIP-1), as the informative reference implementation whose independent build surfaced the v0 discrepancy. That mirror + the same MUST-NOT land in the ERC’s own asset copy (ethereum/ERCs #1932) once #36 settles. And the author-line add still needs your confirmation on #1932 itself for eip-bot, not just here.