Skip to content

test: every fixture generator must reproduce its committed fixtures - #171

Merged
imran-siddique merged 2 commits into
agentrust-io:mainfrom
lywinged:test/generator-reproduction
Aug 16, 2026
Merged

test: every fixture generator must reproduce its committed fixtures#171
imran-siddique merged 2 commits into
agentrust-io:mainfrom
lywinged:test/generator-reproduction

Conversation

@lywinged

Copy link
Copy Markdown
Collaborator

Both generators in this repository currently reproduce their committed fixtures. This adds the check that establishes it, and keeps establishing it.

A generated fixture and the script that generates it can disagree for as long as nobody runs the script, and every test stays green while they do: the tests read the committed file, so they measure the fixture and never the generator. Whichever of the two is stale, running the generator once rewrites the other, and the rewrite is silent.

I found this on a set in my own fork rather than here. 03-superseded-version-refused.json recorded superseded_profile_refused, matching the dedicated branch verify_record takes for the v0.1 identifier, while its generator still emitted the generic profile_not_accepted from before that branch existed. Both codes are registered, so nothing failed. Regenerating would have replaced a specific rule with a general one and left the suite green.

Generators are discovered rather than listed. A catalogue of things to keep in sync is correct until the first person forgets it, and that failure is silent too.

Reproduction

git fetch origin pull/171/head:pr171 && git checkout pr171
pytest -q tests/test_generators_reproduce_fixtures.py     # 3 passed

Load-bearing, in two independent directions:

# a changed emitted value
python - <<'PY'
import pathlib
p = pathlib.Path("examples/action-receipts/conformance/gen_rule_coverage_vectors.py")
p.write_text(p.read_text().replace("indent=2", "indent=3", 1))
PY
pytest -q tests/test_generators_reproduce_fixtures.py
# FAILED ...[conformance]
git checkout -- examples
# a generator that produces nothing at all
python - <<'PY'
import pathlib
p = pathlib.Path("examples/canonicalization-boundary/gen_boundary_vectors.py")
p.write_text(p.read_text().replace("def main()", "def _disabled_main()", 1) + "\n\ndef main():\n    pass\n")
PY
pytest -q tests/test_generators_reproduce_fixtures.py
# FAILED ...[canonicalization-boundary]  missing: [...]
git checkout -- examples

The discovery walk carries its own guard: test_generators_were_found fails if the walk returns nothing, since a walk that finds no generators would pass every parametrised case beneath it.

Full suite on 3.11: 379 passed, 1 skipped. Scope is tests/ only. No src, schema, example, workflow or dependency changes.

Both generators here currently do. This is the check that establishes it, and
keeps establishing it.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Louielunz <48041247+lywinged@users.noreply.github.com>
@github-actions

github-actions Bot commented Aug 13, 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 13, 2026

@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.

The byte comparison is useful, but it only compares names from the committed directory. If a generator begins emitting an additional JSON fixture that is not committed, cmpfiles never sees it and this guard passes—the same silent generator/artifact disagreement this PR is meant to prevent. Please compare the complete generated and committed *.json filename sets first, then compare bytes for the equal set. Add a test or demonstrated mutation showing an extra generated fixture turns the guard red.

@lywinged
lywinged force-pushed the test/generator-reproduction branch from e2bc71f to cc4d562 Compare August 16, 2026 03:03
A byte comparison driven by the committed names cannot see a fixture the
generator writes that nobody committed: cmpfiles is never asked about it.
Both name sets are now compared first, in both directions.

Seeding the work directory with the committed fixtures hid the mirror case.
A fixture the generator stopped writing was left behind by the copy and
compared against itself, so it agreed. The generator's own directory is
emptied of fixtures before it runs, and every byte compared is now a byte
that run wrote.

That turns up a fact worth recording rather than hiding: of the thirty
fixtures beside gen_rule_coverage_vectors.py it produces 10-30. Its own
first paragraph says why the rest cannot be reissued - 01-09 pin a key
whose private half is not published. They are named in NOT_GENERATED with
that reason, and an exemption is rejected both when it names a fixture that
is absent and when it names one the generator does produce, so it cannot
outlive the fact behind it.

Nine tests drive the check against small repositories built to trip it,
including the extra-fixture case above and the one the seeding hid. Removing
either name-set comparison, or the line that empties the directory, turns
one of them red.

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

