Skip to content

rfc(draft, non-normative): A2A delegation-link verification profile, with 23 conformance vectors - #184

Merged
imran-siddique merged 9 commits into
agentrust-io:mainfrom
lywinged:a2a/delegation-link-conformance
Aug 23, 2026
Merged

rfc(draft, non-normative): A2A delegation-link verification profile, with 23 conformance vectors#184
imran-siddique merged 9 commits into
agentrust-io:mainfrom
lywinged:a2a/delegation-link-conformance

Conversation

@lywinged

@lywinged lywinged commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

Opened as a draft at your request on the seam thread. Non-normative. Requirement keywords are lowercase throughout the document, deliberately: CONTRIBUTING.md draws the line that normative text lives in the specifications, and this file binds nothing until its rules are adopted, at which point they become uppercase in spec/ and this becomes a pointer to where they went.

No schema change. The delegation block is already normative in v0.2; this proposes verification rules for it.

What is here

docs/rfcs/a2a-delegation-profile.md, and examples/delegation-link/ with 23 vectors and their generator. The signing keys derive by role label from one published seed, so there is no secret and the whole set is reissuable by anyone; each vector carries the anchor JWKs in context.trusted_root_keys. tests/test_generators_reproduce_fixtures.py already covers this generator, since it discovers them rather than listing them. tests/test_delegation_vectors.py runs them; tests/test_delegation_completeness.py guards the set the way test_vector_completeness.py guards the receipt corpus, so a rule with one load-bearing vector fails rather than passes quietly.

585 pass on 3.11, ruff clean, no src or schema changes.

The corpus was built before the rules, and that is the part worth keeping

The three decisions in §4 were not read out of the existing text. They were hit, because no vector could be written without settling them, and in each case the text supports both branches. A reader passes over all three without noticing; someone building a fixture cannot get to the end of one.

That gives a measurement the repository does not otherwise have. Not do the tests cover the rules, which measures an implementation, but do two independent readings of the same normative text produce the same rules, which measures the specification. Where two readings agree the text is doing its job. Where they diverge, the divergence names the sentence that is missing.

What running it against cA2A returned

Measured against ca2a_verify.verify_trace_dag at ca2a 5dd77b2. The run itself is from an earlier session, so I checked what has landed since rather than restate it: ca2a is now at 8c49177, four commits on, and they add credential validity windows. Neither ca2a_verify/dag.py, which holds verify_trace_dag, nor ca2a_runtime/trace_binding.py, which holds trace_record_hash, is among the files they touch, so what follows still describes the current head. The agent-manifest vector count further down has not been rechecked the same way; treat it as measured then. Say the word on anything that matters to a boundary call and I will re-run the corpus and post the output rather than the claim. Thirteen of the 23 vectors exercise the record-linkage surface that function covers; the other ten are credential defects its own docstring assigns elsewhere.

§4.1 is confirmed independently. ca2a_runtime.trace_binding.trace_record_hash computes "sha256:" + sha256(rfc8785.dumps(signed_record)), the complete record with signature included, byte-identical to what this profile specifies. That was arrived at separately, in another repository.

Vectors 01 to 07 agree. Valid chains accepted, the absent parent and the body-digest link rejected as broken links, both signature vectors rejected as bad signatures. Same verdict, same reason, two implementations.

Vectors 22 and 23 are the SHA-384 case you ruled on. They already assert unverifiable with digest_algorithm_unsupported, so your ruling and this corpus agree. cA2A currently accepts a sha384: link, compares it against a digest it only ever computes as sha256:, and reports ProvenanceLinkBroken with the detail "a tampered or reparented record was detected". Under the ruling that is no longer a divergence to document but a defect, and I will file it in that repository rather than describe it here.

The trust contract differs, and this one is a boundary rather than a bug. verify_trace_dag requires every record's cnf.jwk to be in the trusted set; this profile anchors on the root's key and lets the chain carry the rest. Run under the root-anchored contract, cA2A rejects every chain longer than one record, valid ones included. Neither model is wrong: theirs fits an orchestrator that knows every participant, this one fits the cross-organisation case where that knowledge is exactly what is missing. cA2A already uses the root-anchored model on its other surface, where verify_chain takes trusted_root_issuers. Yours to draw.

The credential surfaces cannot be compared at all, and that is the finding, not a gap in the run.

What this closes on the agent-manifest side

Of its 21 vectors, two touch delegation and both are single-hop, so its narrowing and depth logic has no vector coverage. The same corpus covers it.

Not in scope here

Mutual delegation, which you said you would cover separately. Everything in this corpus is one-directional and stays that way.

…e it

The `delegation` block is normative in v0.2 and nothing says what a verifier does
with a chain of them. `spec/trace-v0.2.md` never mentions `parent_record_hash` or
`credential_id`; the only prose is one sentence in `docs/schema.md`, and every
operative term in it is open — which bytes the digest covers, what "the delegation
chain" is when no credential object exists in the schema, what a verifier does with
a link it cannot compute. Two implementations can satisfy every constraint the
repository states today and agree on nothing.

`docs/rfcs/a2a-delegation-profile.md` proposes ten rules over the fields that
already exist, so adopting it requires no schema change. `examples/delegation-link/`
carries 23 vectors that score an implementation against them. Requirement keywords
in the RFC are lowercase on purpose: a proposal that writes itself in the imperative
is a specification nobody agreed to.

