0004: Canonicalization and Hash Chain
Date: 2026-06-26
Status
Section titled “Status”Accepted; implemented in dent8-core/src/hash.rs. One item amended from the
original plan (JCS → serde_json sorted-key form) and two recorded as decided below
(schema_version, FactValue::Json).
Context
Section titled “Context”AppendReceipt promises an event_hash, and schema 001 has
previous_event_hash/event_hash columns, but FactEvent derives no
serialization and the canonicalization format was explicitly left unresolved.
Tamper-evidence is only as strong as deterministic bytes: two logically-equal events
must produce byte-identical input to the hash regardless of map order, whitespace, or
number formatting.
Decision
Section titled “Decision”-
Derive
serde::{Serialize, Deserialize}onFactEventand sub-types. Done. -
Amended: canonicalize with a sorted-key
serde_jsonform (to_value→to_vec), not RFC 8785 (JCS). The original plan named JCS; the implementation does not implement JCS (key order is UTF-8 byte order, not UTF-16; number/escape rules differ). They coincide because all keys are ASCII field names and all numbers are integers. Invariant: no non-ASCII/dynamic object key without bumpingCANON_VERSION. A real JCS crate (serde_jcs) is deferred until cross-implementation interop is actually required. -
Hash with SHA-256 and RFC 6962-style domain separation (
0x00leaf prefix). Amended for injectivity: the leaf input is length-framed and genesis-tagged —SHA-256(0x00 || CANON_VERSION || len(canonical) || canonical || tag || prev_digest)— and a malformedpreviousis rejected, so no two distinct(canonical, previous)pairs share a hash input. Done. -
Compute canonical bytes from the typed Rust struct, never from Postgres JSONB. Done.
-
provenance.recorded_atis appender-supplied inside the canonical event; SQL defaults are not used for event timestamps, anddent8_claim_edge.recorded_atis copied from the originating event.dent8_replay_runs.started_atstays DB-generated (operational run metadata, not replayable event data). Done. -
Done —
FactValue::Jsonis canonical by construction. The variant now holdsCanonicalJson, a newtype with a private field built only viaFactValue::json/CanonicalJson::new, which parse the input and re-emit it sorted-key + compact (and reject invalid JSON). The canonicalization is re-applied onDeserialize, so the invariant also holds on the trusted-reload path, not just at the write boundary. Embedded JSON differing only in key order/whitespace now hashes identically, so the “logically-equal → identical bytes” invariant holds for all fields. (NoCANON_VERSIONbump: the serialized shape is unchanged — a newtype struct is transparent — and no persisted log contained aJsonvalue.)Two number caveats, both consistent with the not-JCS premise above. (a) Floats are canonicalized via
serde_json’sfloat_roundtripfeature soto_string(from_str(x))is stable; without it ~10% off64values (e.g.13e300) drift on re-canonicalization, which — through the re-canonicalizingDeserialize— would change a written value on reload and false-alarm the hash chain. The feature is therefore mandatory, with float idempotency + serde-round-trip regression tests. (b) A JSON integer beyondu64or a high-precision decimal is parsed asf64and loses precision on the first canonicalization (idempotent thereafter, so it never trips the chain, but lossy). This is documented onCanonicalJson; callers needing exactness pass such numbers as JSON strings. -
Decided —
schema_versionis the out-of-bandCANON_VERSIONconstant, mixed into every leaf hash, rather than a per-event field. A per-event field is unnecessary while a single encoding version is in force; revisit if multiple encodings must coexist in one log.How to introduce a second encoding safely (the deferral is NOT a one-way door). When v2 is needed, do not bump the
CANON_VERSIONconstant inhash_leafon its own — that would re-hash every existing event under2and raise a false tamper alarm on the whole log. Instead, in the same change:- add a per-event
schema_version: u8field toFactEventwith#[serde(default = "…v1")]and exclude it fromcanonical_bytes(mix it into the leaf where the constant is today); - mix
event.schema_versioninto the leaf instead of the constant.
Every event already in the log was written under the only encoding that ever existed (v1), so it deserializes to
schema_version = 1and mixes1into its leaf — byte- identical to today’sCANON_VERSION = 1— so its stored hash, the chain, and any witness/anchor signature over it all still verify, with no data migration. New events carry2and the v2 rules; verification dispatches per event. Because the backfill-to-v1 is free at that point, adding the field now would be premature churn (it touches everyFactEventconstruction site) for no integrity gain. The single rule to preserve the property: never change the leaf-mixed version without a per-event field to record it. - add a per-event
Consequences
Section titled “Consequences”Positive:
- Tamper-evidence and cross-implementation deterministic replay become real, not slogans.
- Domain separation keeps a future transparency-log/Merkle upgrade non-breaking.
- The narrow
FactValue::Jsoncanonicalization (a type-enforced newtype) avoids over-engineering the integer fields while closing the one gap in the bytes invariant.
Negative:
- Adds
serde+serde_json+sha2+hexand a canonicalization round-trip on every append. - Not interoperable with an external JCS implementation (acceptable — no interop need yet; the invariant note guards against silently growing one).
Follow-Up
Section titled “Follow-Up”- [DONE]
canonical_bytes,event_hash/hash_chain, and tests (canonicalize(deserialize(canonicalize(e))) == canonicalize(e), key-order independence, injective genesis/previous, tamper-cascade). - [DONE]
FactValue::Jsonis canonical by construction via theCanonicalJsonnewtype (item 6) — canonicalized on build and on deserialize, with unit + hash-equality tests. - [DONE] Wired
hash_chaininto the Postgres transactional append (populateevent_hash/previous_event_hash, reverify on replay). - See storage.md, STATUS.md, and roadmap.md.