Copy link
Copy Markdown
Collaborator Author

Both changes are in 54d7e09.
The name sets are compared before any bytes are, in both directions, and test_an_extra_generated_fixture_is_caught builds a repository whose generator writes a fixture that is not committed, then asserts the failure.
Fixing that surfaced the mirror case, which was mine: the work directory was seeded with copytree, so a fixture the generator stopped writing was left behind by the copy and compared against itself. It agreed, every time. The generator’s directory is now emptied of fixtures before it runs, so every byte compared is a byte that run wrote.
On this repository, the old guard against the new one:

mutation before after
generator writes an uncommitted fixture passes fails
generator drops a committed fixture passes fails
committed fixture edited by hand fails fails

The clean room also makes a standing fact visible: gen_rule_coverage_vectors.py produces 10-30 of the thirty fixtures beside it. Its own first paragraph gives the reason, that 01-09 pin a key whose private half is not published, so nothing can reissue them.
Those nine are named in NOT_GENERATED, and I would ask you to read that list as a ledger rather than a carve-out. It cannot grow quietly, because a committed fixture no generator produces fails the check and the only way in is an edit there, with a reason, in front of a reviewer. An exemption also withdraws reproduction and not verification: test_action_receipt_fixtures globs the same directory and puts all thirty through the verifier, signature included.
What it is really recording is a cost that has not arrived yet. While this corpus only grows, nine unreproducible vectors cost nothing. The first release that revises it makes every one of them a hand edit, and a hand edit is the failure this module exists to catch. That release is the one #66 and #173 are building toward. I have not touched them here, since reissuing changes pinned key material in vectors other implementations may pin and the timing is yours; I have filed it as #178 so it can be triaged on its own rather than living in a review thread, and I am happy to do the mechanical work whenever you want it.
390 passed, 1 skipped. ruff clean.

@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.

The prior filename-set bypass is resolved. The guard now compares produced and committed names in both directions, clears copied fixtures before generation, detects stale/unknown/unnecessary exemptions, and refuses ambiguous multi-generator directories. Its self-tests cover extra output, dropped output, byte drift, generator failure, empty corpora, and exemption lifecycle. All 14 focused assertions passed locally; pytest then encountered a Windows temp-directory cleanup PermissionError after completion, so that local exit is not claimed clean. Hosted Python 3.11/3.12 and CodeQL runs are green. Approved.

