Skip to content

test: conformance vectors for seven unexercised receipt rules - #122

Merged
imran-siddique merged 2 commits into
agentrust-io:mainfrom
lywinged:conformance/rule-coverage-vectors
Aug 8, 2026
Merged

imran-siddique merged 2 commits into
agentrust-io:mainfrom
lywinged:conformance/rule-coverage-vectors

Conversation

@lywinged

@lywinged lywinged commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

What

Seven conformance fixtures, one per receipt rule that the verifier applies and that no
fixture previously exercised.

Rule What an implementation can omit today and still pass
action_ref_invalid Recomputing the action reference instead of trusting the declared value
call_id_mismatch Checking the receipt is bound to this call
session_id_mismatch Checking it is bound to this session
evidence_hash_mismatch Recomputing the evidence digest
issuer_key_untrusted Checking the signing key against a pinned set at all
receipt_from_future Rejecting a receipt issued after the verification time
decision_invalid Refusing to read an unknown verb as accept or reject

Why these two matter beyond tidiness

Without issuer_key_untrusted, a receipt authenticates itself. A signature verifies
against whatever key it names, and only a pinned set decides whether that key was ever
entitled to speak.

Without evidence_hash_mismatch, the signature covers a digest whose document can be
swapped. The receipt signs evidence_hash, not the evidence body, so only recomputation
catches a substituted body.

The other five are ordinary binding and freshness checks, and the same argument applies
in weaker form: a suite that never exercises a rule certifies implementations that skip
it.

How they were found

By walking the verifier's source for every failure code it can emit and comparing that
against the codes the fixtures expect. The seven above were emitted by the verifier and
expected by nothing.

I am happy to contribute the check itself as a follow-up if it is wanted — it recovers
the rule inventory from source with ast rather than from a hand-maintained list, so a
rule added without a fixture fails rather than passing quietly. Keeping it out of this PR
so the fixtures can be judged on their own.

Shape

One fixture per rule, each triggering exactly that rule and nothing else, so a failure
names the check that broke rather than a set of them.

They pin their own deterministic test key. The private half of the key behind 0109
is not published, and each fixture already carries its own trusted_issuer_keys, so a
separate key changes nothing about how the set is verified.
gen_rule_coverage_vectors.py regenerates the set byte-for-byte; only public JWKs appear
in the files. Worth stating plainly that the keys are deliberately deterministic test
keys rather than leaving a reviewer to notice the pattern.

Scope

Additive. No existing fixture changes, no verifier changes, no schema or spec changes.
The only edits to existing files are the filename list in test_fixture_set_is_complete
and a table in the examples README.

Under GOVERNANCE.md this is a conformance-test addition: no normative text, so no
sponsor and no comment window. Please tell me if you read it differently.

Checks

  • 112 tests pass (105 before, plus 7)
  • ruff check src tests and mypy src/agentrust_trace clean
  • Verified on Python 3.11, 3.12, 3.13 and 3.14
  • DCO signed

The receipt verifier applies rules that no fixture exercises. An implementation
can omit each of these checks entirely and still pass the published set, which is
the one thing a conformance suite is supposed to prevent.

  action_ref_invalid       recomputing the action reference rather than trusting
                           the declared value
  call_id_mismatch         that the receipt is bound to this call
  session_id_mismatch      that it is bound to this session
  evidence_hash_mismatch   recomputing the evidence digest
  issuer_key_untrusted     the signing key against a pinned set at all
  receipt_from_future      a receipt issued after the verification time
  decision_invalid         refusing to read an unknown verb as accept or reject

Two are load-bearing for the trust model rather than tidiness. Without
issuer_key_untrusted a receipt authenticates itself, since a signature verifies
against whatever key it names and only a pinned set decides whether that key was
entitled to speak. Without evidence_hash_mismatch the signature covers a digest
whose document can be swapped, because the receipt signs the digest and not the
evidence body.

One fixture per rule, each triggering exactly that rule and nothing else, so a
failure names the check that broke. They pin their own deterministic test key:
the private half of the key behind 01-09 is not published, and each fixture
already carries its own trusted_issuer_keys. gen_rule_coverage_vectors.py
regenerates the set byte-for-byte and only public JWKs appear in the files.

Found by walking the verifier's source for every failure code it can emit and
comparing that against the codes the fixtures expect, rather than by reading the
set and guessing what was missing.

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

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Contributor Check: UNKNOWN

Check Result
Profile UNKNOWN
Credential LOW
Overall UNKNOWN

Automated check by AgenTrust Contributor Check.

@lywinged

lywinged commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator Author

Flagging something against my own PR, found while reading §3.3 rather than the verifier.

22-receipt-issuer-key-untrusted.json pins behaviour that diverges from §3.3.1.

