Hash only Version for Merkle path, not content - #34
Merged
Conversation
…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
force-pushed
the
w2/version-keying
branch
from
August 19, 2026 17:12
2b1ae35 to
f3fef7b
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #12 by implementing it as described.