Three forks in the current text had to be settled before a single vector could be
written, and each is recorded with its reason rather than assumed:

  The digest covers the complete parent record, signature included. A digest over
  the signed body alone does not bind the parent's *signer* — anyone may re-sign
  identical bytes under another key and satisfy the child's commitment — so the
  child would have committed to what its parent said and not to who said it.
  Vector 05 is a complete, correctly signed chain whose only defect is which bytes
  its link was computed over.

  There is no cycle rule. A cycle needs each record's block to carry a digest
  covering the block that names it back, which is a hash collision; a rule against
  it would be untestable by construction. The reachable analogue is an unbounded
  chain, and that is the only reason the depth bound exists. Stated so a reader can
  tell which of the two was decided and which was forgotten.

  A link naming a digest algorithm the verifier cannot compute makes the chain
  unverifiable, not invalid. Reporting `parent_not_found` for it would be a finding
  nobody made: the verifier did not fail to find the parent, it did not look. This
  is the delegation-surface instance of the semantics merged in
  `docs/verification.md`, and `parent_not_found` is explicitly guarded on algorithm
  support so the two cannot be produced together for one link.

Coverage is held to agentrust-io#124's discipline from the first vector rather than as a later
hardening pass: two load-bearing vectors per rule, and for every rule at least one
declared implementation defect that one vector catches and the other misses. All ten
defects model a real shortcut — verifying the leaf only, anchoring on any trusted key
found, an off-by-one bound, case-insensitive lookup of an opaque identifier, issuer
and holder compared to the wrong ends of the hop, half a validity window, narrowing
checked at one hop, the link algorithm read once and assumed uniform.

Two of those declarations found faults in the walk while it was being written, which
is the argument for declaring them rather than asserting margin and stopping. The
walk's break condition originally repeated the depth comparison, so a weakened bound
never got to walk further than a correct one and both depth vectors moved together
under every mutation — margin without independence. And an earlier vector 09 put an
untrusted root three hops down, which no defect could separate from vector 08; the
version that separates them places a *trusted* key partway up the chain, which is
the shortcut an implementation actually takes.