The spec says, of a receipt whose issuer key the verifier does not hold:

A receipt whose issuer key is unknown to the verifier is unverified, not invalid. The
Trust Record's gateway-produced evidence (signature, audit-chain hash, policy hash, TEE
measurement) is unaffected. Verifiers SHOULD surface an advisory status (e.g.
external_evidence_unverified) rather than silently ignoring the receipt.

The fixture verifier appends issuer_key_untrusted to failures, which resolves to
receipt_invalid. My fixture records that as the expected outcome:

"status": "receipt_invalid",
"failures": ["issuer_key_untrusted"],
"warnings": []

Advisory and invalid are different claims. §3.3.1 draws the line deliberately — an
unconfigured key is a gap in the verifier's configuration, not a defect in the evidence,
and the distinction is the one the section spends a paragraph on.

The divergence is not mine. issuer_key_untrusted has been a failure since before this
branch; the fixture only records what the verifier already does. But that is the part worth
raising: a conformance vector that pins a divergence makes it harder to correct, because
it converts an accident into an expectation.
Left as is, changing the verifier to match
§3.3.1 would show up as a failing conformance test, which is backwards.

§3.3.2 is informative and this fixture set declares its own test profile, so nothing here
is strictly violated. It diverges without saying so, which is the smaller version of the
same problem.

What I would suggest, though it is your call

Three options, in the order I would rank them:

  1. The verifier is wrong. Emit issuer_key_untrusted as a warning with a status that
    is not receipt_invalid, matching §3.3.1. That is a behaviour change rather than a test
    addition, so it does not belong in this PR — but the fixture should then be written
    against the corrected behaviour, not merged first and rewritten after.
  2. The spec's rule does not reach this profile, because §3.3.2 is informative and the
    profile may be stricter on purpose. Then the fixture is right and the divergence is
    worth one sentence in examples/README.md, so the next reader does not have to
    rediscover it.
  3. Drop 22 from this PR and land the other six, leaving that rule unguarded until the
    question is settled.

I have no view on which of the first two is correct — it is a question about intent, not
about code. Happy to do any of the three; tell me which and I will push it.

The other six fixtures are unaffected: none of them touches a rule §3.3.1 or §3.3.2 speaks
to differently from the verifier.

Spec section 3.3.1: "A receipt whose issuer key is unknown to the verifier
is unverified, not invalid", surfaced as an advisory rather than silence.
The receipt verifier appended issuer_key_untrusted to failures, resolving
to receipt_invalid, and fixture 14 pinned that divergence as the expected
outcome — flagged on the PR before this commit.

The verifier now records an issuer_key_unknown advisory in warnings and,
when nothing else failed, returns a fifth outcome, receipt_unverified: no
trust conferred, no forgery proven. The structural checks still run first,
and any positive failure still yields receipt_invalid. Fixture 14 is
renamed to 14-receipt-issuer-key-unknown.json and regenerated against the
corrected behaviour; the other six fixtures are byte-identical. The outcome
table in docs/verification.md gains the new row.

An unpinned key is an inability to check, not evidence of forgery.
"Invalid" claimed evidence this path never had.

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

lywinged commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed the correction for the flag above, as option 1 (ff6ad93): the verifier now surfaces an unknown issuer key as an issuer_key_unknown advisory in warnings, with a fifth outcome receipt_unverified, and fixture 14 is regenerated against that behaviour — renamed to 14-receipt-issuer-key-unknown.json, since the old filename carried the same conflation. The other six fixtures are byte-identical, and the outcome table in docs/verification.md gains the new row.

I said above that the behaviour change was not this PR's to make, and the call is still yours. But every reading I could construct came out the same way — an unpinned key is an inability to check, not evidence of forgery, and §3.3.1 spends its paragraph on exactly that line — and of the three options, merging a vector that pins the divergence looked strictly worst. So the branch now carries the spec-aligned behaviour as the default rather than the divergence. If you would rather take option 2 or 3, reverting is one commit: say so and I will push it.

@lywinged

lywinged commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator Author

One more note, to make this easier to dispose of rather than to add to it.

The six vectors other than 14 touch nothing §3.3.1 or §3.3.2 speaks to differently from the verifier. They were reviewable before I raised the issuer-key question and they still are. If it is useful, say the word and I will drop 14 and the behaviour change from this branch entirely, leaving six fixtures that are pure test additions with no decision attached. The issuer-key question then lives on as its own issue, where it can take as long as it needs without holding anything up.

I would rather that than have a question I raised become the reason six unrelated vectors sit unmerged.

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

Seven rules the verifier applies that no fixture exercised. An implementation could skip recomputing the action reference or the evidence digest, or never pin the issuer key, and still pass the suite. Closing that is worth doing before the spec moves and other-language verifiers start certifying against it.