@imran-siddique
imran-siddique merged commit 8d67055 into agentrust-io:main Aug 16, 2026
6 of 7 checks passed
@lywinged
lywinged deleted the test/generator-reproduction branch August 18, 2026 05:01
lywinged added a commit to lywinged/trace-spec that referenced this pull request Aug 18, 2026
…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>
imran-siddique pushed a commit that referenced this pull request Aug 23, 2026
…with 23 conformance vectors (#184)

* rfc: propose delegation-link verification, with the vectors that argue 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 #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` (#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 #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>

* rfc: record what running the corpus through cA2A returned

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>

* rfc: run the corpus in the reverse direction, and read agent-manifest'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>

* rfc: name the two things a Project Lead would notice first

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.

* rfc: state the method, which was the reason for the order and went unwritten

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.

* rfc: correct what the roadmap's "mutual case" refers to

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.

* test(adequacy): grade delegation-link by the criteria this repository merged

#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 #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>

* rfc: the sha384 divergence is closed, so stop reporting it as live

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>

---------

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
Four days of upstream and every one of these broke loudly rather than silently,
which is the design working. Recorded here because what broke is more useful than
the repair.

The rebase skipped d365f8d, whose guard had landed upstream as agentrust-io#171. The commit
also carried the gen_vectors.py fix that upstream did not take, so skipping it
dropped that half and the generator drifted from its fixtures again. Restored.
A commit that bundles two changes cannot be skipped by halves.

The depth loader named surface/builder_chain/dependency_chain, which upstream
renamed to surface/builder/transitive. It now reads the depth names from a fixture
instead of restating them, so the next rename changes nothing here.

The same loader read only `outcome`, and upstream moved the signal: a vector that
cannot be established at a depth now reports accept with `verified_depth` short of
it and the reason under `unresolved`, which separates cannot-establish from failed
and is the better shape. Reading the verdict alone scored 04 and 06 as separating
nothing. Measured properly the set is healthy: builder has two vectors, transitive
three, plus the accepting control.

docs/normative-crosswalk.md gains nine rows for spec 3.3.1, and its prose no longer
states requirements it does not cite. That guard is upstream's, contributed from
here as agentrust-io#175, and it was failing on this fork's own document.

603 pass; the three failures are the schema-const collision that predates this.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Louielunz <48041247+lywinged@users.noreply.github.com>
opento-suggestions added a commit to opento-suggestions/trace-tests that referenced this pull request Aug 25, 2026
`appraisal.policy_ref` is a bare URI. A record names the appraisal policy
that produced its verdict but carries nothing stating what that URI held,
so two verifiers resolving it at different times can retrieve different
documents and both report `affirming` honestly. The enforcement policy is
digest-bound through `policy.bundle_hash`; the appraisal policy is not.

This adds seven candidate vectors under
tests/vectors/appraisal-resolution/, their generator, and three test files
that grade the set. It adds no module, no schema change, and no
dependency, and nothing here imports or requires a conformance module that
does not exist on main.

Why vectors rather than a check. Nothing in this repository resolves
`policy_ref` today, so there is no implementation to test. What a vector
set can do before an implementation exists is fix what the answers should
be, which is the more useful half while the shape is still open.

The set at a glance, one defect per vector, every record identical except
for `appraisal.policy_ref`:

  01 no-binding-declared              accept
  02 resolved-and-matches             accept
  03 digest-mismatch, one byte apart  reject
  04 digest-mismatch, other object    reject
  05 referent unreachable             deferred
  06 digest algorithm uncomputable    deferred
  07 binding bound to another URI     reject

01 and 02 are why the set is not one-directional. Written from the
motivating problem alone, every vector would be a rejection or a deferral,
and a verifier that rejects everything would pass. 01 is the
backward-compatibility control: every conformant record today declares no
binding and must keep verifying, or this set would be proposing a breaking
change rather than describing a gap.

03 and 04 keep the contradicted boundary off a single vector. 03 differs
from the appraised object in exactly one byte, moving a SLSA floor from 2
to 3, which flips this record's verdict; 04 substitutes an unrelated
document of a different length. A verifier comparing lengths, or sampling
a prefix, passes one and fails the other.

05 and 06 are unresolvable by different mechanisms: one cannot reach the
object, the other reaches it and cannot compute over it, because the
declared algorithm is outside the set the schema admits.

07 is the vector a well-formedness check passes. The binding is a valid
sha256 digest and is the true digest of a real object in the set, while
`policy_ref` cites a different one. Both halves are valid; the pair is not.

What this deliberately does not decide. The outcome a verifier should
record for an unresolvable citation is open across four surfaces and is
tracked by agentrust-io/trace-spec#190. Vectors 05 and 06 assert only that
the outcome is not `affirming`. `deferred` is fixture bookkeeping in a
vector's expected block, not a proposed value for `appraisal.status`,
which stays closed at affirming/warning/contraindicated/none;
test_appraisal_resolution_completeness.py fails if any vector reuses a
status value as an outcome.

`candidate_binding` is likewise a candidate shape, marked CANDIDATE: in
every vector and carried in the vector's `context`, never in `record` —
`appraisal` is additionalProperties: false, so a record carrying it would
be schema-invalid, and proposing a field is an editorial decision.

Reproduction. The generator is deterministic: no keys, no clock, no
randomness, no network. Digests are SHA-256 over the exact bytes of the
sibling files under policies/, recomputable by anyone holding only that
directory. test_appraisal_resolution_reproduces.py regenerates into a
temporary directory and compares bytes rather than regenerating in place,
which would compare the files to themselves and agree regardless.

The guard is self-contained by choice. agentrust-io/trace-spec#171 covers
that repository's examples/ and this repository has no equivalent
registry; reaching across for one would be a guard that needs another
checkout, which is a guard that gets skipped.

.gitattributes pins eol=lf for the directory, and is load-bearing rather
than tidy. With core.autocrlf=true, restoring a policy file through
`git checkout --` rewrote its SHA-256 from d8764863... to 7e68506c...,
which would break every digest in the set on a clean Windows clone.

Records are unsigned and ASCII-only. Unsigned because the defect under
test is resolution of a cited object, orthogonal to the envelope
signature — signing would put a second variable in every vector — and
because keyless vectors regenerate from this directory alone. ASCII-only
because tests/conftest.py reads vectors with a bare Path.read_text() and
no explicit encoding, so a non-ASCII byte would decode under the platform
locale rather than a defined one.

Verification, on main at c725bbb: 246 passed, 5 xpassed (201 + 5 without
this change, so +45 and nothing displaced); 88 passed under
-m "level0 or negative", all 45 new tests collecting into that gate; ruff
and mypy clean on the new files.

Refs: agentrust-io#63, agentrust-io/trace-spec#66,
agentrust-io/trace-spec#190
imran-siddique pushed a commit to agentrust-io/trace-tests that referenced this pull request Aug 25, 2026
* test(appraisal): candidate vectors for appraisal.policy_ref resolution

`appraisal.policy_ref` is a bare URI. A record names the appraisal policy
that produced its verdict but carries nothing stating what that URI held,
so two verifiers resolving it at different times can retrieve different
documents and both report `affirming` honestly. The enforcement policy is
digest-bound through `policy.bundle_hash`; the appraisal policy is not.

This adds seven candidate vectors under
tests/vectors/appraisal-resolution/, their generator, and three test files
that grade the set. It adds no module, no schema change, and no
dependency, and nothing here imports or requires a conformance module that
does not exist on main.

Why vectors rather than a check. Nothing in this repository resolves
`policy_ref` today, so there is no implementation to test. What a vector
set can do before an implementation exists is fix what the answers should
be, which is the more useful half while the shape is still open.

The set at a glance, one defect per vector, every record identical except
for `appraisal.policy_ref`:

  01 no-binding-declared              accept
  02 resolved-and-matches             accept
  03 digest-mismatch, one byte apart  reject
  04 digest-mismatch, other object    reject
  05 referent unreachable             deferred
  06 digest algorithm uncomputable    deferred
  07 binding bound to another URI     reject

01 and 02 are why the set is not one-directional. Written from the
motivating problem alone, every vector would be a rejection or a deferral,
and a verifier that rejects everything would pass. 01 is the
backward-compatibility control: every conformant record today declares no
binding and must keep verifying, or this set would be proposing a breaking
change rather than describing a gap.

03 and 04 keep the contradicted boundary off a single vector. 03 differs
from the appraised object in exactly one byte, moving a SLSA floor from 2
to 3, which flips this record's verdict; 04 substitutes an unrelated
document of a different length. A verifier comparing lengths, or sampling
a prefix, passes one and fails the other.

05 and 06 are unresolvable by different mechanisms: one cannot reach the
object, the other reaches it and cannot compute over it, because the
declared algorithm is outside the set the schema admits.

07 is the vector a well-formedness check passes. The binding is a valid
sha256 digest and is the true digest of a real object in the set, while
`policy_ref` cites a different one. Both halves are valid; the pair is not.

What this deliberately does not decide. The outcome a verifier should
record for an unresolvable citation is open across four surfaces and is
tracked by agentrust-io/trace-spec#190. Vectors 05 and 06 assert only that
the outcome is not `affirming`. `deferred` is fixture bookkeeping in a
vector's expected block, not a proposed value for `appraisal.status`,
which stays closed at affirming/warning/contraindicated/none;
test_appraisal_resolution_completeness.py fails if any vector reuses a
status value as an outcome.

`candidate_binding` is likewise a candidate shape, marked CANDIDATE: in
every vector and carried in the vector's `context`, never in `record` —
`appraisal` is additionalProperties: false, so a record carrying it would
be schema-invalid, and proposing a field is an editorial decision.

Reproduction. The generator is deterministic: no keys, no clock, no
randomness, no network. Digests are SHA-256 over the exact bytes of the
sibling files under policies/, recomputable by anyone holding only that
directory. test_appraisal_resolution_reproduces.py regenerates into a
temporary directory and compares bytes rather than regenerating in place,
which would compare the files to themselves and agree regardless.

The guard is self-contained by choice. agentrust-io/trace-spec#171 covers
that repository's examples/ and this repository has no equivalent
registry; reaching across for one would be a guard that needs another
checkout, which is a guard that gets skipped.

.gitattributes pins eol=lf for the directory, and is load-bearing rather
than tidy. With core.autocrlf=true, restoring a policy file through
`git checkout --` rewrote its SHA-256 from d8764863... to 7e68506c...,
which would break every digest in the set on a clean Windows clone.

Records are unsigned and ASCII-only. Unsigned because the defect under
test is resolution of a cited object, orthogonal to the envelope
signature — signing would put a second variable in every vector — and
because keyless vectors regenerate from this directory alone. ASCII-only
because tests/conftest.py reads vectors with a bare Path.read_text() and
no explicit encoding, so a non-ASCII byte would decode under the platform
locale rather than a defined one.

Verification, on main at c725bbb: 246 passed, 5 xpassed (201 + 5 without
this change, so +45 and nothing displaced); 88 passed under
-m "level0 or negative", all 45 new tests collecting into that gate; ruff
and mypy clean on the new files.

Refs: #63, agentrust-io/trace-spec#66,
agentrust-io/trace-spec#190

* refactor(vectors): rename appraisal-resolution to policy-resolution

Directory, generator, tests, and the four policy files renamed to match
the retarget. Bytes unchanged (sha256 verified). Policy files are named
by the defect each carries: base, onebyte, other, unrelated.

Signed-off-by: opento-suggestions <opentosuggestionsofficial@gmail.com>

* feat(modules): per-code table for when UNVERIFIED fails the run

Adds modules/unverified.py: UNVERIFIED_FAILS_FROM_LEVEL and
unverified_fails(). cli.py and report.py call it in place of the blanket
level >= 1. Unlisted codes default to level >= 1 (fail-closed, previous
behaviour). result.py restates the UNVERIFIED contract in general terms:
the check could not be executed against the evidence the record cites.

The table lives under modules/ so test_docs_match_the_modules can see it,
and the error-codes.md row for TR-POL-003 lands here because the table
names the code and the guard reads string literals. Test totals
unchanged.

Signed-off-by: opento-suggestions <opentosuggestionsofficial@gmail.com>

* feat(tr-pol): TR-POL-003 resolves policy_uri and matches bundle_hash

policy_resolver: Callable[[str], bytes] | None = None on tr_pol.check
and runner.run, threaded explicitly - the fifth caller-supplied input of
this shape after #79's receipt. None -> SKIP, so
offline runs are unchanged.

policy_uri that is not an absolute URI -> FAIL, checked before the
resolver is consulted. Resolver raises -> UNVERIFIED, message carries the
exception text. Digest recomputed with the algorithm bundle_hash names
(sha256 or sha384); match -> PASS, mismatch -> FAIL.

The CLI summary line for unverified findings is reworded: TR-SIG-005 is
no longer the only code that can be unverified. The TR-SIG-005 finding
text is unchanged.

Signed-off-by: opento-suggestions <opentosuggestionsofficial@gmail.com>

* feat(cli): --policy-dir supplies a manifest-backed policy resolver

Same shape as --receipt: DIR/resolutions.json is form-checked at load and
malformed input exits 2. Existence is a resolve-time fact, so a missing
key or a missing file surfaces as UNVERIFIED, not as a CLI error.

Signed-off-by: opento-suggestions <opentosuggestionsofficial@gmail.com>

* test(vectors): retarget onto policy.bundle_hash and policy_uri

Eleven vectors, every record identical except policy.*; runtime.nonce
added so level 1 carries exactly one constant failure. Boundaries:
accept 3, contradicted 4, unresolvable 2, malformed 2. Each vector's
context carries its anchor and tier; candidate_binding and the deferred
markers are removed, both fields being merged.

Adds the CLI differential (failure deltas [0, 0, 1] across levels, exit 0
at level 0 both ways) and the report-path test. No hand-built Findings,
and no expectation derived from the table: a test that read its expected
delta from the registration table stayed green under every row mutation
and was removed. Byte-reproduction re-established over vectors, bundles,
and manifest.

Signed-off-by: opento-suggestions <opentosuggestionsofficial@gmail.com>

* docs: register TR-POL-003, TR-POL-001 admits sha384, --policy-dir

error-codes.md, modules/tr-pol.md, modules.md, levels.md (both failure
lists and the Unverified findings table), quickstart.md. Guard extended
three ways: every UNVERIFIED emitter is in the table, the table is a
subset of documented codes, and the table equals the levels.md table.

Signed-off-by: opento-suggestions <opentosuggestionsofficial@gmail.com>

---------

Signed-off-by: opento-suggestions <opentosuggestionsofficial@gmail.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