Skip to content

spec(anchor)!: a Level 2 anchor verdict requires the registry entry - #533

Open
imran-siddique wants to merge 4 commits into
mainfrom
fix/464-anc-entry-binding
Open

imran-siddique wants to merge 4 commits into
mainfrom
fix/464-anc-entry-binding

Conversation

@imran-siddique

Copy link
Copy Markdown
Member

Refs #464. Breaking, proposed: this changes what Level 2 requires, so it waits out the 30-day comment period under GOVERNANCE.md.

Ruling of 2026-10-15: TR-ANC-002 judged the proof against a merkle_root and leaf_count the receipt brought with it, so a pass tied the record to no entry. Registry Anchor Format §6 now requires the entry itself. The receipt's root and count must equal the entry's, and with no entry Level 2 is not earned. The suite takes it with --entry.

Nothing identifies an entry from the transparency URI yet. The pass message says that link was not checked, and #448 finding 21 tracks it. That supersedes the 09-23 line on trace-tests#92 until the link exists.

The worlds from trace-tests#92, in tests/test_tr_anc_entry_binding.py:

World TR-ANC-002
matching sealed entry pass
wrong caller root fail
only entry root changed fail
only entry count changed fail
entry absent fail

Removing each new comparison breaks at least one test. Conformance suite on Windows: 798 passed, plus one test_manifest_ref_draft_vectors failure that main shows too.

Overlap: #519 edits the same three docs pages.

🤖 Generated with Claude Code

TR-ANC-002 judged the inclusion proof against a merkle_root and leaf_count
that came from the receipt, so a pass tied the record to no registry entry.
Registry Anchor Format section 6 now says a Level 2 verdict rests on the
entry itself (the third input of section 5.1): the receipt's root and count
must equal the entry's or the anchor is refused, and with no entry Level 2
is not earned.

The suite takes the entry with --entry (run() and tr_anc.check() take
entry=). A missing entry fails TR-ANC-002 the same way a missing receipt
does. Nothing yet identifies an entry from the transparency URI, so the
pass message names the entry and says that link was not checked; it stays
open as finding 21 of #448.

Breaking, proposed: this redefines what Level 2 requires, so it needs the
30-day comment period and Project Lead sign-off under GOVERNANCE.md.

Refs #464

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Imran Siddique <45405841+imran-siddique@users.noreply.github.com>

@dinakarjs dinakarjs left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Independent technical review of 95e160a. Current-head CI and Conformance CI succeeded. Fresh hash-locked Python 3.12.14 dependencies: reference suite 4,033 passed / 48 skipped; conformance 799 passed / 5 xpassed. The new entry-binding module passes all 18 cases.

I separately built RFC 6962 trees by recursive largest-power-of-two splitting, rather than the test helper's level-by-level promotion, and checked every leaf position for sizes 1 through 33 (561 proofs), using records with non-ASCII content and an included signature field. Each valid proof passes; changing only the entry root, changing only its count, omitting the entry, or changing the claim signature each refuses (2,244 refusal twins). Disabling the new root comparison causes 1 failure / 17 passes; disabling the count comparison 2 failures / 16 passes; synthesizing a missing entry from receipt fields instead of refusing causes 4 failures / 14 passes. Symbols and valid controls were retained; temporary mutations restored.

The bounded guarantee is consistent with the new PASS diagnostic: receipt root/count match the supplied entry, and inclusion replays to that root. It does not establish that this is the entry named by transparency or authenticate the entry's origin. Controls with changed producer/batch_id but identical root/count still pass, as expected under the stated limitation. This does not close #448 finding 21.

One nonblocking shape-validation observation: parse_entry accepts ts='not-a-timestamp' as long as it is nonempty, while section 4 describes ISO-8601 UTC with a Z suffix. Either validate that field in a follow-up or make the parser's narrower shape-validation ceiling explicit; a passing inclusion result should not be read as timestamp validation.