@imran-siddique
imran-siddique merged commit 66990a6 into agentrust-io:main Aug 8, 2026
1 of 2 checks passed
lywinged added a commit to lywinged/trace-spec that referenced this pull request Aug 10, 2026
Upstream merged agentrust-io#148, the last large piece this fork was carrying, which the
maintainer rebased and opened himself under this fork's authorship. With agentrust-io#122,
agentrust-io#125, agentrust-io#126, agentrust-io#136 and agentrust-io#137 already merged, most of the thirty-three commits here
described work that now lives upstream with better provenance than this fork can
give it: a PR number and a maintainer's review.

`git rebase upstream/main` was tried first and abandoned. It stopped on the oldest
commit in the set, a schema-and-version alignment whose content upstream has since
taken, superseded and released three times over. Replaying thirty-three commits
against fifteen of upstream's resolves early commits into shapes that no longer
mean anything.

So: main reset to upstream/main, fork-only material re-applied. The old history is
tagged `archive/pre-576507b` and pushed rather than discarded.

What is held here, and why it is held:

- agentrust-io#117 gap disclosure: design note, two normative drafts, 18 vectors, generator
- agentrust-io#116 verifier compatibility: 8 vectors, generator, normative draft
- the normative crosswalk, mapping every RFC 2119 statement to whom it binds
- `docs/conformance-method.md`, `coverage-report/` (historical), DECISIONS.md
- the independent signature path and the package-consistency test

Each is an unaccepted proposal or a method write-up, not unfinished work.

Four files needed a real merge, and not in the same direction. `models.py` and
`__init__.py` are upstream's plus this fork's two profile constants, because
upstream had moved on with `origin` (agentrust-io#135) and the `declared` enforcement mode
(agentrust-io#143) and a wholesale copy would have dropped both. `sign.py` and `test_sign.py`
are this fork's `accepted_profiles` version, which supersedes the minimal cutover
check upstream took from agentrust-io#125 -- a supersession CLAUDE.md predicted when agentrust-io#125 was
offered. All four merged cleanly three-way against a817621, the last commit the
two histories agree on.

The crosswalk guard earned its place in the same run: upstream added two normative
statements this fork had never seen, and `test_normative_crosswalk.py` failed until
both had rows. A source-derived inventory noticing its subject moved is the
property that document exists to have.

432 passed, 1 skipped. ruff and mypy clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: lywinged <48041247+lywinged@users.noreply.github.com>
@lywinged
lywinged deleted the conformance/rule-coverage-vectors branch August 18, 2026 05:01
lywinged added a commit to lywinged/trace-spec that referenced this pull request Aug 24, 2026
Upstream merged agentrust-io#148, the last large piece this fork was carrying, which the
maintainer rebased and opened himself under this fork's authorship. With agentrust-io#122,
described work that now lives upstream with better provenance than this fork can
give it: a PR number and a maintainer's review.

`git rebase upstream/main` was tried first and abandoned. It stopped on the oldest
commit in the set, a schema-and-version alignment whose content upstream has since
taken, superseded and released three times over. Replaying thirty-three commits
against fifteen of upstream's resolves early commits into shapes that no longer
mean anything.

So: main reset to upstream/main, fork-only material re-applied. The old history is
tagged `archive/pre-576507b` and pushed rather than discarded.

What is held here, and why it is held:

- agentrust-io#117 gap disclosure: design note, two normative drafts, 18 vectors, generator
- agentrust-io#116 verifier compatibility: 8 vectors, generator, normative draft
- the normative crosswalk, mapping every RFC 2119 statement to whom it binds
- `docs/conformance-method.md`, `coverage-report/` (historical), DECISIONS.md
- the independent signature path and the package-consistency test

Each is an unaccepted proposal or a method write-up, not unfinished work.

Four files needed a real merge, and not in the same direction. `models.py` and
`__init__.py` are upstream's plus this fork's two profile constants, because
upstream had moved on with `origin` (agentrust-io#135) and the `declared` enforcement mode
(agentrust-io#143) and a wholesale copy would have dropped both. `sign.py` and `test_sign.py`
are this fork's `accepted_profiles` version, which supersedes the minimal cutover
check upstream took from agentrust-io#125 -- a supersession CLAUDE.md predicted when agentrust-io#125 was
offered. All four merged cleanly three-way against a817621, the last commit the
two histories agree on.

The crosswalk guard earned its place in the same run: upstream added two normative
statements this fork had never seen, and `test_normative_crosswalk.py` failed until
both had rows. A source-derived inventory noticing its subject moved is the
property that document exists to have.

432 passed, 1 skipped. ruff and mypy clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: lywinged <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