Reproducibility is a property of the corpus, not a courtesy. Keys derive from one
published seed by role label. `tests/test_generators_reproduce_fixtures.py` (agentrust-io#171)
discovered the generator with no new guard code and holds it to byte reproduction
with no entry in the `NOT_GENERATED` ledger, which is the bar agentrust-io#178 proposes for the
repository's corpora.

Every record in every vector, including the ones built to fail, validates against
`schema/trace-claim.json`: a defect the schema already rejects is not a profile
defect, and a rule that looks covered only because its vector is malformed in some
louder way is not covered.

Nothing enters the package's public API. The walk lives in `tests/`, beside the
action-receipt verifier it is modelled on, because the rules it implements are not
normative yet.

Signed-off-by: Louielunz <48041247+lywinged@users.noreply.github.com>
The proposal argued for cross-verification and did not do any, which left its
central section a plan. This runs the 23 vectors against
`ca2a_verify.verify_trace_dag` at ca2a 5dd77b2 and writes down what came back,
including the parts that went against the draft.

The §4.1 digest decision is confirmed from outside this repository.
`ca2a_runtime.trace_binding.trace_record_hash` computes the sha256 of the
complete signed record's RFC 8785 bytes — byte-identical to what the profile
specifies, arrived at separately. Vectors 01-07 agree in verdict and in reason.

Vectors 22 and 23 disagree exactly as §4.3 predicted: cA2A accepts a `sha384:`
link at block validation, compares it against a hash it only ever computes as
`sha256:`, and reports the chain as "a tampered or reparented record". An intact
chain addressed under the other permitted algorithm is reported as tampering.
The distinction between unreadable and contradicted is now observed rather than
argued.

Two things the draft got wrong, corrected here rather than left standing:

  It said cA2A "states that its credentials are cross-verifiable with
  agent-manifest" and that nothing tests the claim. The claim in
  `ca2a_runtime/canonical.py` is narrower — that RFC 8785 makes the signed byte
  string identical across conforming implementations, so signatures verify
  either side. Read as credential interoperability it is a claim ca2a does not
  make. Checked on the axis it does make: ca2a hand-implements JCS rather than
  taking a library, and that implementation is byte-identical to the reference
  on all four vectors of `examples/canonicalization-boundary/`, both UTF-16
  key-order cases included. Upheld.

  A first pass recorded that cA2A has no depth bound. It has one — `max_depth`,
  default 8, on the credential chain rather than on the record DAG. A bound in
  a different place is not an absent bound.

The credential surfaces turn out not to be comparable at all, which is the
finding rather than an obstacle to it: three repositories, three delegation
models, no conversion between them. §7.1 tabulates them. Two consequences worth
carrying forward — agent-manifest already narrows on `data_classifications`,
which is independent support for D-9 belonging on this surface, and cA2A
credentials carry no validity window at all, so D-8 has no counterpart there.

The trust contract also differs and cannot be normalised away: `verify_trace_dag`
requires every record's key to be trusted, this profile anchors on the root's.
Under this profile's contract cA2A rejects every valid chain longer than one
record. Neither is wrong; they fit different deployments, and cA2A itself uses
the root-anchored model on its other surface.

No code changes. The vectors are untouched and both suites still pass.

Signed-off-by: Louielunz <48041247+lywinged@users.noreply.github.com>
…'s outcomes

The cross-check so far only pushed this corpus outward, which shows that cA2A
rejects what the profile rejects and nothing about whether the profile describes
what the ecosystem emits. This runs it the other way and reads the third
implementation's declared outcomes.

`ca2a/examples/trace-dag/demo.py` emits a signed three-hop TRACE DAG through
cA2A's own `trace_binding`. Against it: three schema-valid records, both links
matching the section 4.1 preimage exactly, all three signatures valid under D-1,
and the chain returns `verified` with no codes and no adjustment to the walk. A
chain produced by an independent implementation verifies here unchanged.

agent-manifest turns out to settle section 4.3 rather than leave it open. Its
corpus declares results as data in the vector files -- VALID, MISMATCH,
UNVERIFIABLE, EXPIRED, REVOKED, SIGNATURE_MISSING, INCOMPLETE,
INCOMPATIBLE_VERSION, ATTESTATION_UNAVAILABLE -- and AM-VEC-012 declares
`{"result": "UNVERIFIABLE", "fields_verified": {"delegation_chain":
"UNVERIFIABLE"}}` for a delegation chain with no public keys. Evidence the
verifier lacks what it needs to check, recorded as unreadable rather than as a
finding against the chain: section 4.3, on this surface, in a second
implementation, arrived at independently. Two of the three distinguish
unreadable from contradicted; cA2A's TRACE DAG verifier collapses them, which
makes the sha384 divergence a gap rather than a preference.

Its `fields_verified` shape is prior art this proposal does not have. A verdict
per field says more than a verdict per chain, and section 8 should probably ask
about it.

Two smaller things recorded where they were found. `examples/trace-dag/` commits
a README and a demo but no vectors -- the DAG is produced at runtime and not
kept, which is the gap this corpus fills from the trace-spec side. And cA2A uses
the field name `parent_record_hash` in two formats: the schema's prefixed digest
in a TRACE record, and a bare hex digest in its own provenance DAG, on records
carrying no TRACE fields. Both deliberate, neither wrong, and a hazard for
anyone writing a parser against the name.

No code changes; the vectors are untouched and both suites pass.

Signed-off-by: Louielunz <48041247+lywinged@users.noreply.github.com>
Both are gaps in this document rather than in anything it argues, and both were
found by reading `ROADMAP.md:21` against §6 rather than by anyone raising them.

**The mutual case.** That line scopes the v0.3 A2A profile as "binding rules over
the `delegation` block ... including the mutual case". §6 lists six things this
proposal does not do and omitted the one the roadmap names. Nothing here covers
mutual delegation: every rule walks one chain in one direction, and the block as
it stands names one parent and no peer. Calling two agents each holding the
other's authority "two chains" would be deciding that question rather than
raising it, so §6 now says so plainly. It is the largest distance between the
roadmap's line and this document.

**Who this is for.** The same line names cA2A as the reference implementation and
says nothing about who writes the binding rules, and this was written without
asking. §8 opens with that question ahead of the design ones, because the answer
changes what the document should become: the profile itself would need the mutual
case, a credential model and a ratification path; an input stays a set of rules
with executable material behind them, liftable or discardable a rule at a time.

Neither is a change to a rule, a vector or a suite. 585 passed.
…written

The document presented the three decisions in §4 as decisions and never said how
they were arrived at. They were not read out of `docs/schema.md`; they were hit,
because no vector could be written without settling them, and in each case the
text supports both branches. A reader passes over all three without noticing.
Someone building a fixture cannot get to the end of one.

That order -- corpus first, and let it interrogate the text -- is the part worth
keeping if every rule here is replaced, because it yields a measurement this
repository does not otherwise have. Not whether tests cover the rules, which
measures an implementation, but whether two independent readings of the same
normative text produce the same rules, which measures the specification. Agreement
means the text is doing its job; divergence names the sentence that is missing.

§7.1 was already that measurement run once and was not labelled as one. Two
implementations written without reference to this document agree with §4.1 and
with each other on the digest preimage, and split on the unresolvable-algorithm
question -- one calling it unreadable, one calling it tampering. The first result
is the text working. The second is a located gap that took no argument to find,
because the same question was put to two implementations rather than debated.

It also settles what a second profile design would be for. One reading measures
nothing, so an independently written set of binding rules is the experiment, not
a collision with this one.

No rule, vector or suite changed. 585 passed.
The previous entry read that line as mutual delegation and reported this document
as short of it. That was a guess at the referent, made without checking, and it
is wrong.

In the reference implementation the mutual case is mutual attestation.
ca2a/docs/spec/mutual-attestation.md describes a callee-issued challenge and a
caller offer bound to it, so each side establishes what the other is running
before a payload opens. It separates the two concerns explicitly -- it
"establishes what each side is running", while "the delegation chain remains the
thing that says what it is allowed to ask for" -- and it does not mention a Trust
Record anywhere. Nor does cA2A's own docs/spec/trace-a2a-profile.md, whose A2A
profile is the delegation-link block and nothing else.

Which changes what the entry says. The roadmap asks the A2A profile to cover
something with no record representation today in either repository, sitting at
the transport layer rather than on this surface. That is a scoping question --
either mutual attestation gains a binding into the record, which is a schema
question rather than a verification one, or the v0.3 profile is two profiles --
and not a coverage failure in these rules. A reader comparing this document
against that roadmap line would otherwise conclude the second.

The bidirectional-delegation reading is kept as a separate note rather than
dropped, because it is true and unreachable for the reason section 4.2 gives, and
because the two readings should not merge later.

Every claim above traced to its file before writing: grep for "trace" in
mutual-attestation.md returns 0, grep for "mutual" in trace-a2a-profile.md
returns 0, and both quotations were checked against the source with whitespace
normalised, since the file wraps mid-sentence and a single-line grep misses them.

No rule, vector or suite changed. 585 passed, ruff clean.
@github-actions

github-actions Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Contributor Check: UNKNOWN

Check Result
Profile UNKNOWN
Credential LOW
Overall UNKNOWN

Automated check by AgenTrust Contributor Check.

@github-actions github-actions Bot added the needs-review:UNKNOWN Contributor check flagged UNKNOWN risk label Aug 18, 2026
@imran-siddique
imran-siddique self-requested a review August 18, 2026 18:57
imran-siddique pushed a commit to agentrust-io/ca2a that referenced this pull request Aug 20, 2026
…ot tampering (#119)

verify_trace_dag accepted sha384: as a well-formed parent link and then
compared it against a value trace_record_hash only ever computes as sha256:.
The mismatch was reported as ProvenanceLinkBroken, whose documented meaning in
failure-modes.md is that tampering or reparenting was detected.

Nothing was tampered with. The two halves disagreed: _DIGEST_RE admits both
digests the TRACE schema permits for delegation.parent_record_hash, and the
comparison had not caught up. A conforming, correctly signed chain differing
from an accepted baseline only in which permitted digest names its parent was
reported as a tampering finding, and an auditor reading the outcome had no way
to tell that from a real one.

Fail-closed either way: no forged, reparented or tampered DAG was accepted as a
result, and every sha256 chain verifies exactly as before. What changes is what
the verifier says about a chain it cannot read.

TraceDigestUnsupported (TRACE_DIGEST_UNSUPPORTED, 501) is raised instead, before
the comparison and per hop, so an unreadable link deep in a chain is not masked
by a readable one at the leaf. LINK_DIGEST names the algorithm once and is used
both to label and to compute the hash, so the verifier cannot drift from the
producer.

Tests cover the leaf case, the deep case, and a control that a well-formed link
pointing at the wrong record still raises ProvenanceLinkBroken: the fix narrows
tamper detection, it does not replace it. Both new tests fail without the guard.

Portable vectors for this case are agentrust-io/trace-spec#184, numbers 22 and
23, which assert unverifiable with digest_algorithm_unsupported.

Signed-off-by: lywinged <louie.lunz@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@imran-siddique

Copy link
Copy Markdown
Member

Noting for whoever reads this next, including me: the SHA-384 case is closed upstream. agentrust-io/ca2a#119 landed yesterday and carried the fix, not just the finding. At ca2a 52141e8, verify_trace_dag now raises TraceDigestUnsupported with the detail "the chain is unverifiable here, not invalid" before the link comparison is reached, so a schema-valid sha384: parent link is no longer reported as a tampered or reparented record.

That means vectors 22 and 23 now describe fixed behaviour rather than a live divergence, and they agree with the implementation as well as with the ruling. Worth dropping that divergence from §4 when this branch next moves, so the document does not carry a defect report that is already resolved.

Also worth stating plainly, because the comparison is the part of this I value most: §4.1 was confirmed independently. ca2a_runtime.trace_binding.trace_record_hash computes the digest over the full signed record with the signature included, using RFC 8785 canonical bytes, byte-identical to what the profile specifies, and that was arrived at in a different repository without reference to this text. Two readings of the same normative text converging is a measurement of the specification rather than of an implementation, and it is not one this repository otherwise takes.

The branch conflicts with main now. No hurry while it is a draft, and the review is still mine to do.

The UNKNOWN on the contributor check is the bot, not you. It returns UNKNOWN on any transient failure with no retry, and it has landed on you repeatedly. It carries no information and I do not read it as one.

Brings the branch up to f51e1f7, twenty-one commits on from the base it was
opened against. The only conflict is CHANGELOG.md, where both sides added an
entry at the top of Unreleased/Added: key revocation from agentrust-io#187 and this
branch's delegation profile. Both are kept, the merged change first and the
proposal under it, since one describes what landed and the other what is
proposed.

Nothing in the RFC needed changing. Its section references are its own, and the
one external citation, spec/trace-v0.2.md section 3.1, still resolves. The
"No revocation" limitation still stands as written: agentrust-io#187 anchors revocation of
a record-signing key, and what this profile leaves open is revocation of a
delegation credential, which is a different object.

Signed-off-by: Louielunz <48041247+lywinged@users.noreply.github.com>
… merged

agentrust-io#186 added criteria that every vector set on disk is measured against, and
test_every_vector_set_on_disk_is_measured_somewhere fails for a set in neither
SETS nor MEASURED_ELSEWHERE. This branch was opened three days before those
criteria landed, so merging upstream leaves `delegation-link` as the one set
nothing grades, and it is the only failure in the merged tree.

Registered in SETS rather than named in MEASURED_ELSEWHERE, because the set
holds up when it is actually graded rather than only pointed at:

    delegation-link: 23 vectors, 3 accepting, 10 distinct failure codes

No shortfall on either criterion decidable from the fixtures. It is not
satisfiable by an implementation that answers "accept" to everything or one
that answers "reject" to everything, and every one of the ten failure codes is
carried by exactly two vectors, which is the margin agentrust-io#124 asks for.

Boundaries are counted by failure code, the default. adequacy.py says that
assumption is the set's to justify: here the codes are the unit, because
tests/delegation_margins.json records the per-code margin and
tests/test_delegation_completeness.py holds each rule to being load-bearing for
both of its vectors, deleting the rule from the registry rather than matching
source text. The criteria adequacy.py leaves to each set, a rule nothing pins
and a weakness shared across a boundary's vectors, are implemented there too,
by rebuilding the registry without an entry and by substituting shortcut checks
that read only the first link or the first hop.

605 passed, 1 skipped. Verified by removing the SETS entry again, which fails
test_every_vector_set_on_disk_is_measured_somewhere on its own.

Signed-off-by: Louielunz <48041247+lywinged@users.noreply.github.com>
Section 7.1 recorded the cA2A disagreement in the present tense: its block
validator accepts a sha384: link, compares it against a hash it only computes
as sha256:, and reports ProvenanceLinkBroken with "a tampered or reparented
record was detected". That was true when the corpus was run and is not true
now, so the document was carrying a defect report against another repository
that the other repository has already fixed.

Checked at ca2a 52141e8 rather than taken from the report:
src/ca2a_verify/dag.py:194 raises TraceDigestUnsupported with the detail "the
chain is unverifiable here, not invalid" at line 199, and the parent-link
comparison that produced ProvenanceLinkBroken is at line 201, after it. So the
guard precedes the comparison and vectors 22 and 23 now describe fixed
behaviour.

Both places are re-tensed rather than deleted. What the case establishes is not
that one verifier had a bug: a corpus written to argue a rule found the case,
the other implementation changed, and the shape it changed to is the
distinction section 4.3 asks for. Deleting it would drop the strongest evidence
in the document that the corpus does what it claims. The second passage said
two of three implementations distinguish unreadable from contradicted; it is
now all three.

Section 4.3's rule text is untouched, since it states the rule rather than
reporting on an implementation.

605 passed, 1 skipped.

Signed-off-by: Louielunz <48041247+lywinged@users.noreply.github.com>
@lywinged

Copy link
Copy Markdown
Collaborator Author

Thank you Imran.
Both are done on the branch.

§7.1 carried the divergence in the present tense in two places, and both are re-tensed rather than deleted. I checked the fix rather than taking it from the report: at ca2a 52141e8, src/ca2a_verify/dag.py:194 raises TraceDigestUnsupported with that detail at line 199, and the parent-link comparison that produced ProvenanceLinkBroken is at line 201, after it. So the guard does precede the comparison, and 22 and 23 describe fixed behaviour.

They stay in the document because of what the case establishes rather than as a defect report. A corpus written to argue a rule found it, the other implementation changed, and the shape it changed to is the distinction §4.3 asks for. The second passage said two of three implementations separate unreadable from contradicted; it is now all three. §4.3's rule text is unchanged, since it states the rule rather than reporting on an implementation.

The branch is merged with main and no longer conflicts. The only conflict was CHANGELOG.md, where both sides had added an entry at the top of Unreleased/Added. The revocation entry from #187 is kept first and this proposal under it, since one describes what landed and the other what is proposed.

One thing that is not a response to your comment. #186 landed after this branch was opened, so on the merged tree delegation-link was the one vector set nothing graded, and the only failure. It is registered in SETS rather than named in MEASURED_ELSEWHERE, because it holds up when it is actually graded rather than only pointed at:

delegation-link: 23 vectors, 3 accepting, 10 distinct failure codes

No shortfall on either criterion decidable from the fixtures. Not satisfiable by an implementation that answers "accept" to everything, nor by one that answers "reject" to everything, and each of the ten failure codes is carried by exactly two vectors. Boundaries are counted by failure code, which tests/adequacy.py says is the set's assumption to justify: tests/delegation_margins.json records the per-code margin, and tests/test_delegation_completeness.py holds each rule to being load-bearing for both of its vectors, deleting the rule by rebuilding the registry without its entry rather than by matching source text.

605 passed, 1 skipped.

@imran-siddique

Copy link
Copy Markdown
Member

Let's ship it!

@lywinged
lywinged marked this pull request as ready for review August 22, 2026 23:13
@lywinged
lywinged requested a review from a team as a code owner August 22, 2026 23:13

@imran-siddique imran-siddique left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Shipping, as said on the thread. Thank you for checking the ca2a fix at 52141e8 yourself rather than taking my report, and for re-tensing §7.1 in both places instead of deleting the divergence. A profile that records what was true and when is worth more than one that quietly reads as though the gap never existed.

Non-blocking, and not yours to fix: CHANGELOG.md shows as +265-264 because the file converted CRLF to LF wholesale. Measured on the diff bytes, all 259 removed lines end in CR, none of the added ones do, and there are exactly two genuinely new lines and zero genuinely deleted. So the content change is one entry and the rest is your editor doing what the repository never told it not to. This repo has no .gitattributes, which is the actual defect. I am adding one rather than asking you to hand-revert 259 lines of blame.

@imran-siddique
imran-siddique merged commit aa4e3b5 into agentrust-io:main Aug 23, 2026
5 checks passed
lywinged added a commit to lywinged/trace-spec that referenced this pull request Aug 23, 2026
Spec section 3.1.2 landed in agentrust-io#198 as text. Text alone did not make the field
usable: schema/trace-claim.json sets additionalProperties: false at the top
level and TrustRecord is extra="forbid", so a record carrying references was
rejected by both artifacts a producer validates against. examples/tpm2.json
validates as committed and fails with "Additional properties are not allowed
('references' was unexpected)" the moment an entry is added.

The block is an array. That is the reading the text supports rather than a
decision made here: 3.1.2 says "a references entry", "an entry in references",
and "omit the entry", and its field table describes the fields of an entry
rather than of the block.

Two of the four rules in 3.1.2 are properties of a record and are tested:
rule 1, a record carrying references and no origin keeps the hardware
runtime.platform it earned; rule 2, the signature covers the block, so
rewriting any member of any entry in transit fails verification. Rules 3 and 4
bind verifiers rather than records — no schema can express them — and belong to
the conformance suite. The schema description says so rather than implying it
enforces them.

resolver is constrained on presence and not on value. Rule 4 requires a
producer that cannot name a resolver to omit the entry, and whether an
identifier is self-asserted is not decidable from the record.

retention is validated as an ISO 8601 duration by a pattern written out in
alternation rather than with a negative lookahead, so the same string serves
both files: pydantic's default regex engine has no look-around, and a lookahead
form would build in JSON Schema and raise in the model, forcing the two apart.
A test asserts the two pattern strings are byte-equal, and that the rel sets
match, because a case table only catches a divergence it happens to sample.

Both schema copies change together; tests/test_validate.py already fails on
drift between them. docs/schema.md gets the field and a table, guarded the same
way as the build_provenance table.

713 tests pass on this rebase onto aa4e3b5 (604 when first written; agentrust-io#184 added the delegation-link suite). Each guard was checked by reverting what it guards.

Signed-off-by: Louielunz <48041247+lywinged@users.noreply.github.com>
imran-siddique added a commit that referenced this pull request Aug 23, 2026
trace-spec had no .gitattributes, so line endings were whatever each
contributor's editor produced. #184 landed a one-line CHANGELOG entry as
+265-264, with all 259 removed lines ending in CR and none of the added ones,
rewriting the blame on a file nobody had edited. The same class cost two
listing PRs in awesome-ai-governance before it.

Pins *.md, *.json, *.py, *.yml and *.yaml to LF and normalises the ten text
files still stored with CRLF. The normalisation is line endings only:
git diff --cached --ignore-all-space reports no content change on any of them.

Doing it here rather than in a contributor's PR keeps the blame churn in one
commit that is obviously mechanical, instead of spread across whoever next
edits docs/schema.md, schema/trace-claim.json or src/agentrust_trace/models.py.
Those three are exactly the files #199 is currently touching.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
lywinged added a commit to lywinged/trace-spec that referenced this pull request Aug 23, 2026
Spec section 3.1.2 landed in agentrust-io#198 as text. Text alone did not make the field
usable: schema/trace-claim.json sets additionalProperties: false at the top
level and TrustRecord is extra="forbid", so a record carrying references was
rejected by both artifacts a producer validates against. examples/tpm2.json
validates as committed and fails with "Additional properties are not allowed
('references' was unexpected)" the moment an entry is added.

The block is an array. That is the reading the text supports rather than a
decision made here: 3.1.2 says "a references entry", "an entry in references",
and "omit the entry", and its field table describes the fields of an entry
rather than of the block.

Two of the four rules in 3.1.2 are properties of a record and are tested:
rule 1, a record carrying references and no origin keeps the hardware
runtime.platform it earned; rule 2, the signature covers the block, so
rewriting any member of any entry in transit fails verification. Rules 3 and 4
bind verifiers rather than records — no schema can express them — and belong to
the conformance suite. The schema description says so rather than implying it
enforces them.

resolver is constrained on presence and not on value. Rule 4 requires a
producer that cannot name a resolver to omit the entry, and whether an
identifier is self-asserted is not decidable from the record.

retention is validated as an ISO 8601 duration by a pattern written out in
alternation rather than with a negative lookahead, so the same string serves
both files: pydantic's default regex engine has no look-around, and a lookahead
form would build in JSON Schema and raise in the model, forcing the two apart.
A test asserts the two pattern strings are byte-equal, and that the rel sets
match, because a case table only catches a divergence it happens to sample.

Both schema copies change together; tests/test_validate.py already fails on
drift between them. docs/schema.md gets the field and a table, guarded the same
way as the build_provenance table.

713 tests pass on this rebase onto aa4e3b5 (604 when first written; agentrust-io#184 added the delegation-link suite). Each guard was checked by reverting what it guards.

Signed-off-by: Louielunz <48041247+lywinged@users.noreply.github.com>
imran-siddique pushed a commit that referenced this pull request Aug 23, 2026
* schema: put the references block in the schema and the model

Spec section 3.1.2 landed in #198 as text. Text alone did not make the field
usable: schema/trace-claim.json sets additionalProperties: false at the top
level and TrustRecord is extra="forbid", so a record carrying references was
rejected by both artifacts a producer validates against. examples/tpm2.json
validates as committed and fails with "Additional properties are not allowed
('references' was unexpected)" the moment an entry is added.

The block is an array. That is the reading the text supports rather than a
decision made here: 3.1.2 says "a references entry", "an entry in references",
and "omit the entry", and its field table describes the fields of an entry
rather than of the block.

Two of the four rules in 3.1.2 are properties of a record and are tested:
rule 1, a record carrying references and no origin keeps the hardware
runtime.platform it earned; rule 2, the signature covers the block, so
rewriting any member of any entry in transit fails verification. Rules 3 and 4
bind verifiers rather than records — no schema can express them — and belong to
the conformance suite. The schema description says so rather than implying it
enforces them.

resolver is constrained on presence and not on value. Rule 4 requires a
producer that cannot name a resolver to omit the entry, and whether an
identifier is self-asserted is not decidable from the record.

retention is validated as an ISO 8601 duration by a pattern written out in
alternation rather than with a negative lookahead, so the same string serves
both files: pydantic's default regex engine has no look-around, and a lookahead
form would build in JSON Schema and raise in the model, forcing the two apart.
A test asserts the two pattern strings are byte-equal, and that the rel sets
match, because a case table only catches a divergence it happens to sample.

Both schema copies change together; tests/test_validate.py already fails on
drift between them. docs/schema.md gets the field and a table, guarded the same
way as the build_provenance table.

713 tests pass on this rebase onto aa4e3b5 (604 when first written; #184 added the delegation-link suite). Each guard was checked by reverting what it guards.

Signed-off-by: Louielunz <48041247+lywinged@users.noreply.github.com>

* schema: open rel and drop minItems, per review

Two constraints the schema asserted that the normative text does not.

`rel` was a closed enum, matching `origin.kind`. Section 3.1.1 says of `kind`
that it is closed "because the value of the field is that a verifier can key on
it"; section 3.1.2 says only "Registered rel values" and does not make that
claim, which is a distinction the two sections draw on purpose. Closing it here
would make every new relation a schema change and a spec change at once. It is
now a plain string, with the three registered values named in the schema
description and in docs/schema.md.

`minItems: 1` is gone. Rule 4 requires a producer that cannot name a resolver to
omit the entry, not the block, and nothing in 3.1.2 says the array must be
non-empty. An empty array does look like a record claiming to point somewhere
and pointing nowhere, but that belongs in the spec if it belongs anywhere, and a
schema asserting what the text does not is this PR's own argument reversed.

The enum was also a guard: it made the registry mechanically checkable. What
replaces it is a test that the three values stay named in both the schema
description and docs/schema.md, since documentation is now all there is. Both
new guards were checked by putting the constraint back and confirming they fail.

The two case-table rows that asserted the old behaviour now assert the new: an
unregistered `rel` is accepted, and so is an empty array.

714 passed, 1 skipped. ruff and mypy clean.

Signed-off-by: Louielunz <48041247+lywinged@users.noreply.github.com>

* schema: rel must name a relation, per review

`rel` was open but not required to be present in any meaningful sense: with the
enum gone it accepted the empty string, while `id` and `resolver` were both held
to `minLength: 1`. An empty `rel` is not a future registered relation. It carries
no relation at all, so it is now rejected the way an empty `id` or `resolver` is.

`minLength: 1` in both schema copies and `min_length=1` on the model. The enum
does not return, and the array stays allowed to be empty: open about which
relation is named, strict that one is.

The rejection case goes in the case table rather than in a test of its own,
because the table runs every case against both the schema and the model and
asserts the error path starts at `references`. That makes it the attributable
rejection and the parity coverage in one, which was checked in both directions:
removing `minLength` from the schema copies fails the schema half and leaves the
model half green, and removing `min_length` from the model does the reverse.

`docs/schema.md` said the schema does not constrain `rel`, which stopped being
true with this commit. It now says the schema does not restrict which relation
is named, only that one is.

716 passed, 1 skipped. ruff and mypy clean.

Signed-off-by: Louielunz <48041247+lywinged@users.noreply.github.com>

* docs(changelog): rel reads as a closed set there, and stopped being one

The entry was written when `rel` was an enum, and its parenthetical list still
reads as the permitted values. It is now the registry's current contents, and a
reader who took the CHANGELOG at its word would expect the schema to reject a
fourth relation. It does not.

Restated: the three are what 3.1.2 registers today, the schema holds `rel` to
being non-empty rather than to that list, and the array may be empty because
rule 4 tells a producer to omit the entry rather than the block.

No behaviour change. Found by re-reading the prose written before the enum was
dropped rather than by a test, since nothing checks a changelog.

Signed-off-by: Louielunz <48041247+lywinged@users.noreply.github.com>

---------

Signed-off-by: Louielunz <48041247+lywinged@users.noreply.github.com>
lywinged added a commit to lywinged/trace-spec that referenced this pull request Aug 23, 2026
agentrust-io#184 added docs/rfcs/a2a-delegation-profile.md and did not touch mkdocs.yml, so
the RFCs nav section lists composable-zk-assurance.md alone and a reader
browsing the site never reaches its peer.

It is not unreachable: CHANGELOG.md links it, examples/delegation-link/README.md
links it, it is in sitemap.xml, and the built-in search index carries 13 entries
for it. What it has no path from is the navigation.

Measured with mkdocs build --strict on both configs, same environment:

  baseline  rc=0, no warnings; 0 links to it from the home page
  with nav  rc=0, no warnings; 1

The page file grows 87KB -> 93KB from the expanded sidebar; the rendered
<article> is byte-identical between the two builds (sha256 4d5b3cf2d6e94551).

llms.txt is untouched by design: the llmstxt plugin takes an explicit sections
list that names neither RFC.

Signed-off-by: Louielunz <48041247+lywinged@users.noreply.github.com>
lywinged added a commit to lywinged/trace-spec that referenced this pull request Aug 23, 2026
8380993 ("unhide ten pages", 2026-08-19) added ten nav entries, one of them
docs/rfcs/composable-zk-assurance.md, which had landed in agentrust-io#162 three days
earlier without one. agentrust-io#184 merged four days after that sweep and did not touch
mkdocs.yml, so docs/rfcs/a2a-delegation-profile.md is the eleventh page in the
same state. Nothing in agentrust-io#184's description, comments or reviews mentions the nav.

It is not unreachable — CHANGELOG.md links it, examples/delegation-link/README.md
links it, it is in sitemap.xml, and the search index carries 13 entries. What it
has no path from is the navigation.

Measured by reproducing the deployed build rather than a local one: .docs_build
assembled as docs.yml assembles it, the same sed applied to docs_dir, --strict
added.

  baseline  rc=0, no warnings;  2 pages link to it (CHANGELOG, and itself)
  with nav  rc=0, no warnings; 49 pages link to it (all of them, via the sidebar)

The RFC's page grows 87KB -> 93KB from the sidebar; its rendered <article> is
byte-identical across the two builds (sha256 4d5b3cf2d6e94551, 35201 chars).
sitemap.xml and the search index are identical in both.

llms.txt is untouched by design: the llmstxt plugin takes an explicit sections
list that names no RFC at all.

Signed-off-by: Louielunz <48041247+lywinged@users.noreply.github.com>
lywinged added a commit to lywinged/trace-spec that referenced this pull request Aug 23, 2026
47 nav entries, 48 built pages. docs/rfcs/a2a-delegation-profile.md is the page
they do not cover.

The same fix has been applied once already: 8380993
("Adopt-the-shared-design-system-and-unhide-ten-pages", 2026-08-19) added ten
nav entries, one of them docs/rfcs/composable-zk-assurance.md, which had landed
in agentrust-io#162 three days earlier without one. agentrust-io#184 merged four days after that sweep
and did not touch mkdocs.yml. Nothing in agentrust-io#184's description, comments or reviews
mentions the nav.

It is not unreachable — CHANGELOG.md links it, examples/delegation-link/README.md
links it, it is in sitemap.xml, and the search index carries 13 entries. What it
has no path from is the navigation.

Measured by reproducing the deployed build rather than a local one: .docs_build
assembled the way docs.yml assembles it, the same sed applied to docs_dir,
--strict added.

  baseline  rc=0, no warnings;  2 pages link to it (CHANGELOG, and itself)
  with nav  rc=0, no warnings; 49 pages link to it (all of them, via the sidebar)

Nothing else moves. 49 files differ, none added, none removed, no page loses a
link. 48 gain exactly one — the sidebar entry. The forty-ninth is the RFC's own
page, +17: its table of contents, which the theme renders only for a page it
navigates to. The rendered <article> is byte-identical across both builds
(sha256 4d5b3cf2d6e94551, 35201 chars); sitemap.xml and the search index are
identical in both.

llms.txt is untouched by design: the llmstxt plugin takes an explicit sections
list that names no RFC at all.

Signed-off-by: Louielunz <48041247+lywinged@users.noreply.github.com>
lywinged added a commit to lywinged/trace-spec that referenced this pull request Aug 24, 2026
…ded references

Rebased onto upstream/main, 26 commits behind. Six files were flagged by the overlap
check and three needed a real merge; the directions are the ones DECISIONS.md recorded
on 2026-08-10, because the situation is the same one.

models.py and __init__.py are upstream's plus this fork's two profile constants and
the accepted_profiles machinery. sign.py merged cleanly as this fork's richer version,
superseding the minimal cutover check upstream took from agentrust-io#125, which CLAUDE.md section
5 predicted when agentrust-io#125 was offered.

The adequacy criteria are now upstream's, from agentrust-io#186, and the fork's own copy is dropped
rather than merged: upstream's went through review, carries the completeness guard the
fork's did not, and gained a delegation-link set from agentrust-io#184. Only the verifier-compatibility
loader is re-applied, since that set is fork-only, along with its recorded shortfall.
The pyproject pythonpath entry goes with it: it existed for a bare `from adequacy
import` that upstream's version does not use.

Upstream's models.py docstring says "TRACE v0.1 Trust Record" on a class whose
eat_profile is Literal["...trace-v0.2"]. The fork's correction is kept.

The crosswalk guard did what it exists to do. Upstream replaced "Verifiers MUST consult
current revocation status" with a SCITT-anchored model and added the references block,
and the source-derived inventory failed until nine rows moved. The four references rows
are mostly absences: an assurance-neutral block staying neutral is discharged by nothing
deriving a platform value from it, and two of the four MUST NOTs bind verifier behaviour
that no record shape can express, which the schema description already says.

The revocation rows are the ones worth reading. This library implements the fallback in
3.2.3 and not the primary rule: there is no last_valid_entry_id, no log_id and no receipt
comparison in src/, so a record is rejected on a key match whether or not it carries an
anchor. Section 3.2.3 names that behaviour itself for deployments with no receipts, so
it is incomplete rather than wrong, and the row says which.

856 passed, 1 skipped; ruff and mypy clean. Each new row and the restored shortfall
entry were checked by mutation: deleting a row, misspelling an anchor, and dropping a
recorded boundary each turn the naming test red.

Signed-off-by: lywinged <louie.lunz@gmail.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
imran-siddique pushed a commit that referenced this pull request Aug 24, 2026
47 nav entries, 48 built pages. docs/rfcs/a2a-delegation-profile.md is the page
they do not cover.

The same fix has been applied once already: 8380993
("Adopt-the-shared-design-system-and-unhide-ten-pages", 2026-08-19) added ten
nav entries, one of them docs/rfcs/composable-zk-assurance.md, which had landed
in #162 three days earlier without one. #184 merged four days after that sweep
and did not touch mkdocs.yml. Nothing in #184's description, comments or reviews
mentions the nav.

It is not unreachable — CHANGELOG.md links it, examples/delegation-link/README.md
links it, it is in sitemap.xml, and the search index carries 13 entries. What it
has no path from is the navigation.

Measured by reproducing the deployed build rather than a local one: .docs_build
assembled the way docs.yml assembles it, the same sed applied to docs_dir,
--strict added.

  baseline  rc=0, no warnings;  2 pages link to it (CHANGELOG, and itself)
  with nav  rc=0, no warnings; 49 pages link to it (all of them, via the sidebar)

Nothing else moves. 49 files differ, none added, none removed, no page loses a
link. 48 gain exactly one — the sidebar entry. The forty-ninth is the RFC's own
page, +17: its table of contents, which the theme renders only for a page it
navigates to. The rendered <article> is byte-identical across both builds
(sha256 4d5b3cf2d6e94551, 35201 chars); sitemap.xml and the search index are
identical in both.

llms.txt is untouched by design: the llmstxt plugin takes an explicit sections
list that names no RFC at all.

Signed-off-by: Louielunz <48041247+lywinged@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs-review:UNKNOWN Contributor check flagged UNKNOWN risk

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants