ADR-0015 — Evidence format 1.0 is frozen against a committed fixture, not a promise¶
Status: accepted · week 12 (2026-10-10) Implements: SPEC §22 week 12 (freeze evidence format 1.0, publish it as a spec), SPEC §25.6 (versioning and compatibility promises) Relates to: ADR-0005 (record encoding), ADR-0006 (format constants carry no product name), ADR-0010 (rotation destroys the old private key)
Context¶
The product's central claim is that an auditor can verify evidence years later without trusting the vendor or the customer. That claim has a precondition nobody checks on day one: the records written today must still be readable by whatever the verifier becomes.
SPEC §25.6 already said how that would be kept — "any change to hashing or
record fields bumps schema_version with an ADR and keeps qed-verify able to
verify all prior versions (cross-version fixtures in CI)". Through week 11
there were no cross-version fixtures. The suite had something that looked like
one: tests/evidence/test_chain.py writes a chain with today's producer and
verifies it with today's verifier, end to end, through the real store and the
real signer. It is a good test and it cannot see the failure that matters,
because a producer and a verifier that change together pass it, while the
records already sitting in a customer's database do not change with them.
That is the same shape as the three findings in docs/validation.md that a
green suite shipped anyway: a test that imports the source tree is not a test of
the thing you ship, and a test that regenerates its own input is not a test of
compatibility.
Decision 1 — The freeze is a committed artefact, produced once¶
tests/evidence/fixtures/evidence-format-1.0/ holds a real export, produced by
the kernel on 10 October 2026: fourteen records over five simulated days, with
genesis, policy_loaded, three real proposals from examples/proposals/
deciding ALLOW / REQUIRE_APPROVAL / BLOCK, an approval decided by a different
human through the real separation-of-duties check, a signing-key rotation, two
Merkle checkpoints, a retention purge that recorded itself, and one proposal
whose payload is gone. Both public keys are committed. No private key is, so the
chain cannot be extended — which is the correct state for a published reference.
tests/evidence/test_frozen_format.py checks four things against those bytes,
each catching something the one before it cannot:
qed-verifyverifies the export — the auditor's whole position, in a line.record_hash, the producer's function as it is today, recomputes the hash of every frozen record. (1) proves the verifier can still read 1.0; this proves the producer still writes it.qed.core.jcsandqed_verify.jcs— two deliberately separate canonicalisers — agree on every frozen payload.- Twelve mutations of the frozen bytes each produce the documented failure code, which makes the fixture's README a tested document.
Decision 2 — The fixture's digest is pinned in the test, not in the fixture¶
A regeneration would quietly turn every check above into "today's code agrees
with today's code". So DIGESTS in the test file holds a sha256 over the
fixture's records, keys and manifest, and a regeneration fails there.
The digest lives in the test and not in the fixture on purpose: a checksum that the regeneration rewrites alongside the thing it describes verifies nothing. Refreshing the fixture is therefore a deliberate edit in two files, with a test whose failure message says why it is almost certainly wrong.
Markdown inside the fixture directory is excluded from the digest. The pin exists to stop the records being refreshed, and a guard that also fires on every improvement to the prose beside them is a guard people learn to update without reading.
The generator is committed (fixtures/generate_1_0.py) so the records are
explicable rather than taken on trust, and it refuses to run without
--i-mean-it.
Decision 3 — A schema_version bump fails CI until it has a fixture¶
This is the part that makes the freeze a rule rather than a habit.
schema_version is a Literal["1.0"] in three places in
qed/core/schemas.py. Changing it is one edit, and nothing in the repository
would have noticed. Now
test_every_schema_version_the_code_can_write_has_a_fixture reads those
literals and asserts each has a fixture directory, so "1.1" is red until 1.1 has
records of its own — and 1.0 goes on being verified beside it, because the test
is parametrised over every directory it finds.
Decision 4 — Freeze the reading of a record, not its contents¶
Freezing too much makes a format unimprovable, so the boundary is written into
docs/evidence-format.md as two tables.
Inside: the six hash inputs and their order, the 0x1F separator, the two
domain prefixes, the field names, base64url-without-padding, lowercase hex,
RFC 3339 with three fractional digits, RFC 8785 with integers limited to
±(2⁵³−1), the reserved _-prefixed keys, the Merkle construction, and the rule
that key_rotated is signed by the old key.
Outside: payload contents (a verifier checks the digest, not the fields), the
baseline rule pack, policy_hash, engine_version, the CSV and OSCAL
renderings — and the masking pattern set.
Masking is the one that changed meaning here, and the previous version of
docs/evidence-format.md had it the other way round: it said "the pattern set
is part of the format: changing it changes record hashes for future records".
Both halves are true and the conclusion was wrong. Masking runs before hashing,
so a new pattern changes what future records contain; it cannot change a
record already written, and a verifier never re-derives a mask — it hashes the
payload as it finds it. So the pattern set is producer-side policy, and
improving it is not a format change. Getting that wrong in the other direction
would have frozen the secret-detection patterns for the life of the product.
Decision 5 — Freeze now, before the auditor's written acceptance¶
Week 9's exit criterion — written auditor acceptance — is not in hand, and freezing a format an auditor has not signed off on is a real risk taken knowingly.
Two reasons it is the right way round. An auditor's objections land on what the
evidence says — control mappings, which fields a verdict carries, how an
OSCAL observation is shaped — and all of that is outside the freeze and
additive. The hash construction, the signature and the chain linkage are
cryptographic mechanics no auditor asks to change. And the alternative is worse:
design partners are being asked to run this in enforce mode now, so the records
accumulating in their chains are already permanent whether or not a document
calls the format frozen. Freezing is the honest description of a commitment
already made in fact.
If an auditor does require a change to the hashed surface, the answer is 1.1 with an ADR and a second fixture — exactly the mechanism Decision 3 installs — and 1.0 keeps verifying. That is the version of "we were wrong" this format is built to survive.
Consequences¶
- A format change can no longer be made accidentally, only deliberately and visibly, in at least three files.
docs/evidence-format.mdis normative and carries a conformance section: ten MUSTs, three MUST NOTs, and an acceptance test an independent implementer can run against committed bytes. The one most often got wrong is called out — an unsuppliedkey_idis a failure, not a skip, or a verifier reports a forged chain as clean.- The conformance kit is in
tests/, so it is in the repository and not in the wheel. A third party writing a verifier clones the repo; a kit shipped inside the package the kit is meant to be independent of would be the wrong place for it. - Nine of eighteen record types appear in the fixture. The others are
hash-identical in structure —
typeis one string in the hash input — and the fixture's README says which are covered rather than implying all are.