No blocking defect found in the implemented root/count/entry-presence guarantees. This is technical evidence, not approval to bypass the breaking-change comment period, Project Lead decision, or remaining URI-binding work.

@solloek369-arch

Copy link
Copy Markdown
Contributor

Non-blocking, for the comment period on 95e160a. This sits outside the bounded review above — @dinakarjs scoped that to the receipt-to-entry binding and the inclusion replay, and found no blocking defect there. The question below is about which object the inclusion covers for one format. It predates this PR; I'm raising it here because #533 is where what a Level 2 anchor verdict establishes is being redefined.

Failing behaviour. For cmcp-runtime, TR-SIG and TR-ANC-002 judge different objects, and the reference registry anchors the one the suite does not.

Smallest case. Registry-side: a RuntimeClaim anchored with the registry's own _leaf_hash/_build_tree, the same bytes handed to both verifiers — registry verify_inclusion PASS, suite TR-ANC-002 FAIL. Suite-side: the same inner trace, proof and entry, with gateway.session_id and gateway.catalog.hash changed and the envelope re-signed — TR-ANC-002 still PASS.

Expected. Registry Anchor Format §1: "The unit of anchoring is the complete signed claim object, signature included … if either the claim body or its signature changes, the anchor no longer matches."

Consequence. For this format a Level 2 pass fixes the bytes of the inner trace, not the identity of the signer's envelope or the scope of the gateway facts bound to it. Two differently signed envelopes satisfy the same anchor.


extract_trace returns record["trace"] for cmcp-runtime (conformance/src/trace_tests/loader.py), and _run_core passes the full RuntimeClaim to TR-SIG but only the inner trace to TR-ANC. TRACE v0.2 §3.2.2 lists cMCP's RuntimeClaim as an enveloping-signature profile, so the signature belongs on the envelope; that signature covers the inner trace, but the suite's anchor leaf excludes the outer gateway object and the signature itself.

The registry anchors the other object. claim_leaf_subject (trace-registry src/trace_verify/_verify.py) classifies a submission as envelope only when its keys are exactly producer, trace and signature; a RuntimeClaim is classified record, and its leaf is taken over the whole claim.

Reproduced locally with synthetic Ed25519-signed RuntimeClaims; registry at 0fb5f48, suite at 95e160a.

Registry route — leaf and tree from the registry's own _leaf_hash/_build_tree (batch of three), the same bytes given to both verifiers:

verifier result
registry trace_verify.verify_inclusion PASS
suite run(…, "cmcp-runtime", 2), TR-SIG / TR-ANC-002 PASS / FAIL: inclusion proof does not reproduce the committed merkle_root

Suite route — this PR's _receipt/_entry helpers:

world TR-SIG TR-ANC-002
proof over the complete signed RuntimeClaim PASS FAIL
proof over the inner trace PASS PASS
same inner trace, proof and entry; gateway.session_id and gateway.catalog.hash changed, envelope re-signed PASS PASS

In the two passing worlds, differently signed envelopes use the same inner trace, proof and supplied entry, so the checks do not establish inclusion of either complete signed RuntimeClaim.

