0031. The trust-bundle format: canonical bytes, evidence closure, integrity manifest¶
- Status: accepted (2026-07-10, spec 0133; plan spec 0131)
- Context: ROADMAP3 Milestone 20 — portable records a third party re-checks offline by re-executing claim-vs-evidence verification.
Context¶
A trust bundle is a contract with strangers: once .tsb files exist
outside this repository, every byte-level choice — canonicalization,
hashing granularity, what the file must contain for verdicts to be
re-derivable — is expensive to reverse. The scoped novelty claim (spec
0131) also depends on these choices staying honest: the envelope
(hashing, signing, transparency logs) is crowded prior art; the claim
rests entirely on the semantic core, so the format's job is to make
re-execution possible and tampering nameable, without overclaiming
what the envelope proves.
Decision¶
1. One JSON document, UTF-8, extension .tsb¶
Top-level sections, format major 1:
| key | content |
|---|---|
format |
{"name": "tessera-trust-bundle", "major": 1, "minor": 0} |
engine |
tessera_version, domain, claim_shapes (dotted identifiers, declared order) |
result |
the GroundedResult dict, verbatim — its per-claim verified flags are the recorded emission verdicts |
evidence_closure |
kind + the full graph snapshot and knowledge base (serde shapes) |
integrity |
canonicalization, leaves (the manifest), root |
action |
reserved, null until unit 0136 |
signature |
reserved, null until unit 0135 |
anchor |
reserved, null until unit 0138 |
Reserving the planned keys now means format major 1 survives the whole act — a verifier for major 1 reads every bundle the act will ever emit. A future key not in this table is a minor bump (old verifiers ignore it); a change to any existing section's meaning or to the canonicalization is a major bump.
2. Canonical bytes: tessera-canonical-json-1¶
json.dumps(value, sort_keys=True, ensure_ascii=False,
separators=(",", ":")), UTF-8-encoded. This is the _canonical_request
recipe that already produces the cross-process-stable idempotency key
(ADR 0026), generalized into bundle/canonical.py (the execution module
is not modified). The identifier string is recorded inside every bundle
(integrity.canonicalization), so the recipe can never drift silently —
a different recipe is a different identifier, visible to every verifier.
Deliberately not RFC 8785 (JCS) and never claimed to be: JCS
prescribes ECMAScript number serialization; this chain's data model is
strings + Decimal end-to-end (the only floats are ER confidences,
which Python's repr-based JSON round-trips exactly — pinned by spec
0132's fidelity tests). Claiming JCS conformance would buy interop we
don't need at the cost of float-formatting machinery we'd have to get
perfectly right on every platform. A smaller, exactly-specified recipe
is the honest choice; the mandatory-caveats rule (spec 0131) bans
"RFC 8785" from all public copy.
3. Evidence closure: the full corpus snapshot (v1)¶
evidence_closure.kind = "full-graph-snapshot": the bundle carries the
whole graph (nodes, edges, resolutions, mentions) and the knowledge
base, not just the cited records. Rationale (spec 0131 D4): whole-graph
claim shapes — superlative_conclusion and compare_conclusion
re-rank/recompute over every cluster (spec 0019) — cannot be
re-derived from cited records alone, and a cited-records-only bundle
would let a malicious bundler omit the larger customer and make a false
superlative "verify". Packaging the full closure closes that attack by
construction, not by policy. The committed corpora are small (sizes
measured and recorded at the milestone close); cited-only slimming for
large corpora is named future work and would arrive as a new closure
kind, never a silent change. A bundle whose closure kind the verifier
does not recognize can only ever degrade (unit 0134's taxonomy), never
upgrade to RE-DERIVED.
4. Integrity: a leaf manifest and a depth-1 root¶
integrity.leaves maps a leaf name to sha256:<hex> of that content's
canonical bytes:
node:<record-id>— one leaf per graph node, so integrity tampering names the exact record;format,engine,result,closure.kind,graph.edges,graph.resolutions,graph.mentions,kb,action— one leaf per remaining section.actionis a leaf from day one (hashing its literalnulluntil unit 0136 fills it), so that unit extends content, not the manifest shape.closure.kindis hashed too, so relabelling the closure is at least an integrity break without a re-seal (the verifier additionally decides re-derivability from the graph's presence, never from this label — spec 0134).signatureandanchorare attestations over the sealed root and are structurally excluded from the manifest they attest — a signature inside its own signed content would be circular.
Because the manifest hashes individual leaves rather than the containing
dicts, the root does not by itself commit to the section set — an
unexpected extra top-level, evidence_closure, or graph key would ride
along unhashed and unread. The verifier therefore also rejects any section
outside the fixed format-major-1 set (integrity_mismatches), so the root
effectively commits to the set as well as the contents; and a non-null
anchor (reserved, no verifier until unit 0138) is refused rather than
ignored. (Both hardenings were added after the M20/M21 adversarial audit.)
integrity.root is sha256 over the canonical bytes of the manifest
itself (whose keys sort deterministically). This is a depth-1 Merkle
construction, and the ADR says so plainly: a deeper tree exists to
serve inclusion proofs for partial data, and a v1 bundle always
travels whole — every verifier holds every leaf, so a tree would add
machinery without adding a guarantee. If a future closure kind ships
partial evidence, that format-major bump introduces a real tree; the
root's meaning ("commitment over all leaves") is unchanged by that
upgrade path. External anchoring (unit 0138) anchors the root.
What the integrity layer honestly proves: that the file is the file — which section (down to the record) changed since sealing. What it can never prove is that the content is true; that is the verifier's job (unit 0134), and conflating the two is exactly the category error the act exists to name (spec 0131). Verify reports the two layers separately.
5. Engine pins¶
engine.tessera_version (via importlib.metadata, fallback "0.0.0")
and engine.claim_shapes — the module.qualname dotted names of the
domain's declared shape tuple, in declared order (business: six;
devex/github_actions: empty — the generic grammars). Identifiers are a
proxy: a function body can change under a stable name, so identifier
equality plus version equality is the only combination read as "same
grammar". Unit 0134 downgrades on any mismatch (NOT-EVALUABLE, naming
both sides) rather than re-deriving under a different grammar and
calling it the same verdict.
6. Emission builds its own engines¶
build_bundle() replicates ground()'s four public steps on a fresh
graph/kb so the packaged snapshot is by construction the same object the
packaged verdicts were computed against. No dependency on the
module-private engine cache; a consistency test pins fresh == cached.
Consequences¶
- Unit 0134 can implement verify entirely against this contract: recheck
leaves → recheck root → reconstruct via
serde→ re-runis_supportedunder the pinned shapes → report the semantic and integrity layers separately. - Every future unit (signature, action, anchor) extends the manifest instead of restructuring the file; format major 1 holds for the act.
- The canonicalization identifier inside the bundle makes the byte recipe self-describing; the 3-OS determinism matrix (unit 0137) guards the recipe's cross-platform stability.
- Full-closure bundles are larger than cited-only bundles would be — accepted deliberately; the sizes are measured and recorded, and any future slimming is a visible format event.
Addendum (2026-07-18, spec 0148) — numbers in tessera-canonical-json-1¶
Writing an independent verifier (ADR 0038) immediately found a defect in this recipe: it was under-specified for numbers, and the gap is not theoretical — it changed two leaf digests and therefore the root.
The producer is Python, where json.dumps writes a float 1.0 as 1.0
and an int 1 as 1. A language without that type distinction cannot
reproduce the canonical bytes from a parsed document: JSON.parse
turns both into the same number and re-emits 1. The resolution and
mention sections carry float confidence scores, so any portable
implementation that re-serialises a parsed bundle computes different
hashes and reports a false TAMPERED.
The rule, now explicit: canonical bytes preserve the lexical form
in which a number appeared. An implementation must emit a number's source
text verbatim rather than re-format it (in Node, via the JSON.parse
reviver's context.source); an implementation that cannot recover the
lexical form must refuse to verify rather than guess — guessing
produces false TAMPERED verdicts, the worst possible failure mode for a
trust tool.
Nothing about the emitted bytes changes: this addendum states what the producer already does, so every committed artifact and every recorded root stays byte-identical. What changes is that the recipe is now implementable from the document, which is the property that makes a format portable rather than a description of one program.