test(appraisal): candidate vectors for appraisal.policy_ref resolution - #69
Conversation
`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
lywinged
left a comment
There was a problem hiding this comment.
This is well built, and I checked rather than assumed. Flipping one byte in a policies/ file, changing 05's outcome to a status value, and deleting 04 each turn the suite red, on test_the_declared_resolution_matches_the_bytes_on_disk, on test_no_vector_proposes_an_appraisal_status_value, and on test_the_generator_produces_nothing_the_set_does_not_carry respectively. The four policy digests recompute from the directory alone. The set also genuinely meets the criteria it cites: 01 and 02 expecting pass against 03, 04 and 07 expecting reject makes it fail both unconditional implementations, which canonicalization-boundary over in trace-spec does not manage.
What is missing is the one you recorded yourself, no_verifier_exercised, and it is worth saying plainly that on this field it cannot be closed. appraisal.policy_ref is a bare URI. The record states nothing about the referent, so there is nothing to compare a resolved document against, and there will not be until a field lands, which is normative and not yours or mine to decide. Putting candidate_binding in context is the right way to write that down, and it is also the reason no verifier can run these.
One field over, both halves are already merged.
policy is top-level required. Inside it bundle_hash is required, "SHA-256 or SHA-384 digest of the policy bundle in force at execution time", and beside it sits policy_uri, optional, "format": "uri", described in the schema as "URI to the policy bundle for verification". Where both are present the record carries a referent and a digest of that referent, both fields already merged, with no candidate field needed and nothing for the schema editorial process to decide. Nothing resolves the pair. In this repository policy_uri is zero occurrences under src/, and bundle_hash appears only in tr_pol.py, where TR-POL-001 regex-matches the digest's shape and reports policy.bundle_hash has valid digest format. In trace-spec, setting aside the packaged schema copies, both appear only in the adapters that write them and in the dataclass fields that declare them. Nobody fetches the bundle and recomputes anything.
So what is left, concretely:
- A resolver, caller-supplied, defaulting to absent so the check skips offline, and skipping equally where a record carries no
policy_uri, since that field is optional.verify_recordin trace-spec already takesrevocation: RevocationStore | None, aContainer[str] | Callable[[str], bool]. ACallable[[str], bytes]is the same shape, and yourpolicies/directory is already the fixture side of it. TR-POL-003.tr_pol.pystops at 002 today, andcheck()takes only the record, so where a resolver reaches a module is the one real design question in this.- The registration this repo gives a new code, which as far as I can see no test enforces, so it is easy to miss:
docs/error-codes.md,docs/modules/tr-pol.md,docs/modules.md, and the per-level failure lists indocs/levels.md. - Your seven boundaries, unchanged: 02 resolves and matches, 03 minimal mutation, 04 different object, 05 unreachable, 06 uncomputable algorithm, 07 bound to another referent.
On 05 and 06, the distinction you are deferring already exists here, one layer down. Status.UNVERIFIED is in result.py, held apart from SKIP so that, in its own comment, callers can never mistake an unverified record for a benign omission, and TR-SIG-005 emits it today. Whether TR-POL-003 can reuse it is a real question rather than a given: that same comment scopes it to cryptographic verification and makes it a failure at any level that requires signatures, and docs/levels.md runs TR-POL at every level, so an unreachable policy bundle inheriting it would fail a signed record for a reason that has nothing to do with its signature. Either way this does not wait on agentrust-io/trace-spec#190, because a finding status is not appraisal.status, and that issue is about the latter.
|
The set itself is built the way I want vectors built here. Digests recomputable from the directory alone, a generator held to byte-reproduction by an in-repo test rather than one that needs another repository checked out, the must-accept pair so the set fails an unconditionally-rejecting verifier as well as an unconditionally-accepting one, and both shortfalls pinned in KNOWN_SHORTFALLS instead of left to be noticed later. Keeping I am holding the merge, and it is because of where the vectors point rather than how they are made. @lywinged is right, and I checked the claims rather than taking them. One field over it is already closeable with nothing merged that is not merged already. So what I would like, and it is your seven boundaries essentially unchanged:
The one real design question is the seam, and it is worth deciding before the code rather than after: On 05 and 06: @lywinged is also right that this does not have to wait on trace-spec#190. That issue is about None of this is wasted work. The boundaries, the generator, the mutation discipline and the digest recomputation all carry over. What changes is the field they are aimed at, from one that cannot be verified until a spec decision lands to one that can be verified today. Thank you both, and @lywinged specifically for verifying the set by mutation before arguing with it. |
|
We're in agreement, and thank you both for verifying before responding — @lywinged's mutation checks and the schema walk are exactly the review I was hoping for with this set. The layering note on agentrust-io/trace-spec#190 settles what waits where: everything below is finding-status territory, one layer down, so none of it waits on that issue. Both reserved questions follow; decisions yours. The seam.Proposing the smallest one that matches the precedent you both cited. The runner's entry point gains One thing turned up while grounding this against the schema.
The status question — both options, one recommendation.Unreachable-with-a-resolver is "check attempted, could not complete, not a defect of the record," which is Option A, recommended: generalize the contract. The comment widens from cryptographic verification to "the check could not be executed against evidence the record cites," and level-failure becomes per-code rather than blanket. Concretely that is a two-site code change, not a docs edit: the blanket rule is real code, in Option B: keep UNVERIFIED scoped to cryptographic verification and add a sibling member for evidence-execution failure, per-code registered the same way. The cost is a second held-apart-from-SKIP value in shared I would take A. @imran-siddique — your call on both, as offered. A vector boundary migrated into the schema, and that vector should transform. The old 06 — a declared digest algorithm the verifier cannot compute — cannot exist in a schema-valid record now that the digest under test is Registration. TR-POL-003 lands with all four rows: Sequence, unless you'd rather otherwise: settle the seam and the status question here, then the retargeted set and TR-POL-003 push to this branch and the PR description updates to match. Let me know! 🦏 |
Candidate conformance vectors for
appraisal.policy_refresolution — answering the acceptance on agentrust-io/trace-spec#66 (2026-08-18). The boundary originates from #63:policy_refcan be format-checked but not reproduced, because the record carries a digest for the enforcement policy (policy.bundle_hash) and none for the appraisal policy that produced the verdict.Seven vectors under
tests/vectors/appraisal-resolution/, one defect each, every record identical exceptappraisal.policy_ref:Standalone by design. No module, no schema change, no dependency, no edits to existing files (+1611/−0). The set runs green on main with no verifier present, because none exists for this field — which is the gap. The tests grade the set's internal consistency: every digest is SHA-256 over the exact bytes of sibling files under
policies/, recomputable from the directory alone, and a deterministic generator is held to byte-reproduction by a self-contained in-repo test (per the principle merged in #66: a guard that needs another repository checked out is a guard that gets skipped).What it deliberately does not decide.
candidate_bindingis markedCANDIDATE:in every vector and lives in the vector'scontext, never inrecord—appraisalisadditionalProperties: false, and proposing a field is an editorial decision. Vectors 05 and 06 assert onlymust_not: "affirming", deferring the unresolvable-case outcome to agentrust-io/trace-spec#190 rather than coining one;deferredis fixture bookkeeping, not a candidateappraisal.statusvalue, and a completeness test fails if any vector reuses a status value as an outcome. Both shortfalls — no verifier exercised, unresolvable outcome unnamed — are pinned inKNOWN_SHORTFALLSso they cannot drift silently.Built to the adequacy criteria in agentrust-io/trace-spec#186: the must-accept pair (01, 02) makes the set fail an unconditionally-rejecting verifier, and each boundary carries two independent vectors split by a named defect.
Two reviewable choices, flagged rather than buried: the scoped
.gitattributespinningeol=lffor this directory is load-bearing (withcore.autocrlf=true, checkout rewrites LF→CRLF and every digest in the set breaks on a clean clone) — but whether it belongs here, at the root, or elsewhere is a repository-convention call. And the candidate binding's field name is a placeholder shape for review, not a proposal.Verification on main at c725bbb: 246 passed, 5 xpassed (+45 over base, nothing displaced); 88 in the
level0 or negativegate with all 45 new tests collecting; ruff and mypy clean on the new files.Refs: #63, agentrust-io/trace-spec#66, agentrust-io/trace-spec#190