Skip to content

Hash only Version for Merkle path, not content - #34

Merged
plaidfinch merged 11 commits into
mainfrom
w2/version-keying
Aug 19, 2026
Merged

Hash only Version for Merkle path, not content#34
plaidfinch merged 11 commits into
mainfrom
w2/version-keying

Conversation

@plaidfinch

Copy link
Copy Markdown
Collaborator

Closes #12 by implementing it as described.

…locally

A leaf's path is now the full-width BLAKE3 hash of its version's
canonical bytes, and its Merkle digest commits the version (plus the
compressed suffix) instead of resting on a content-derived path:
message bytes enter no path and no digest, so every compared quantity
is a pure function of the version set. Identity rests on the invariant
the protocol already requires everywhere — no two messages ever share
a version — instead of on a canonical content encoding, and a content
author contributes zero bits to any compared digest (issue #12).

What content addressing made silently divergent becomes locally
detectable: an insert landing on an occupied path, or a merge meeting
two leaves at one path that disagree on version or payload, is now a
typed LeafCollision instead of silent split-brain. The error is
crate-internal — no input can produce it (a fresh tick strictly
dominates the ceiling bounding every live leaf, and ingestion enforces
containment), so the public seams expect() it as the invariant breach
it would be, and reused-version copies on replicas that never meet at
one node remain digest-equal by design: an accepted, modeled trade.

Test fixtures that diversified leaves by payload bytes now diversify
by version, since payloads no longer move paths.

Claude-Session: https://claude.ai/code/session_01H26YwsygLvogog1aBm8Y2N
Version-only leaf addressing leaves Key nothing to add: it named the
same 32 bytes the version already determines. The public surface
retargets — Snapshot::get and Rumors::redact take &Version, snapshot
iteration and both observers yield versions (already their identity)
without a separate key, and persisting an identity for later redaction
means persisting the version's canonical bytes. Internally, iterators
that reconstructed paths for no remaining consumer stop doing so (the
borrowed walks slim their frontier frames), while the owned walk keeps
yielding paths for the mirror's leaf keying, and the causal observer
stages its backlog by (Rank, canonical version bytes): rank cached
once per leaf for cheap repeated comparison, byte tiebreak identical
to before::Ranked's total order.

The reconciliation docs re-derive their identity and digest-width
arguments for version addressing: compared digests are pure functions
of the version set, so the offline content-grinding vector is
structurally gone rather than priced, and 24 bytes remains the
unconditional birthday floor against actors with version-minting
influence.

Test fixtures that steered tree shapes by payload search can no longer
do so; the searched-shape fixtures in the snapshot suites are re-staged
in the wire-format re-acceptance.

Claude-Session: https://claude.ai/code/session_01H26YwsygLvogog1aBm8Y2N
Version-only addressing frees the payload encoding from canonicity, so
T's bounds become serde's (Serialize + DeserializeOwned) and the cached
payload is one CBOR value via ciborium — self-describing, so field and
variant names are the wire contract (reordering-compatible, pinned end
to end by tests/cbor_evolution.rs), with unknown fields skipped and
missing fields erroring absent a serde default. Message::new keeps its
documented panic: serializability is a stated caller obligation, and
with CBOR imposing no format-driven failures the only trigger left is
T's own Serialize declining a value.

before's types ride their existing canonical codec everywhere, framed
rather than re-encoded: as bare bytes where a frame already delimits
them (the greeting version, the party hand-off) and as single CBOR
byte-string values where the stream must delimit itself (leaf records,
the V1 node bodies, the bookmark payload's clocks via before/serde).
The V2 leaf record is now record-header ‖ CBOR(version) ‖ payload;
record_len prices that framing exactly, pinned against an actual push.
The greeting listing drops its count prefix for the codec's raw
radix-hash record shape. The V1 alternating messages keep their
structural framing over a crate-internal wire codec (tree::wire) whose
method names deliberately avoid before's inherent encode_to — an
inherent method silently shadows a trait method, which cost one
misaligned wire during this migration. The bookmark payload becomes
CBOR and its on-disk format version bumps to 3; older versions are
rejected loudly, as before.

Wire-format snapshot pins (insta, bookmark frames, codec atlas) are
deliberately left red for the named re-acceptance commit that follows.

Claude-Session: https://claude.ai/code/session_01H26YwsygLvogog1aBm8Y2N
…rmat

One deliberate, owner-ruled pre-release format change, named in full:

- Leaf paths and every Merkle digest derive from versions alone (the
  digest VALUES moved; digest count and 24-byte width did not).
- Payloads are CBOR: a leaf record's body is one CBOR byte string
  wrapping the version's canonical bytes, then the payload's CBOR bytes
  (record BYTE counts moved with borsh-to-CBOR, separably from the
  keying change, which moves no payload byte).
- The greeting's root-fan listing is raw radix-and-digest records,
  frame-delimited, with no count prefix.
- V1's wire atoms (version, message) are single CBOR values inside the
  unchanged structural framing.
- The bookmark on-disk format is version 3: a CBOR payload behind the
  same magic/version/hash frame. Both bookmark pins move with it, and
  AGENTS.md's bookmark re-accept rule is restated so a moved frame_empty
  is definitionally a format change, distinct from the ratified
  fixture-re-pin class.

The dispute-wire law is re-derived from the tests' own byte counts: the
per-message intercept is 35 B (was 34 - the record's version atom now
carries a one-byte CBOR byte-string header at calibration-corpus version
sizes), the design record stays m = 172, so the design-point anchor is
207 B; the default-budget crossover solves to m* = 60 B (was 61) and the
BDP-scale u64 window to 65,404 scopes (~4.3x), with the docs' quoted
figures and the generated trade-off table re-derived from the same
constants. The 5431 B per-dispute envelope is unmoved (node pricing is
untouched), verified by its recomputing test. digestshare: 5606 -> 5273
total wire B over the pinned corpus with digest bytes unchanged (1704 B,
71 digests), so the digest share reads 30.4% -> 32.3%.

The searched-shape snapshot fixtures re-stage themselves: payload bytes
no longer steer paths, so each fixture mints a deterministic pool of
versions, searches the pool for the shape its pin requires, and redacts
the rest (tests/common/shape.rs); the self-checks still verify every
landed shape. gossip_snapshot's both_redact_same_key is renamed
both_redact_the_same_message in the same re-accept.

Claude-Session: https://claude.ai/code/session_01H26YwsygLvogog1aBm8Y2N
The vocabulary sweep retargets every remaining content-addressing claim
to what the tree does: leaves are version-addressed, paths and digests
are version-derived, and the uniformity arguments (window statistics,
descent depth) rest on version hashing. The full-width identity
primitive is renamed PathHash — it hashes a version into a leaf's path,
and nothing about it is a content hash. The crate docs gain a "Message
payloads" section stating the serde/CBOR contract callers now hold:
names are the evolution contract, unknown fields skip, missing fields
error absent serde defaults, and no canonical encoding is required of T
because payload bytes carry no identity. READMEs regenerated
(`just readme`); both rustdoc gates (public and private) run clean.

Claude-Session: https://claude.ai/code/session_01H26YwsygLvogog1aBm8Y2N
The doclint summary cap admits one short first paragraph; the renderer
pin's statement moves below the fold.

Claude-Session: https://claude.ai/code/session_01H26YwsygLvogog1aBm8Y2N
An op_ref in the apply walk's identity check, a Path conversion left
over from the retired key type, two helper functions inserted between
tests and their doc comments, one orphaned doc block folded into the
pin it described, and the V1 wire codec gated to its consumers (the
alternating protocol and the typed tree's tests), so default builds
carry no dead codec.
The opening_bulk_pair helper landed on main after this branch's act
became fallible; the rebase merged it textually while clippy's denied
unused-Result caught the semantic seam. Handled with the collision-free
expectation every other test call site states.
A mechanized sweep: serde::de::DeserializeOwned, serde::Serialize, and
the serializer traits move into use lines at each consuming file (two
imports cfg-gated to their exclusively gated use sites). The qualified
trait-method calls on serde::de::Error and serde::ser::Error stay: a
bare Error import would collide with the local error types.
…lves to ingestion's assertion

Owner-ruled: a leaf's path is the full-width hash of its version, so
under the uniform-hash model the suffix is already a complete commitment
to the version set — committing the raw version bytes bought detection
only of off-model hash collisions (excluded by the model of record) and
of local path-derivation bugs the differential oracles already sample
deliberately at test time. The preimage returns to
LEAF_TAG ‖ suffix_len ‖ suffix.

With same-position leaves digest-equal by construction, the merge walk's
leaf arm is unreachable (equal pairs prune above) and becomes an
assertion; join loses its error channel entirely. The apply walk keeps
the one live detector — both leaves in hand at an occupied path:
byte-identical re-inserts stay idempotent, disagreement asserts as
version reuse (a crate bug, never an input: fresh ticks strictly
dominate the ceiling, party linearity keeps regions disjoint, and no
wire-derived leaf passes through the walk). LeafCollision dissolves as a
type; act, react, Tree::act, Tree::join, and the Batch and gossip
commits are all infallible, restoring Tree::act's original public
signature.

Every digest moves, so the wire snapshots re-accept as this deliberate,
owner-ruled pre-release format change (digest-value movement only:
line-for-line hex structure verified unchanged).
@plaidfinch
plaidfinch merged commit 10914b4 into main Aug 19, 2026
2 of 3 checks passed
@plaidfinch
plaidfinch deleted the w2/version-keying branch August 19, 2026 20:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

We don't need to hash message contents at all

1 participant