Evidence format 1.0¶
Status: Frozen, 10 October 2026 (SPEC §22 week 12, ADR-0015).
schema_version: "1.0". This document is the specification of what a conforming
verifier checks. It is normative: where this page and any implementation
disagree, this page and the frozen fixture in
tests/evidence/fixtures/evidence-format-1.0/ are right and the implementation
has a bug.
What freezing commits to¶
Three promises, in the order they matter to somebody deciding whether to put their audit trail in this format.
A record written under 1.0 verifies under every later verifier, forever. Not
"for the 1.x line" and not "while we support it". The frozen fixture is a real
export produced on 10 October 2026 — fourteen records including a rotation, two
checkpoints and a purged payload — committed to the repository and verified by
CI on every change. It is never regenerated: the test that loads it pins its
digest, so refreshing it is a visible edit in two places rather than a quiet
one. The day the producer and the verifier change together in a way that stops
reading those bytes, CI fails. See tests/evidence/test_frozen_format.py.
Any change to the hash input or the record fields is a new
schema_version, with an ADR. Not a patch release, not a "clarification". A
new version needs its own frozen fixture before CI will go green, which is
enforced rather than remembered: schema_version is a Literal in
qed/core/schemas.py, and bumping it fails
test_every_schema_version_the_code_can_write_has_a_fixture until the records
exist.
qed-verify keeps every version it has ever been able to read. It is a
separate package whose only dependency is cryptography, and it reimplements
canonicalisation and the record hash from this page rather than importing them
from the producer — a shared helper would mean a bug in the producer verifies
cleanly.
An auditor needs three things and nothing else: the records, the public keys,
and qed-verify. No access to the instance, no cooperation from the operator,
no trust in either.
What is not frozen¶
Freezing the wrong amount is how a format becomes unimprovable, so the boundary is written down.
| Free to change | Why it is outside the freeze |
|---|---|
| The contents of a payload | payload_hash covers whatever bytes were there. A verifier checks that the payload hashes to the recorded digest; it has no opinion about the fields inside it. New fields in a verdict payload are additive and break nothing. |
| The masking pattern set | 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 the mask. |
The baseline rule pack, control catalogues, policy_hash |
Policy is data the chain records, not part of how it is read. |
engine_version in a verdict |
Deliberately in the record so an auditor can ask which build decided; deliberately not in the hash input. |
| Export containers — CSV, OSCAL | Renderings of the same records. JSONL is the one a verifier reads. |
| What a verifier must report | The freeze is a promise about bytes: a record written under 1.0 verifies under every later verifier. It is not a ceiling on what a verifier has to tell its user. Requirement 11 in the conformance list below was added on 11 October 2026, after the freeze, because the reference verifier was printing an unqualified pass over a check it had skipped. No record changed and no previously valid chain became invalid. |
| Frozen | Why it is inside |
|---|---|
| The six hash inputs and their order | Changing any of them changes every hash. |
0x1F as the separator, and the two domain prefixes |
A chain is bound to them. |
| The field names and types in the table below | A verifier reads them by name. |
Base64url-without-padding for sig, lowercase hex for digests |
Spelling is part of the bytes. |
RFC 3339 UTC with exactly three fractional digits and Z |
The timestamp is hashed as its string. |
| RFC 8785 canonicalisation, with integers limited to ±(2⁵³−1) | payload_hash depends on it. |
The reserved _-prefixed payload keys |
SPEC §10.2; a caller payload using one is rejected. |
| The Merkle construction, duplicate-last-node included | A root is not recomputable without it. |
key_rotated is signed by the old key |
The one rule that makes rotation one chain. |
A record¶
{
"schema_version": "1.0",
"seq": 3,
"ts": "2026-10-07T09:14:22.431Z",
"type": "verdict",
"assurance": "kernel",
"mode": "shadow",
"enforcement": "enforcing",
"dev": false,
"payload_hash": "9f2c…",
"payload": { "...": "...", "_assurance": "kernel", "_key_id": "ed25519:7f05…" },
"prev_hash": "41ab…",
"hash": "c7d0…",
"key_id": "ed25519:7f05ae6076e7f9cd",
"sig": "MEUCIQ…"
}
| Field | Rule |
|---|---|
seq |
starts at 0, strictly +1, no gaps, no reuse |
ts |
RFC 3339 UTC, exactly three fractional digits, always Z |
type |
one of the closed set in SPEC §3.5 |
assurance |
kernel / broker / adapter / self — who vouches (ADR-007) |
payload |
redacted; may become null after retention |
payload_hash |
sha256(JCS(payload)), never purged |
key_id |
ed25519: + first 16 hex of sha256(raw public key) |
sig |
base64url without padding, over the 32 raw bytes of hash |
Canonicalisation¶
RFC 8785 (JCS). Object keys sort by UTF-16 code unit. Numbers follow ECMAScript
Number::toString. Integers outside ±(2⁵³−1) are rejected rather than rounded:
a number that cannot round-trip has no business in a hash.
The hash¶
H = SHA256( schema_version ‖ 0x1F ‖ seq ‖ 0x1F ‖ ts ‖ 0x1F ‖ type ‖ 0x1F ‖ payload_hash ‖ 0x1F ‖ prev_hash )
hash = hex(H)
sig = base64url_nopad( Ed25519_sign(H) )
0x1F joining is unambiguous only because no field can contain 0x1F:
schema_version is a literal, type is a closed enum, seq is an integer, the
two hashes are hex, and ts is machine-formatted.
tests/evidence/test_hashing.py pins that.
The hash covers payload_hash, not the payload. That is what lets a purged
record verify forever.
Genesis: seq = 0,
prev_hash = sha256("evidence-chain/1:genesis:" + instance_id). The chain is
therefore bound to one instance identity, and two instances cannot splice their
chains together.
Principals: principal_hash = sha256("evidence-chain/1:principal:" + issuer +
":" + subject).
Those two prefixes, and the chain_format value evidence-chain/1 in the
genesis payload, are the whole of the format's fixed vocabulary. They carry no
product name on purpose — a brand inside a hash input would make renaming the
product break every existing chain, and the product was renamed, from gate
to QED, on 8 October 2026 without touching a single record. See ADR-0006;
tests/evidence/test_format_constants.py pins the bytes.
Reserved payload keys¶
SPEC §10.2 requires assurance, mode, enforcement, dev and key_id to be
inside the payload so payload_hash covers them, and mirrored as columns for
querying. They are written as _assurance, _mode, _enforcement, _dev,
_key_id. A caller payload using one of those names is rejected, not
overwritten — see docs/adr/0005-record-encoding.md.
Masking¶
Masking runs before hashing (qed/core/evidence/masking.py). A secret
therefore never reaches payload_hash in the clear and no later purge is needed
to remove it.
The pattern set is outside the freeze, and the reason is worth being precise about, because the previous version of this page said the opposite. Masking changes what a record contains before it is hashed, so adding a pattern changes future records and cannot change one already written. A verifier never re-derives the mask — it hashes the payload as it finds it. So the pattern set is a producer-side policy, and improving it is not a format change.
Checkpoints¶
Every 1,000 records or 3,600 seconds, whichever comes first. The payload carries
from_seq, to_seq, merkle_root and records. The tree is binary over the
record hash values as raw bytes; when a level is odd the last node is
duplicated.
A checkpoint cannot cover itself — its hash depends on the root it carries — so each range begins at the previous checkpoint's own record. Every record in the chain, checkpoints included, ends up under exactly one root.
Publication receipts are not written back into the record they describe (that
would be a hash cycle). They land in the checkpoints table and appear in the
next checkpoint's payload.
Key rotation¶
key_rotated carries old_key_id, new_key_id and new_public_pem, and is
signed by the old key. That single rule is what makes rotation one
verifiable chain instead of two unrelated ones. qed-verify reports
rotation_discontinuity if a rotation record is signed by the new key.
Retention and personal data¶
payload may be removed per type after its retention period;
payload_hash, hash, prev_hash and sig never are. Principals appear only
as salted hashes; the hash-to-identity mapping lives in the erasable
principals table, outside the chain (ADR-005).
Some payloads are structural and may never be purged, whatever the policy says:
a checkpoint's root, a key_rotated's public key, genesis, policy_loaded
and the admin_action records of the purges themselves. That is a correctness
constraint, not a preference — see qed/core/evidence/retention.py.
What qed-verify reports¶
| Code | Meaning |
|---|---|
not_from_genesis |
the export does not start at seq 0 |
genesis_mismatch |
record 0 does not belong to the claimed instance |
sequence_gap |
a record is missing or reordered |
broken_link |
prev_hash does not match the previous hash |
hash_mismatch |
hash does not match the record's own fields |
payload_tampered |
payload_hash does not match the payload |
unknown_key |
no public key was supplied for this key_id |
bad_signature |
the signature does not verify |
rotation_discontinuity |
a rotation record was signed by the wrong key |
checkpoint_mismatch |
the Merkle root does not match the range |
checkpoint_range_missing |
the range is not fully present in the export |
empty |
nothing to verify — reported as a failure, not a pass |
Conformance¶
An independent implementation may call itself a conforming 1.0 verifier when it does all of the following. The list is deliberately short; everything in it is checkable against the fixture.
It MUST, given a JSONL export and a set of Ed25519 public keys:
- Reject an export that does not begin at
seq0, and one with a gap or a repeat inseq. - Check
prev_hashof record n againsthashof record n−1, and, when the instance id is supplied, record 0'sprev_hashagainstsha256("evidence-chain/1:genesis:" + instance_id). When it is not supplied, see 11: the check is skippable, and a skipped check may not be reported as a performed one. - Recompute
hashfrom the six inputs above, usingpayload_hashand never the payload, so a purged record still verifies. - Recompute
payload_hashby RFC 8785 canonicalisation whenever the payload is present, and skip that check — not fail it — when the payload isnull. - Verify
sigas Ed25519 over the 32 raw bytes ofhash, under the key named bykey_id, decoding base64url without padding. - Report an unsupplied
key_idas a failure. Not a skip. A record nobody can vouch for is the one an attacker would add. - Verify each
key_rotatedrecord under the old key, and treat one signed by the new key as a failure. - Recompute each
checkpoint's Merkle root over the range it claims, duplicating the last node on an odd level, and fail when the range is not fully present. - Report an empty input as a failure rather than a pass.
- Reach a verdict without contacting the instance that produced the records, or any other network service.
- State, in both its human and machine-readable output, whether the genesis binding in 2 was performed — and when it was not, that no instance is bound. This is the one check in the list that can be skipped, and it is the only one that distinguishes this chain from a chain generated anywhere else: 1 and 3 through 9 are satisfied just as well by records fabricated five minutes ago and signed with a key made at the same time. A verifier that prints an unqualified pass without it is overstating its own result, which is the failure mode this format exists to remove.
It MUST NOT:
- treat an unknown
typeas a failure. The type set is closed for a producer and open for a reader: a verifier that refuses an unfamiliar type cannot read a chain written by a newer producer, and the type is only a hash input. - accept a record whose
payload_hashis absent, even when the payload is. - round a number it cannot represent exactly. Reject it.
- take the instance id, or a public key, out of the material under review. Both are present in it — the id in the genesis payload, the key id on every record — and both were chosen by whoever wrote the file. Reading either out of the export and checking the export against it is a tautology, and a verifier that offers it as a convenience is building the tautology for its user. They arrive out of band or the binding does not happen.
The acceptance test is the fixture. A conforming implementation verifies
tests/evidence/fixtures/evidence-format-1.0/chain.jsonl against the public
keys beside it with the instance id in its manifest.json, and reports
fourteen records, two checkpoints, one purged payload and two keys. The
fixture's README.md lists the mutations it should reject and the code each
one should produce.
$ cd tests/evidence/fixtures/evidence-format-1.0
$ qed-verify chain.jsonl --keys public --instance-id qed-conformance-1.0
records : 14 (seq 0..13)
keys : ed25519:464e88767c380dcd, ed25519:e4b402f9126abfa9
checkpoint: 2
purged : 1 payload(s) removed under retention
result : OK -- chain is contiguous, unmodified and signed