Skip to content

Commit 1f76106

Browse files
lywingedclaude
andauthored
test(conformance): adequacy criteria for a vector set, applied to every set here (#186)
A conformance vector set is a claim that a non-implementing verifier will fail it. Nothing checked that claim. #169 and #170 were both found by asking it of a set rather than of a vector, and both were sets that were passing. Four criteria, each from a defect on a real set rather than from first principles: - a set must fail both unconditional implementations, accept-everything and reject-everything, or it pins nothing - each boundary needs more than one vector, since a single vector cannot distinguish a check that reads the head of a list from one that reads all of it - every set on disk is measured here or named with the test that measures it - shortfalls are recorded exactly, so they cannot widen unnoticed and the entry is deleted when someone closes the gap Applied to every set in this repository. `build-provenance-depth` carries a margin at every boundary. `canonicalization-boundary`, which I wrote, expects acceptance in every vector and so cannot tell a conformant verifier from one that accepts unconditionally; that is recorded rather than skipped, and the record is asserted so it cannot grow. `action-receipts` is named as covered by test_vector_completeness.py rather than graded twice. The completeness guard is on the instrument itself for a reason. SETS is a hand-maintained list of what gets graded, which is the defect these criteria exist to catch, and the one place it would otherwise be invisible: a set added later would simply not be graded and nothing would fail. Adding an unlisted set directory turns the guard red, as does a stale entry, as does naming a test that does not exist. Each was checked by making the change and watching the specific test fail. Signed-off-by: lywinged <louie.lunz@gmail.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent d4e5c36 commit 1f76106

3 files changed

Lines changed: 502 additions & 0 deletions

File tree

‎docs/conformance-method.md‎

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
# How the conformance vectors are built, and how the set is checked for holes
2+
3+
*Informative. This page describes method, not requirements, and carries no RFC 2119
4+
keywords.*
5+
6+
A conformance suite makes a promise: an implementation that passes it has done the
7+
things the specification asks for. That promise is only as good as the suite's
8+
coverage, and coverage is usually assumed rather than established. A suite that never
9+
exercises a rule certifies implementations that skip that rule entirely — silently,
10+
and with a green badge.
11+
12+
This page describes how the action-receipt vectors in
13+
`examples/action-receipts/conformance/`
14+
are built, and how the set itself is checked. The checking part found seven rules in
15+
the receipt verifier with no vector behind them.
16+
17+
## 1. Vectors are data, not tests
18+
19+
Each vector is a JSON file stating the inputs, the trust anchors, and the outcome any
20+
conformant verifier must produce:
21+
22+
```jsonc
23+
{
24+
"context": { /* what the verifier is told: session, call, freshness policy */ },
25+
"action": { /* the action and its canonical reference */ },
26+
"trusted_issuer_keys": { /* the pinned key set — nothing self-authenticates */ },
27+
"receipt": { /* the artifact under test, genuinely signed */ },
28+
"expected": { "status": "...", "failures": [...], "warnings": [...] }
29+
}
30+
```
31+
32+
Nothing in a vector names a language, a function, or an API. An implementation writes a
33+
small adapter that feeds the JSON to its own verifier and compares against `expected`;
34+
the vectors themselves are portable.
35+
36+
This is worth stating because it is easy to get wrong. One set I wrote began as tests
37+
against a single library's function signature. Those tests were correct, and they were
38+
not conformance vectors: no second implementation could run them. The shape matters
39+
more than the assertions, and the two are easy to confuse while the assertions are
40+
passing.
41+
42+
**Every artifact in a vector is genuinely signed, including in the negative cases.**
43+
A vector that expects a rejection must reject for the reason it names. If its signature
44+
were malformed, the rejection would come from the signature check instead, the vector
45+
would pass, and it would have stopped testing the rule in its own filename.
46+
47+
## 2. The set is checked against the verifier, automatically
48+
49+
The interesting question is not whether the vectors pass. It is whether they cover
50+
what the verifier does. Three questions, in increasing strength:
51+
52+
**Are there dead expectations?** A vector naming a failure code that nothing can emit
53+
holds an assertion that can never fail. Usually it means a rule was renamed or removed
54+
and the vector was left behind.
55+
56+
**Is every rule exercised?** For each code the verifier can emit, is there a vector that
57+
expects it? A rule with no vector is a check an implementation can omit while passing.
58+
59+
**Is every rule load-bearing — twice?** The strongest form: *delete the rule and count
60+
the vectors that notice.* A rule can be named by a vector and still not be load-bearing —
61+
if another rule fires on the same input, removing it changes no outcome and nothing
62+
distinguishes an implementation that performs the check from one that skips it. And one
63+
load-bearing vector is existence, not margin: any change that weakens or retires that
64+
single vector silently removes the rule's coverage. So the floor is two
65+
([#124](https://github.com/agentrust-io/trace-spec/issues/124)), and a ratchet keeps
66+
anything above the floor from quietly decaying back toward it.
67+
68+
**Are the two vectors different tests, or two copies?** Two identical vectors have
69+
margin two and prove nothing extra. #124's definition: vectors are independent if a
70+
single implementation defect causes one to pass and the other to fail. That is made
71+
executable by declaring, for every rule, at least one *weakened* variant of its check —
72+
a plausible implementation shortcut: comparing digest prefixes, case-normalising
73+
identifiers, granting clock tolerance, validating signature structure without
74+
cryptography. The suite fails unless some declared defect deviates one of the rule's
75+
vectors while leaving another undisturbed. The declaration is itself fail-closed: a
76+
rule with no declared defect fails, so "what bug would your second vector catch that
77+
your first would not?" is answered when the rule is added.
78+
79+
The strong criteria subsume the first two questions, which are kept because they are
80+
cheap and their failure messages are more direct.
81+
82+
### The inventory is the registry the verifier consumes
83+
84+
The obvious implementation is a list of rules kept next to the tests. That list is
85+
guaranteed to drift: it is correct only until someone adds a rule and forgets it, and
86+
the failure is silent in exactly the direction that matters.
87+
88+
The first replacement recovered the inventory from the verifier's source with `ast` —
89+
every string literal appended to a failure or warning list. The
90+
[#124 review](https://github.com/agentrust-io/trace-spec/issues/124) identified that
91+
this has the mirror failure mode: a rule written as `extend([...])`, `+=`, an f-string
92+
or a named constant is invisible to the walk, and the suite reports complete coverage
93+
over an inventory that is quietly missing entries.
94+
95+
The resolution, from the same review: the verifier itself consumes an explicit registry
96+
of named rules, and the registry *is* the inventory. A check that is not registered
97+
never runs, so it cannot exist outside the inventory; a residual guard fails on any
98+
code that would emit around the registry. Mutation follows the same principle — a rule
99+
is deleted by rebuilding the registry without its entry, or weakened by substituting
100+
its check, never by pattern-matching source text. If no vector's outcome changes under
101+
a deletion, that rule has no vector standing behind it.
102+
103+
## 3. Fixtures and their checker cannot vouch for each other
104+
105+
A green run proves the vectors and the verifier agree. Both are usually written by the
106+
same person in the same sitting, so agreement is close to guaranteed and says little.
107+
The failure it cannot see: a shared helper that canonicalizes or decodes incorrectly,
108+
used both to generate the fixtures and to check them. Everything agrees, and agrees with
109+
nothing else in the world.
110+
111+
So every signature is re-derived through a path that shares no code with either: it
112+
imports nothing from the library, reuses no helper from the vector modules, and
113+
reconstructs each signing input from the JSON directly. Any vector whose signature is
114+
not re-derived by that path is a failure unless it is explicitly declared
115+
signature-free with a reason — the "missing receipt" case is the only one, since the
116+
absence of a receipt is the thing it tests.
117+
118+
## 4. What this found
119+
120+
Seven rules in the receipt verifier had no vector at all:
121+
122+
| Rule | What an implementation could have skipped |
123+
|---|---|
124+
| `action_ref_invalid` | Recomputing the action reference instead of trusting the declared value |
125+
| `call_id_mismatch` | Checking that the receipt is bound to *this* call |
126+
| `session_id_mismatch` | Checking that it is bound to *this* session |
127+
| `evidence_hash_mismatch` | Recomputing the evidence digest, so a swapped evidence body passes |
128+
| `issuer_key_unknown` | Consulting the pinned key set at all |
129+
| `receipt_from_future` | Rejecting a receipt issued after the verification time |
130+
| `decision_invalid` | Refusing to read an unrecognised decision verb as accept or reject |
131+
132+
Each is a check a conforming implementation could have omitted entirely while passing
133+
the published suite. Two are load-bearing for the trust model rather than merely tidy:
134+
without `issuer_key_unknown` a receipt authenticates itself — a signature verifies
135+
against whatever key it names, and only the pinned set says which keys the verifier can
136+
check, so a receipt under an unpinned key is surfaced as unverified rather than valid or
137+
invalid — and without `evidence_hash_mismatch` the signature covers a digest whose
138+
document may have been replaced.
139+
140+
## 5. The checker's own false positives
141+
142+
Three of its first findings were bugs in the checker, not in the suite. They are
143+
recorded because a completeness checker that reports the wrong thing is worse than
144+
none: it converts attention into noise, and the next person stops reading it.
145+
146+
| Symptom | Cause |
147+
|---|---|
148+
| Every rule reported as non-load-bearing | Mutants were executed into a bare namespace, so `@dataclass` raised before a single vector ran. It read as "every rule matters" while testing nothing. |
149+
| A real outcome reported as unreachable | The scanner walked an entire conditional expression and collected the literal being *compared against* as if it were an outcome. |
150+
| A real warning reported as emitted by no rule | It scanned inline `failures=[...]` arguments but not `warnings=[...]`. |
151+
152+
The pattern in all three: the check failed *open* — it reported a problem where there
153+
was none, which is survivable, rather than passing where there was one, which is not.
154+
That direction was not designed in, and a checker of this kind should be built so that
155+
its own breakage is loud. Hence the guard that asserts the inventory was non-empty
156+
before any conclusion is drawn from it.
157+
158+
## 6. What this does not establish
159+
160+
- **Not that the rules are right.** Completeness is a property of the vectors relative
161+
to the verifier. If the verifier implements the wrong rule, a complete set pins the
162+
wrong rule down precisely.
163+
- **Not that the specification is complete.** A rule absent from both the verifier and
164+
the vectors is invisible to this method. Only reading the specification finds it.
165+
- **Not that an implementation is correct.** Passing shows it agrees on these inputs.
166+
Behaviour on inputs no vector describes is unconstrained.
167+
- **Not that the vectors are adversarial enough.** Two independent vectors per rule is
168+
a floor, not a proof of adversarial coverage: independence is demonstrated against
169+
the *declared* defects, and a shortcut nobody thought to declare is a shortcut the
170+
pair may still share. The declaration requirement makes the blind spot enumerable,
171+
not empty.
172+
173+
The method answers one question — *could an implementation skip this check and still
174+
pass?* — and answers it mechanically. That is narrower than "is the suite good", and it
175+
is the part that was previously left to assumption.
176+
177+
## Reproducing
178+
179+
```bash
180+
pip install -e ".[dev]"
181+
pytest tests/test_vector_completeness.py -v # the completeness checks
182+
pytest tests/test_fixture_signatures_independent.py # the independent signature path
183+
```
184+
185+
Signing keys are derived from published seeds, so every vector set regenerates
186+
byte-for-byte and only public JWKs appear in the files. They are deliberately
187+
deterministic test keys with no standing.

‎tests/adequacy.py‎

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
"""Criteria a conformance vector set has to meet to be worth running.
2+
3+
A vector set is a claim about what an implementation must do. The claim is only
4+
as strong as the set's ability to fail an implementation that does not do it, and
5+
a set can pass every one of its own assertions while failing that. These are the
6+
ways it happens that we have actually seen, each with the vector set it was found
7+
on:
8+
9+
1. **No control, in either direction.** If every vector expects rejection, an
10+
implementation that rejects unconditionally satisfies the whole set. If every
11+
vector expects acceptance, one that accepts unconditionally does. Both halves
12+
are needed and the second is the one that gets forgotten: a set written to show
13+
that certain inputs *do* verify reads as complete while pinning nothing, which
14+
is the shape `canonicalization-boundary` has here.
15+
16+
2. **No margin.** One vector per boundary has no margin: a single implementation
17+
shortcut that happens to reject that one vector passes the boundary. Two vectors
18+
introducing distinct failure codes force the boundary itself to be implemented.
19+
Established in agentrust-io/trace-spec#124.
20+
21+
Margin is counted per *boundary*, never per failure code, and the two are not
22+
the same unit. A boundary covered the way #124 asks for produces two vectors
23+
carrying two different codes, so counting by code reports the correct design as
24+
a shortfall. This was written the wrong way round first and the set that caught
25+
it was the one that had done it right. A set therefore has to say what its
26+
boundaries are; where it does not, the code is used and that assumption is the
27+
set's to justify.
28+
29+
3. **A rule that nothing pins.** If deleting a rule from the verifier changes no
30+
expected verdict, the set does not cover it, whatever its name suggests. Only
31+
deletion establishes this; reading does not.
32+
33+
4. **A weakness shared across a boundary's vectors.** Distinct failure codes are
34+
not sufficient. Both dependency vectors of the build-provenance set placed
35+
their defect last in a list, so a verifier that read one entry of three passed
36+
both while still rejecting the absent-list vector, presenting as having
37+
implemented the rule. Every check was present; the shortcut was in how many
38+
entries each one ran over, so no code went missing and no code-level guard
39+
could see it. Found in #169, on a set that already satisfied 1 through 3.
40+
41+
Criteria 1 and 2 are decidable from the fixtures alone and are implemented here.
42+
Criteria 3 and 4 need to run the set's own verifier under mutation, so each set
43+
implements them in its own module; this file states them so that a set which omits
44+
them omits something named rather than something nobody thought of.
45+
"""
46+
from __future__ import annotations
47+
from collections import defaultdict
48+
from collections.abc import Iterable
49+
50+
ACCEPTING = frozenset({"accept", "verified", "pass"})
51+
52+
53+
class Vector:
54+
"""One fixture, reduced to what adequacy is decided on."""
55+
56+
__slots__ = ("name", "outcome", "codes", "boundary")
57+
58+
def __init__(self, name: str, outcome: str, codes: Iterable[str] = ()):
59+
self.name, self.outcome = name, outcome
60+
self.codes = tuple(c for c in codes if c)
61+
self.boundary: str | None = None
62+
63+
@property
64+
def accepts(self) -> bool:
65+
return self.outcome in ACCEPTING
66+
67+
68+
def trivially_satisfied_by(vectors: list[Vector]) -> str | None:
69+
"""The unconditional implementation this set cannot fail, if there is one.
70+
71+
Returns ``"reject"`` when no vector expects acceptance, ``"accept"`` when none
72+
expects rejection, and ``None`` when the set pins both directions.
73+
"""
74+
if not any(v.accepts for v in vectors):
75+
return "reject"
76+
if all(v.accepts for v in vectors):
77+
return "accept"
78+
return None
79+
80+
81+
def boundaries_without_margin(
82+
vectors: list[Vector], boundary_of=None
83+
) -> dict[str, list[str]]:
84+
"""Boundaries covered by exactly one vector, each with that vector named.
85+
86+
*boundary_of* maps a vector to the boundary it separates. It defaults to the
87+
vector's failure codes, which is right only where one code means one rule; a set
88+
whose boundaries are coarser than its codes must supply it, or every correctly
89+
covered boundary reads as a shortfall.
90+
91+
The return is the shortfall itself rather than a boolean, because a set short in
92+
a named place is in a different position from one nobody has measured.
93+
"""
94+
key = boundary_of or (lambda v: v.codes)
95+
by_boundary: dict[str, list[str]] = defaultdict(list)
96+
for v in vectors:
97+
for b in key(v):
98+
by_boundary[b].append(v.name)
99+
return {b: names for b, names in sorted(by_boundary.items()) if len(names) < 2}
100+
101+
102+
def report(label: str, vectors: list[Vector], boundary_of=None) -> str:
103+
thin = boundaries_without_margin(vectors, boundary_of)
104+
lines = [f"{label}: {len(vectors)} vectors, "
105+
f"{sum(v.accepts for v in vectors)} accepting, "
106+
f"{len({c for v in vectors for c in v.codes})} distinct failure codes"]
107+
trivial = trivially_satisfied_by(vectors)
108+
if trivial:
109+
lines.append(f" NO CONTROL: a verifier that answers {trivial!r} to everything "
110+
f"passes this set")
111+
for boundary, names in thin.items():
112+
lines.append(f" NO MARGIN: {boundary} is covered only by {names[0]}")
113+
return "\n".join(lines)

0 commit comments

Comments
 (0)