Question. Is the intended Level 2 anchor subject for cmcp-runtime the complete signed RuntimeClaim, as the registry anchors it, or only its nested TRACE claim? If the complete claim, the registry route above is the positive control and the second and third suite worlds, with proof and entry held fixed, are refusal controls. If the nested claim, the registry and the verdict wording would both need to say so. (The signature pre-image for this profile is a separate question, open as #448 item 6.)

This route is not reachable from cMCP's producer today: GatewayTrace at cMCP e116c24 is extra="forbid" and has no transparency field, so a cMCP-issued claim cannot carry the claim Level 2 requires. I'm raising the subject question now so it is settled before that route opens.

This concerns which bytes the inclusion proof covers, not a signature bypass: changing gateway fields needs a re-signature with the key in trace.cnf.jwk. Module results for TR-SIG and TR-ANC-002 only, on synthetic RuntimeClaims that are not schema-complete and carry no freshness challenge; the four-field receipt is used throughout so the receipt-shape question does not interfere; the registry leaf and tree come from its own functions, not its full ingest pipeline. Not an aggregate Level 2 result.

Copy link
Copy Markdown
Contributor

Independent follow-up to Loek's anchor-subject report at 95e160a. I reproduced the distinction locally on Python 3.12.14 with fresh synthetic Ed25519 keys, a single-leaf proof computed from canonical_claim_bytes, and the existing loader/signature/anchor modules:

Anchored object / submitted claim TR-SIG TR-ANC-002
complete signed RuntimeClaim PASS FAIL
nested trace PASS PASS
changed gateway.session_id and gateway.catalog.hash, envelope re-signed, same nested-trace receipt/entry PASS PASS

Direct verify_inclusion over the complete original signed object passes; changing and re-signing the envelope with that original whole-object proof fails. This independently confirms the runner boundary: extract_trace removes the envelope before TR-ANC, while TR-SIG receives the complete record. These are module-level synthetic checks, not schema-complete or aggregate Level 2 acceptance, and I did not rerun the registry ingest pipeline.

My recommendation is to retain registry-anchor-v1 §1's complete signed claim subject and pass an explicit anchor subject alongside the normalized trace into TR-ANC. Keep the URI/receipt shape checks on the trace and include a full-envelope positive twin plus re-signed-gateway and signature-change refusal twins. A nested-only interpretation would require explicit normative wording and registry alignment. @imran-siddique @lywinged please record the intended subject before implementation; my earlier receipt-to-entry review does not resolve or approve this broader semantic choice. Preserve the 30-day comment period and keep #448 item 6 and URI-to-entry binding item 21 distinct. No normative/runtime change or merge requested here.

…ate the entry ts

For cmcp-runtime the runner handed TR-ANC the nested trace while Anchor
Format section 1 and the reference registry anchor the whole envelope, so a
registry-valid anchor failed and a re-signed envelope with changed gateway
fields still passed. TR-ANC now takes the complete record as its subject.
parse_entry also rejects a ts that is not ISO-8601 UTC ending in Z.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Imran Siddique <45405841+imran-siddique@users.noreply.github.com>
@imran-siddique

Copy link
Copy Markdown
Member Author

@solloek369-arch @dinakarjs thanks, reproduced here too. Recording the subject: it is the complete signed claim, as Anchor Format §1 already says, and the next commit makes the suite follow it. For cmcp-runtime the runner now passes the whole envelope to TR-ANC as subject, while URI and receipt shape checks stay on the trace. Twins: full-envelope anchor passes, nested-trace-only anchor fails, re-signed envelope with changed gateway fields fails, changed signature fails. It also rejects an entry ts that is not ISO-8601 UTC with Z. Item 6 and finding 21 stay separate.

@dinakarjs dinakarjs left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up technical review at exact head fe5d01e closes the two findings from the previous discussion: the runner passes the complete signed record as the anchor subject, and parse_entry validates the UTC-Z timestamp shape/calendar date.

Independent Python 3.12, absolute PYTHONPATH to this checkout: full conformance suite 812 passed, 5 xpassed (XPASS is reported separately, not counted as additional passing requirements). Current-head hosted technical checks succeed. Separate synthetic-key probe used independently constructed sorted-key JSON preimages and a SHA-256 single-leaf proof: original whole-envelope anchor TR-SIG/TR-ANC pass/pass; nested-trace-only proof pass/fail; changed gateway session with a valid re-signature against the original full-envelope proof pass/fail; changed signature fail/fail. These are module-level controls, not aggregate Level 2 acceptance or registry ingest validation.

Regression sensitivity on temporary edits: removing subject=record from the runner fails the whole-envelope positive and nested-only refusal tests (2 failures). Removing timestamp validation fails all 7 malformed-date cases while both valid UTC timestamp cases pass. Sources restored, clean worktree; diff whitespace check passes.

No remaining blocking implementation finding in this bounded follow-up. The 30-day breaking-change comment period and Project Lead sign-off still apply; this is not authorization to bypass them. URI-to-entry binding (#448 finding 21), entry-origin authentication, and the separate signature-preimage item 6 remain outside this correction.

Registry Anchor Format section 5 defines the inclusion proof as
leaf_index and audit_path, and the section 5.1 algorithm takes
merkle_root and leaf_count from the registry entry. TR-ANC-002 still
required both on the receipt, so a proof-only receipt with a matching
entry was refused as malformed.

TR-ANC-002 now replays the proof against the entry's root and count.
A receipt that carries its own copies must still carry the entry's
(section 6). parse_receipt keeps its contract for callers that replay
a self-contained receipt without an entry.

Signed-off-by: Sankalp Gilda <23521054+astrogilda@users.noreply.github.com>
A receipt may leave leaf_count out and take the entry's, but one that
carries the field must carry an integer. An explicit null was read as
absent and skipped the comparison with the entry.

Signed-off-by: Sankalp Gilda <23521054+astrogilda@users.noreply.github.com>

@dinakarjs dinakarjs left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review at 6a9d803: the entry-authoritative root/count correction behaves as intended, but the receipt fuzz oracle needs updating before the technical checks are green.

Blocking harness defect: conformance/.clusterfuzzlite/fuzz_receipt.py:124 calls parse_receipt(receipt) after TR-ANC-002 passes. A legitimate proof-only receipt can now pass using the supplied entry, whereas parse_receipt intentionally still requires self-contained root/count fields. Deleting leaf_count or merkle_root from the genuine receipt therefore raises InclusionError in this post-PASS assertion. Hosted ClusterFuzzLite run 38987830178 / job 117263304673 reports precisely the missing-leaf_count exception. This is a stale harness assumption, not evidence of an inclusion-verification bypass.

Please retain the earlier standalone parse_receipt exception-safety probe, but make the post-PASS oracle use parse_proof(receipt) for index/path and parse_entry(_ENTRY) for the authoritative root/count; independently replay verify_inclusion(claim, index, path, entry_count, entry_root), retaining the expected-claim and genuine-root assertions. Do not merely suppress the exception or skip proof-only inputs. Add deterministic harness coverage for omitted leaf_count, omitted merkle_root, and both omitted, then rerun ClusterFuzzLite.

Validation on this exact checkout, Python 3.12: 49 targeted anchor tests passed; full conformance suite 818 passed, 5 xpassed (reported separately). A deterministic probe loaded the actual fuzz fixture with only Atheris instrumentation stubbed: all three omission variants pass TR-ANC-002, raise in the old self-contained parser, and pass an entry-based independent replay. Wrong-claim, wrong-entry-root and wrong-entry-count controls remain refused. This was not a fresh Atheris campaign or a replay of the minimized byte input.

The 30-day breaking-change comment period and Project Lead sign-off remain required. URI-to-entry binding (#448 finding 21) and entry-origin authentication remain unverified; this review does not establish aggregate Level 2 acceptance.

Copy link
Copy Markdown
Contributor

Concrete companion for the fuzz-oracle review finding: #559, targeting this PR's branch at 6a9d803. Head bc3b465 changes only the harness and adds five deterministic callback tests. Full local conformance: 823 passed, 5 xpassed; Ruff passes. Restoring the old post-PASS oracle in memory makes all three optional-field omission regressions and the independent-replay refusal test fail, while the self-contained control passes. The standalone parser exception-safety probe is retained. Please incorporate/review the companion and rerun hosted ClusterFuzzLite; a fresh fuzz campaign is still required before the red check is resolved. Governance comment period and Project Lead sign-off remain unchanged.

This branch has not been deployed

No deployments
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.

4 participants