Litatoli evidence format — specification (v1)¶
This specification is licensed under Apache License 2.0, separately and deliberately, while the reference implementation is under the Business Source License (see
../LICENSE).The reason is the point of the document. An auditor must be able to verify evidence without depending on us, and an independent implementation must be writable by anyone — including a competitor, including a certification body. Restricting the format would make the evidence worth less, not more.
The restriction on the implementation is narrow and elsewhere: you may not run our code as a hosted evidence service for third parties. You may implement this specification and do exactly that.
This document specifies the on-disk evidence format precisely enough to build
an independent verifier with no litatoli code. A reference second
implementation (Python, blake3 + cryptography only) lives at
pimatika/tools/verify_bundle.py and reproduces every check below; it was
validated byte-for-byte against real logs. If the CLI and an independent
implementation disagree, the bug is in one of them — this spec is the
contract.
Everything is UTF-8 text: JSON Lines for the log, JSON for the registry. Primitives: BLAKE3 (keyed MAC + unkeyed digests) and Ed25519 (signatures, RFC 8032). No ASN.1, no certificates, no server.
1. The evidence log — append-only JSON Lines¶
One JSON object per line. Fields of an entry:
| Field | Type | Notes |
|---|---|---|
seq |
u64 | strict 1-based, +1 per entry |
prev_hash |
string? | absent on the first entry; else = previous entry's blake3_signature |
timestamp |
string | YYYY-MM-DDTHH:MM:SSZ (UTC, exactly 20 chars — lexicographic order = chronological order) |
evidence_type |
string | e.g. command_run, ai_inference, checkpoint |
payload |
object | type-specific content |
blake3_signature |
string (hex) | keyed BLAKE3 MAC over the canonical signing payload (§2) |
key_id |
string | first 16 hex chars of BLAKE3(MAC key) |
algorithm |
string | "blake3-keyed" |
ed25519_signature |
string (hex)? | Ed25519 over the same canonical signing payload |
ed25519_pubkey |
string (hex, 64)? | the signer's public key, self-contained |
run_id |
string? | correlation id, present iff payload.run_id is a non-empty string, lifted to the top level at append so a whole run is queryable without searching the payload |
Chain rules (verifiable without any key):
- seq at position i (0-based) must equal i + 1;
- first entry has no prev_hash; every later entry's prev_hash equals the
previous entry's blake3_signature field (string equality). This catches
reordering and middle deletion.
Tail truncation is not detectable from the file. Dropping the last N
entries leaves a prefix in which seq still runs 1..n, every prev_hash still
matches, and every signature is still genuine — because nothing was forged.
Checkpoints (§4) do not close this on their own: a cut deep enough to matter
removes them too, and what remains is a shorter but perfectly valid log.
A verifier can only exclude truncation by comparing against something recorded outside the file. Two anchors, and an implementation should support at least one:
- A pinned head — the last entry's
blake3_signature, recorded when the log was closed and supplied to the verifier out of band. Any cut changes it. - A freshness witness (§7) — an external registry that only ever moves a workload's head forward, so a truncated log presents a head behind what the witness already saw.
A verifier MUST report which of these it applied. Reporting only pass/fail lets a reader conclude "nothing was removed" from a check that never looked.
2. Canonical signing payload (what both signatures sign)¶
The MAC and the Ed25519 signature both sign the UTF-8 bytes of one compact JSON object whose field order is fixed (it is a struct, not a map):
{"seq":<u64>,"prev_hash":<null|string>,"timestamp":<string>,
"evidence_type":<string>,"payload":<value>,"key_id":<string>,
"algorithm":"blake3-keyed","ed25519_alg":"ed25519","ed25519_pubkey":<string>,
"run_id":<string>}
prev_hashis always present here (JSONnullon the first entry) even though it is omitted from the stored entry.- The two
ed25519_*fields are present iff the entry carries an Ed25519 signature — so the MAC also authenticates the key binding (strippinged25519_pubkeyfrom a stored entry breaks verification). run_idcomes last and is present iff the entry carries one. An entry without it signs to exactly the same bytes as one written before the field existed, so older chains stay verifiable unchanged. When present it is authenticated like every other field: the verifier reads the entry's stored top-levelrun_id, so stripping or editing it breaks verification — it is not free-floating metadata.key_idandalgorithmare covered by both signatures — the signing payload is the whole entry minus the two signature fields. Flipping either one's value or renaming the field breaks verification; onlyblake3_signatureanded25519_signatureare self-excluded.payloadis serialized with recursively sorted object keys and compact separators (,and:, no spaces), UTF-8 (not ASCII-escaped).- Numbers are rendered exactly as Rust
serde_json(ryu): integers verbatim; a float uses its shortest round-tripping decimal, in fixed notation when its scientific exponent E is in[-5, 15]and scientific otherwise (d.dddde±NN, sign always present, exponent not zero-padded, e.g.1e-7,0.00005,1e+20). A naivejson.dumps/reprdoes NOT match (1e-07,5e-05); an independent verifier must reproduce this rule (the Python reference does, validated on 200 000+ doubles)..inf/.nancannot appear (not valid JSON).
Third-party verification checks the Ed25519 signature over these bytes with
the entry's ed25519_pubkey, then authorizes that key via the registry (§3).
The keyed MAC is not third-party-verifiable by design (it needs the
operator's secret); it serves the writer's own tamper detection and the chain
linkage.
3. Signed key registry (trust anchor + rotation)¶
registry.json:
{ "payload": { "registry_version": "1",
"root_pubkey": "<hex64>",
"issued_at": "YYYY-MM-DDTHH:MM:SSZ",
"keys": [ { "pubkey": "<hex64>", "role": "evidence" | "bim",
"not_before": "…Z", "not_after": "…Z"?, "revoked_at": "…Z"? } ] },
"signature": "<hex — Ed25519 by root_pubkey>" }
- Canonical bytes = the
payloadobject as compact JSON with recursively sorted keys;signatureis Ed25519 over those bytes byroot_pubkey. - The auditor pins
root_pubkeyout of band — a registry vouching for its own root proves nothing. - A key authorizes an entry at time t (its
timestamp) iffnot_before ≤ t,t ≤ not_after(when set), andt < revoked_at(when set). All comparisons are plain string comparisons on the fixed 20-char form.
4. Merkle checkpoints (evidence_type: "checkpoint")¶
Payload: { "merkle_root": "blake3:<hex>", "through_seq": N, "entry_count": N }.
The root commits to all prior entries' blake3_signature strings (their
ASCII bytes), in seq order, with domain separation:
- leaf:
BLAKE3(0x00 ‖ leaf_bytes) - node:
BLAKE3(0x01 ‖ left ‖ right) - odd level: duplicate the last node;
- zero leaves: root =
BLAKE3(0x00).
through_seq and entry_count must both equal the number of covered entries
(everything before the checkpoint). A checkpoint can then be RFC 3161
timestamped (stamp, sidecar timestamps/<seq>.tsr, imprint =
SHA-256(canonical signing payload)), which prevents a later key compromise
from silently re-signing history.
5. Interop projections¶
export-attestations maps this format onto in-toto v1 Statements and
signed DSSE envelopes (see the CLI reference in the README). That is a
projection for Sigstore-family tooling: DSSE verifiers check the per-statement
signature; chain continuity (§1), registry windows (§3) and checkpoints (§4)
remain this spec's semantics and require a verifier that implements them.
6. Known equivalences an implementer must honour¶
- JSON: compact separators, UTF-8 output, object keys sorted recursively (the writer's maps are sorted; the signing struct of §2 is fixed-order).
- YAML (BIM side, see
pimatika/docs/BUNDLE_FORMAT.md): YAML 1.2 semantics — timestamps stay strings (a YAML 1.1 loader that produces datetime objects will diverge). - Strings are NFC-normalized only where the BIM canonicalization says so; log entries are used as-is.
- Hex is lowercase; pubkey comparisons are case-insensitive, trimmed.
7. Freshness witness — the external anchor¶
A witness is an append-only, monotonic registry of heads, keyed by workload. It answers the one question a self-contained file cannot: is this the whole log, or a shortened version of it?
It is also what gives an auditor a signature from something other than the tool under review: the witness signs with its own key, which the producer does not hold.
7.1 Head — what a producer submits¶
Six fields, fixed set:
| Field | Type | Notes |
|---|---|---|
workload_id |
string | What the chain belongs to. Monotonicity is enforced per workload. |
seq |
u64 | seq of the terminal checkpoint entry. |
merkle_root |
string | The checkpoint's Merkle root (§4). |
head_hash |
string (hex) | The checkpoint entry's own blake3_signature. |
evidence_pubkey |
string (hex, 64) | Public key of the evidence signer. |
timestamp |
string | The checkpoint's timestamp, verbatim. |
7.2 Canonical bytes a witness signs¶
Unlike §2, this is a sorted-key compact JSON object — not a fixed-order struct:
Compact separators (, and :, no spaces), keys sorted lexicographically,
UTF-8. In Python this is exactly
json.dumps(head, sort_keys=True, separators=(",", ":")).encode().
An implementation that re-renders these values rather than reproducing them
byte-for-byte will produce a different signature. In particular timestamp is
a string and must not be parsed into a date and re-formatted.
7.3 WitnessedHead — what a witness returns¶
| Field | Type | Notes |
|---|---|---|
head |
object | The Head of §7.1, verbatim as submitted. |
witness_pubkey |
string (hex, 64) | The witness's own key. Pin this. |
witness_signature |
string (hex, 128) | Ed25519 over §7.2 by witness_pubkey. |
observed_at |
string | YYYY-MM-DDTHH:MM:SSZ — when the witness signed. |
7.4 The monotonicity rule¶
For a given workload_id, a witness MUST refuse any submission whose seq is
at or below the highest it has already recorded. That single rule is the
entire mechanism: a rolled-back or truncated log necessarily presents a lower
seq, and the refusal is what makes the cut visible.
A witness MUST NOT overwrite a recorded head, and SHOULD retain every submission rather than only the newest — "what did you see, and when" is the question an auditor brings, and the latest record alone cannot answer it.
Refusal is a distinct outcome from failure. An implementation must let a caller tell apart: the submission lost against a record (refused), this workload has never been seen (no record), and the witness is unavailable (failure). An auditor draws opposite conclusions from the second and the third.
7.5 HTTP protocol¶
Two endpoints, relative to a base URL:
POST {base}/submit body: Head (§7.1)
→ 2xx recorded
→ 409 refused (non-monotonic)
→ 5xx witness unavailable
GET {base}/latest?workload_id=<urlencoded>
→ 200 WitnessedHead (§7.3), bare — no envelope
→ 404 no record for this workload
→ 5xx witness unavailable
GET /latest returns the WitnessedHead unwrapped. A response nested
inside a status envelope is not this protocol.
7.6 Comparing a log against a witness¶
Given a log's terminal head and a WitnessedHead for the same workload:
| Condition | Verdict |
|---|---|
witnessed seq > log seq |
rollback — the log is behind what the witness saw. Reject. |
witnessed seq = log seq, head_hash differs |
fork — two different chains claim the same position. Reject. |
witnessed seq = log seq, head_hash matches |
ok |
witnessed seq < log seq |
ok, log ahead — not a rollback; the witness may simply be lagging. |
| no record | unknown — freshness was not established. Not a pass. |
A verifier MUST check witness_signature against a pinned witness_pubkey
before trusting any of this. A witness vouching for its own key proves nothing,
exactly as an unpinned log signed end to end by one key proves nothing.
7.7 A local file registry is not a countersignature¶
The file: backend signs at read time, with whatever key the reader holds.
Its witness_signature therefore attests that this reader saw this file — not
that some independent party recorded the head when it was submitted. Pinning
witness_pubkey against it is meaningless: it is the reader's own key.
A file registry is still useful for rollback detection on one host, because the monotonicity rule (§7.4) is enforced at submission and the file is append-only. What it does not provide is independence, which is the whole point of a countersignature for an auditor.
An independent witness therefore requires a remote registry, signing at
submission with a key the producer does not hold. An implementation SHOULD
refuse --witness-pubkey against a file: backend rather than appear to
enforce a pin it cannot.
8. What a verification result must state¶
Two independent axes. A result that collapses them is misleading even when every check passed:
| Axis | Established by | If absent |
|---|---|---|
| Who signed | A pinned ed25519_pubkey, or a registry (§3) rooted in a pinned trust anchor |
The log is only self-consistent — which a log forged end to end by one key also is |
| Whether anything is missing | A pinned head, or a witness (§7) | Entries cut from the end are invisible |
An implementation MUST report which axes a given run established, in the result itself. A caveat that lives in documentation is not delivered: the reader of a green check does not open the manual.