Skip to content

docs(spec): publish the registry anchor and inclusion-proof format (#111) - #134

Merged
imran-siddique merged 1 commit into
mainfrom
docs/registry-anchor-format
Aug 8, 2026
Merged

docs(spec): publish the registry anchor and inclusion-proof format (#111)#134
imran-siddique merged 1 commit into
mainfrom
docs/registry-anchor-format

Conversation

@imran-siddique

Copy link
Copy Markdown
Member

Closes #111.

The format was already normative and already written so a conforming verifier could be built from it alone. It lived in trace-registry, which is private, so the one document an external verifier needs was the one they could not read. An inclusion proof nobody outside can check is not transparency.

This takes option 1 from the issue, which @l33tdawg proposed: publish it here, in the public spec home, and leave the registry implementation where it is. That decouples publishing the format from the larger decision about repo visibility.

spec/registry-anchor-v1.md

RFC 6962 trees, RFC 9162 inclusion check, the complete signed claim as the anchored unit, entry fields, proof shape, conformance.

§0 leads with the trap rather than burying it, per the issue. TRACE canonicalizes with RFC 8785 (JCS) for signing and sorted-key JSON for the anchor leaf. The two agree on ASCII-only records carrying integer numbers, which is most records, which is exactly what makes assuming JCS at the leaf dangerous: proofs never verify and the failure has no useful diagnostic. The section names the test that catches it.

§8 requires the append-only property to be externally checkable, not asserted. An operator issuing verifiable proofs while publishing nothing an outsider can audit is running a log, not a transparency log.

Verified, not asserted

The point of the document is that someone can implement from it without reading our code, so I checked that rather than claiming it. An implementation written from the published text alone:

  • verifies the registry's real anchored sample (leaf_count 1, empty audit path);
  • verifies all five proofs of a five-leaf batch built by the registry's own tools/anchor.py, which exercises the odd-node promotion branch the single-leaf sample never touches (leaf 4 gets a 1-element path, leaves 0 to 3 get 3);
  • rejects a tampered claim against a valid proof.

Two things found while writing it

Four documents told readers to send signed records to a domain we do not own. docs/integration/agt.md, docs/integration/cmcp.md, docs/trust-levels.md and docs/verification.md named registry.agentrust.io, which resolves to third-party parked addresses. Same defect the v0.2 profile cutover fixed in the identifier, missed in the prose. Moved to registry.agentrust-io.com, which is what the SDK's adapters already emit. spec/trace-v0.1.md keeps its original values: it is superseded and stands as a record of what was published.

The anchoring tutorial documented an API that does not exist. It told readers to POST a signed Trust Record to a SCITT HTTP endpoint at that parked domain and read a receipt_uri from the response. There is no such endpoint. Rewritten against the actual mechanism: submit to staging, retrieve the inclusion proof, verify it yourself against the published entry. It also still described transparency as a required string, which 0.5.1 changed. The page carries a dated note stating what it used to say, because anyone who built against it should know that nothing they sent was received.

Not in this PR

Making trace-registry public. That is a separate decision with a licensing blocker in front of it, covered in the go-public runbook, and this PR is deliberately independent of it.

🤖 Generated with Claude Code

)

The format was already normative and already written so that a conforming
verifier could be built from it alone. It lived in trace-registry, which is
private, so the one document an external verifier needs was the one they could
not read. An inclusion proof nobody outside can check is not transparency.

spec/registry-anchor-v1.md publishes it here, in the public spec home, as
proposed in the discussion that raised the issue. RFC 6962 trees, RFC 9162
inclusion check, the complete signed claim as the anchored unit, entry fields,
proof shape, and a conformance section.

Section 0 leads with the trap rather than burying it. TRACE canonicalizes with
RFC 8785 JCS for signing and with sorted-key JSON for the anchor leaf. The two
agree on ASCII-only records carrying integer numbers, which is most records,
which is what makes assuming JCS at the leaf dangerous: the proofs simply never
verify and the failure has no useful diagnostic.

Section 8 requires that the append-only property be externally checkable rather
than asserted. An operator issuing verifiable proofs while publishing nothing an
outsider can audit is running a log, not a transparency log.

Checked rather than asserted: an implementation written from the published text
alone verifies the registry's real anchored sample, and verifies all five proofs
of a five-leaf batch built by the registry's own tool, which exercises the
odd-node promotion branch that the single-leaf sample does not. A tampered claim
is rejected.

Also fixed, found while writing this:

Four documents told readers to send signed records to registry.agentrust.io,
which resolves to third-party parked addresses. Same defect the v0.2 profile
cutover fixed in the identifier, missed in the prose. Moved to the host the
SDK's adapters already emit. The v0.1 spec keeps its original values, since it
is superseded and stands as a record of what was published.

The anchoring tutorial documented an API that does not exist: POST a signed
record to a SCITT endpoint at that parked domain, read a receipt_uri from the
response. Rewritten against the actual mechanism, and it carries a dated note
saying what it used to say, because anyone who built against it should know
nothing they sent was received.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@lywinged

lywinged commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

Reviewed by implementing §1 to §5.1 from the document text alone, since that is the document's own claim, and by checking its two structural assertions rather than reading past them. Both hold. One defect in the package §7 names, one row missing from §0's table, two minor notes.

Escapes throughout below, so nothing here depends on how a terminal renders it.

Verified

§3's equivalence claim is true. "Level by level, promote the odd node unchanged, and this yields the same tree as the RFC 6962 recursive split at the largest power of two" is the kind of sentence that is usually approximately true. I built both constructions independently and compared roots for every batch size from 1 to 200: no mismatch.

The document is implementable without the code. §1, §2, §3 and §5.1 written from the text alone, then every leaf of every batch size from 1 to 40 verified against its own root, and a tampered claim rejected against a valid proof. §5.1 is faithful to RFC 9162 §2.1.3.2, including the right-edge promotion shift, which is the part that usually gets dropped.

§1 matches the shipped verifier exactly. trace_verify._verify.canonical_claim_bytes is the §1 one-liner, character for character.

trace-verify verifies TRACE signatures with the construction §3.2.2 forbids by name

Not in this diff, but §7 is the sentence that makes the package a reference implementation, and §5.2 explicitly hands signature verification to v0.2 §3.3, which the package implements.

trace_verify._signature.canonical_body_bytes builds the signature pre-image as:

body = {k: v for k, v in claim.items() if k != "signature"}
json.dumps(body, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode("ascii")

v0.2 §3.2.2 names that exact construction: "Implementations MUST use an RFC 8785-conformant library. Using json.dumps(sort_keys=True) (Python) or equivalent ad-hoc sorting is insufficient." agentrust_trace.sign_record signs the same object -- the claim with signature removed -- through rfc8785.dumps. Same scheme, different canonicalizer, so the two agree on ASCII and diverge everywhere else:

record contents                                   agentrust-trace   trace-verify
ASCII only                                        valid             valid
any string field containing U+00FC                valid             INVALID
any string field containing a CJK character       valid             INVALID

Reproduction, against agentrust-trace 0.6.0 and trace-verify 0.2.0 from PyPI:

from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from agentrust_trace.sign import sign_record, key_to_jwk
import trace_verify._signature as tvs

key = Ed25519PrivateKey.generate()
record = {
    "eat_profile": "https://trace.agentrust-io.com/profiles/v0.2",
    "sub": "spiffe://trust.example.org/agent/payments",
    "policy_label": "Zahlungsverkehr f\u00fcr M\u00fcnchen",  # escapes; any non-ASCII does it
}
signed = sign_record(record, key)
print(tvs.verify_claim_signature(signed, key_to_jwk(key)))   # False

The CLI verifies signatures by default and exits 1 when the check fails, so an external verifier following §7, on a valid record carrying a German policy label or a non-Latin data class, is told the signature is invalid. verify_claim_against_registry's docstring describes it as "the fail-closed check used by the anchoring paths"; if that is accurate, such a record also cannot be anchored, but that path is not visible from outside.

Two readings, and the public material does not distinguish them:

  • the registry deliberately signs over a different pre-image than v0.2 §3.2.2, in which case this is a third row for §0's table and the most consequential one, since it is a divergence at the layer §0 states is JCS; or
  • it is a defect in the package.

Either way I would not have §7 point an external verifier at it before that is settled, since §7 exists precisely so that people run it.

Worth stating plainly: this is the mistake §0 warns about, in the opposite direction. §0 says do not reuse the signing canonicalizer at the leaf. What is in the shipped code is the leaf canonicalizer reused at the signing layer. The warning is correct and one-directional, and the missing direction is the one that has already happened.

§0's table is missing a row: key order

§0 lists two divergences between JCS and §1, escaping and non-integer numbers. There is a third. JCS sorts object keys by UTF-16 code unit (RFC 8785 §3.2.3); §1 sorts by Unicode code point. The two differ once a key contains a supplementary-plane character, because surrogates occupy D800 to DFFF.

§1's prose is right, and Python's sort_keys=True implements it, because Python compares strings by code point. TypeScript is one of the three verifier languages ROADMAP targets for v1.0, and JavaScript is the counterexample I ran:

// U+1F600 is supplementary-plane (surrogate pair D83D DE00); U+FFFD is high BMP.
Object.keys({"zk\u{1F600}": 1, "zk\uFFFD": 2}).sort()
// -> ["zk\u{1F600}", "zk\uFFFD"]   UTF-16 code unit order, 0xD83D < 0xFFFD
// true code-point order is the reverse, 0xFFFD < 0x1F600

So an implementer who reads §1 correctly and writes the idiomatic sort produces different leaf bytes and gets §0's own symptom: a root that differs, with nothing to indicate why.

The test §0 names does not reach it. It catches the failure it was written for. A non-ASCII BMP character sorts identically under both orders and §1 escapes it either way, so those bytes come out identical; only a supplementary-plane character in a key separates them. I checked both cases.

This is the same distinction as #128, still open, and cnf.jwk remains the one object in schema/trace-claim.json without additionalProperties: false, so it is reachable inside a schema-valid record. Suggested: one clause in §1 saying code point and not UTF-16 code unit, and a supplementary-plane key in the test §0 recommends.

Two minor notes

  • trace-verify's PyPI metadata points Homepage, Documentation and Bug Tracker at github.com/agentrust-io/trace-registry. All three return 404 to an anonymous reader, which is the audience this document exists to serve.
  • The installed distribution is 0.2.0 while trace_verify.__version__ is 0.1.0, and --version prints the latter. Same drift chore(release): 0.6.0, and stop __version__ drifting #131 fixed here.

None of the above touches the format itself, which I think is right, and publishing it here is clearly the correct call.

@imran-siddique
imran-siddique merged commit fd311de into main Aug 8, 2026
6 checks passed
@imran-siddique
imran-siddique deleted the docs/registry-anchor-format branch August 8, 2026 23:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Publish the registry anchor format (inclusion-proof spec) publicly

2 participants