Skip to content

Convert the wire format to pure CBOR - #38

Draft
plaidfinch wants to merge 43 commits into
mainfrom
cbor-legible-wire
Draft

Convert the wire format to pure CBOR#38
plaidfinch wants to merge 43 commits into
mainfrom
cbor-legible-wire

Conversation

@plaidfinch

@plaidfinch plaidfinch commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

This also folds in some API changes: notably, Batch is now proof against async cancellation, because you can't hold one across an await-point, and we now mandate Eq for T and check at send-time that it round-trips, preventing you from accidentally sending a message which would cause divergence between your own understanding of its value and someone else's (as well as guaranteeing that everyone can deserialize whatever you serialize). This guard costs some additional compute, but I deemed it worthwhile to convert "permanent, persistent split-brain" and "poison value which can be gossiped to anyone and kill them" into local-only failures.

Closes #35.

…sign doc

The design doc now records today's rulings: the signal keeps its
redundant stream component; listings are spelled as maps; the greeting
map carries a protocol-magic entry; the observation hook is three-level
(peer, session, directed stream) with no library-imposed cross-stream
ordering; both consumers (the CBOR reflection snapshot extractor and
the tracing adapter) are in scope; and the provisional atom-tag block
is based at 0xD255 in the IANA first-come-first-served range, pinned
in the new public tags module pending registration.

Foundation commit for the CBOR-legible wire lane (rumors#35).
The owner-ruled, pre-release wire format change of rumors#35: every
directed stream of a V2 session now parses as an RFC 8742 CBOR sequence
under standard tag unwrapping, per design/cbor-legible-wire.md.

The spellings: frames are arrays carrying the dense signal code as a
uint item; query listings are {radix: hash} maps, whose deterministic
key order and the wire's canonical child order are now one rule; supply
runs and their records are embedded-sequence byte strings (tag 63) with
the version atom carrying its provisional tag; the greeting is one
embedded item (tag 24) wrapping a text-keyed map with a "protocol":
"rumors" magic entry; the preamble is a 30-byte self-described item,
55799(["rumors", version, network, intent]); the party hand-off is a
tagged byte string; stream labels are two uint items; the epilogue is
the text item ".". Decoders reject non-shortest-form and indefinite
heads, so the byte-pinning snapshot discipline stays meaningful. The
V1 wire, codec, and snapshots are untouched (the preamble and party
codecs branch on the selected dialect).

Re-derived pins, old -> new, each read from its instrument: the
calibrated dispute intercept 35 -> 43 B (the calibration cells; +3.9%
per disputed message at the design record); the default crossover
60 -> 52 B and the u64 BDP-scale window 65,404 -> 91,941 scopes (the
exact solve); DEFAULT_TARGET_MESSAGE_SIZE 1,638,912 -> 1,830,400 B
(const-evaluated from the new listing arithmetic, with a new
construct-and-encode adequacy pin); the disputed-reply transient
ceiling 3,380,000 -> 3,570,000 B (measured 3,468,800); the trade-off
table regenerated by its own renderer. The full wire snapshot corpus
re-accepts as this one deliberate format change; the renderer keeps
its hexdump vocabulary, decoding the new wire through the codec's own
functions. The stale "~4.6x" note in the window solve's assert message
(pre-existing drift: a figure that never matched the quoted prose) is
corrected by the re-pinned message it lives in.
The committed form of the legibility promise: randomized peer contents
drive real gossip, bootstrap, and retire sessions through the recording
link, and every directed stream must parse as a CBOR sequence under a
generic ciborium walk — standard tag unwrapping only, zero bytes
outside CBOR items, recursively through every embedded byte string. No
rumors codec type appears in the walker, so a wire change that smuggles
a non-CBOR byte onto any V2 stream fails here whatever the snapshots
say.
A deliberate, owner-ruled pre-release format change (rumors#35): the
on-disk bookmark frame becomes one self-described CBOR item,

    55799([ format_version: uint, integrity: bstr .size 32,
            payload: 24(bstr .cbor map) ])

with each stored clock spelled as a clock-tagged byte string wrapping
its canonical encoding, so a generic CBOR tool unfolds the whole file
with no rumors knowledge. Both format pins move under the format-change
re-accept class; BOOKMARK_FORMAT_VERSION bumps to 4 (now a u64).

The integrity hash covers the encoded format-version item and the
encoded payload item — every frame-array item except the integrity item
itself — matching the prior format's effective protection (corruption
of the fixed opening fails shape validation before the hash is
consulted). The single-byte-corruption sweep stays total, truncation
rejection is strengthened to every strict prefix, and a rumors-blind
parse test pins the generic-parseability promise.

Error surface is typed throughout: BadMagic carries a FrameDefect
naming the failed shape position, Decode carries a RecordDefect
(replacing stringified io::Errors), and both enums are public.
BOOKMARK_MAGIC is removed: a self-described CBOR file has no raw magic
analogue, and the integration-test helper now walks the frame
generically instead of stripping a transcribed header.
Pre-existing drift, split from the wire change's attribution: the
ignore-gated probe transcribed a 28-byte dispute intercept (the shipped
constant it validates against was 35 before the wire change), priced
u64 records at 8 encoded bytes (a random u64's CBOR encoding is 9),
and minted its "design record" as a 170-byte CBOR array rather than
the byte-string record the design cell is denominated in. The probe
now reads the intercept through the testing accessor and mints the
same Bytes records the calibration cells use.

Re-run readings, quoted at Peer::sync_memory_budget: at 8-26 MB
budgets on the minimal and design corpora, observed sessions ran
1.35-1.96x the closed form's figure, and the form's slowdown reads
~2-3x low at ~10 MB budgets and ~1.4x low at ~26 MB. The previous
1.3-1.65x band was measured through the drifted denominators on the
old wire, so the band's movement is jointly attributable to the
denominator fixes and the wire change and is deliberately not
decomposed further.
…contract, and the measured session-bytes check

The bookmark section states the v4 file as built (integrity region,
typed defect carriers, no raw magic); the committed contract names
capture validity as a permanent external/internal two-property pair;
the cost summary carries the one-off whole-session measurement at the
metadata-dominated worst corner; the decision record gains the capture
contract and sanity-check rulings.
The public `observe` module is the crate's capture surface: a
peer-attached handler (`Peer::observe`, `Bootstrap::observe`) yields a
per-session handler for every session the peer enters — gossip,
bootstrap, and retire — which yields a per-directed-stream handler as
each stream opens, which receives that stream's protocol messages as
raw wire bytes, exactly one CBOR item per invocation. The hook is
rumors-blind (no protocol type in its signature), imposes no
cross-stream ordering (consumers reconstruct interleaving from their
own session-scoped ordinal), never re-encodes (received items are
captured off the transport), costs one branch per frame when
unattached, and observes only the CBOR dialect (V1's frozen wire
cannot honor the one-item contract). The role election is delivered by
a dedicated notification when decided, since it does not exist at
session start.

Threading follows the stats recorder's shape: a cheap-clone session
handle reaches the preamble, greeting, party hand-off, and epilogue at
their call sites, and every data stream mints its own observer at open
or claim. Attaching an observer to the Bootstrap builder retires its
`Copy` (an `Arc` field cannot copy); the retry affordance survives as
`Clone`.

tests/observe.rs is the internal-capture half of the two-property
capture contract: a proptest differential holding the hook's view
byte-for-byte against the recording-link transport capture on
arbitrary gossip sessions (bootstrap and retire covered by fixed
pairings), the one-item property on every invocation, complementary
role elections consistent with every stream's speaker, and a
byte-identity property proving observation never changes the wire.
No snapshot moves.
The observation-hook commit landed with a red gate, two documentation
legs deep. First, five doc comments (the session ordinal's field doc
and four test invariants in the internal-capture suite) ran their
whole statement into the summary paragraph, past doclint's
220-rendered-character bound; each now leads with a one-sentence
summary and carries the rest below the fold, unchanged in content.
Second, the private-items rustdoc build rejects redundant explicit
link targets, and `Peer::observe`'s prose spelled two links through
`crate::` paths that already resolve bare in `peer.rs` scope; they are
bare now.
The wire snapshot corpus now pins each captured session as fully
unfolded CBOR value trees — extended-diagnostic-style notation with a
rumors naming layer (signal semantics, listing children and digests,
tagged-atom meanings, supply runs and records by name) — in place of
annotated hexdumps. The re-accept of every V2 snapshot in this commit
is the deliberate, owner-ruled renderer supplanting Finch commissioned
for rumors#35 (decision record in design/cbor-legible-wire.md: the
snapshot extractor consumer, 2026-08-19); it does not qualify under,
and does not use, the hex-line-preservation class.

The byte-pinning discipline survives on two legs, both stated in the
renderer's module doc: the rendering is injective on wire bytes (the
walk enforces the wire's canonical head grammar and shows every item's
complete content, falling back to explicit exact hex wherever it
cannot vouch for inversion — non-canonical heads, invalid UTF-8,
embedded content that does not fill its byte string, nesting past the
depth bound), and the harness holds the rendered items to the
transport capture as a totality oracle: each stream's open label plus
its concatenated observed items must reproduce the wire bytes exactly.

Capture enters through the public observation hook — the instrument
enters through the public door: the snapshot harness attaches a
recording observer to each peer (bootstrap builders included) and
renders what the hook delivers; the instrumented link stays solely as
the oracle and as the V1 timelines' capture, which are untouched, as
are the V1 wire snapshots and the transport-captured wire-legibility
property.

tools/digestshare speaks the new vocabulary (byte-count headers and
annotated digest lines instead of hexdump arithmetic) and now fails
loudly if the corpus stops yielding wire or digest bytes, instead of
reporting a silent zero. Re-measured over the re-accepted corpus:
8,441 wire B, 1,656 digest B, 69 digests at 24 B (19.6%), recorded in
the design doc.

Pre-existing defect fixed in passing, attributed here: the corpus
carried an orphaned snapshot (gossip_snapshot__both_redact_same_key)
that no test regenerates — it still held pre-conversion wire bytes and
silently survived the format change's re-accept, polluting the corpus
denominator. Deleted; the corrected corpus figure above supersedes the
one recorded at the format change.
The bookmark's FormatError variants say what the bytes are, not what
stage tripped: BadMagic (a misnomer against a magic-less file) becomes
NotABookmark, and Decode becomes Record, each keeping its typed defect
carrier. The handshake's failure cases are enumerated typed and
public: a structurally-wrong preamble field surfaces as
PreambleMalformed carrying a PreambleDefect naming the field, and a
peer hanging up mid-handshake surfaces as PreambleTruncated carrying
the exact byte counts — both formerly collapsed into
io::Error(InvalidData) and io::Error(UnexpectedEof). The disruption
harness's honesty classifier admits the typed truncation as an
injected-cut outcome, and the new seed it shook out is committed.

Owner-ruled (rumors#35 follow-up rulings, 2026-08-19).
…C is protocol-v1 vocabulary

Session numbering is the observer's own concern, counted inside its
handler with its own synchronization, exactly like message
interleaving: SessionInfo carries kind and protocol only, and the peer
keeps no session counter. PROTOCOL_MAGIC is the legacy V1 preamble's
marker and is exposed only under the protocol-v1 feature; the
handshake's cross-dialect detection reads the same bytes through a
crate-internal constant, since a V2 endpoint recognizes a legacy peer
without ever speaking its dialect.

Owner-ruled (rumors#35 follow-up rulings, 2026-08-19).
The capture renderer bounded one parse_node tree by MAX_DEPTH but
re-parsed each embedded byte string (tag 24/63) at depth zero, so a
chain of nested embedded byte strings recursed one Rust stack frame
chain per level with no budget surviving the byte-string boundary: a
deeply nested application payload aborted the harness with a stack
overflow.

Thread one depth budget through the whole render walk: render_node,
render_tag, render_listing, render_embedded, and render_embedded_as
carry the depth already consumed, structural descent increments it,
and an embedded byte string's content re-parses at that depth instead
of a fresh zero. Every render_* call site passes a depth no greater
than the depth its node was parsed at (the invariant stated on
render_node), so a node in hand always fits the remaining budget and
only parse_node checks the bound; nesting past MAX_DEPTH lands on the
existing explicit hex fallback, keeping the rendering injective.

Two committed tests pin the property at ten times the bound, on both
paths: a nested embedded chain rendered directly (render_item) and
the same chain arriving as a supply record's payload in an encoded
frame (render_frame). Both abort with SIGABRT before this change. No
snapshot moves: the depth budget only decides where the fallback
fires, and no committed corpus nests near the bound.
…nimal cell's residual

The three calibration cells shared a +/-2 byte tolerance band whose
rationale claimed it only absorbed integer-division adjacency; in fact
it also absorbed the minimal cell's systematic 2-byte offset below the
intercept, and with it any small-record per-message regression of up to
4 bytes (the minimal cell sat at the band's edge, so +1..+4 B moves all
stayed green while the payload-dominated design cell stayed exact).

Split the band into what each part actually is:

- MINIMAL_CELL_RESIDUAL = 2 names the systematic offset: small records
  batch more densely, so their share of per-frame framing is smaller
  (mechanism stated at DISPUTE_OVERHEAD_BYTES). The minimal cell now
  expects intercept + payload - residual (50 B) exactly.
- The tolerance dissolves entirely: the counters are deterministic
  (seeded corpus, in-memory link, pure byte tallies), all three cells
  sit exactly on their pins (50/107/215), and any slack would absorb
  real per-message movement, not measurement noise. All three cells
  assert exact equality, so a mean per-message cost change of one byte
  in either direction fails its cell.
- The negative control now bounds the converged session's fixed
  overhead below one byte per message at calibration scale (measured
  14560 B against the 2 * DIVERGENT = 16384 B bound), which is the
  premise that keeps the per-message division's truncation from ever
  shifting a cell's quotient; it also prints the measured figure.
- window.rs's intercept doc points at the residual constant instead of
  a tolerance band.

Mutation check performed by hand: perturbing the minimal cell's
measured value by +1 fails the cell (51 vs pinned 50); reverted.

The adversarial review's resolution for this finding specified keeping
a +/-1 tolerance; that band contradicts its own acceptance criterion
(a +1 B perturbation must fail, but sits inside +/-1) and the goal (a
+1 B small-record regression must not pass), so the band resolves to
exact pins per goal-over-mechanism.
decode_v2's intent-width arm is unreachable by width arithmetic (the
fixed 30-byte preamble with validated version and network heads leaves
exactly one byte for the intent item, whose one-byte head's value is at
most 23): make it an expect instead of fabricating an IntentInvalid
diagnostic carrying a byte the peer never sent. The two remaining
defensive arms (PreambleDefect::NetworkTruncated, TrailingBytes) stay,
each now stating its defensively-reachable-only status in the variant
rustdoc and carrying a one-line width derivation at its construction
site. Add the missing reachable-defect constructions as unit tests
(Version by wrong major and by widened spelling, Network by wrong
declared length), with an exemption note naming the two defensive
variants an auditor will find unconstructed.

Re-export HeadError through the codec module's export cluster and the
crate's error module, so LeafRunError::Head's source type is publicly
nameable and its rustdoc link renders live; the enum stays exhaustive
(RFC 8949's head grammar is closed). A nameability pin in
tests/decode_alloc.rs imports rumors::error::HeadError and matches
NotShortest from outside the crate.
Determinism is the encoder's promise: shortest-form heads, definite
lengths, one spelling per value, which is what keeps the byte-pinning
snapshot discipline meaningful. Ingress validates structure and
definite lengths everywhere, and every head the codec hand-parses
additionally rejects non-shortest spellings — but a record's version
atom and application payload, and the bookmark's embedded payload, are
decoded by a general CBOR reader that does not re-judge spelling (the
version atom's content canonicality is enforced by its own strict
decoder). The module docs at the three claim sites now state that
boundary instead of claiming spelling rejection the code does not
perform.

Two acceptance tests pin the boundary so it cannot drift silently in
either direction: a widened version-bstr-head record decodes Ok
through the run's record iterator, and a bookmark payload spelled as
an indefinite-length map decodes Ok to the empty record. Flipping
either to rejection is a deliberate contract change, not drift.

Zero snapshot movement; no behavior change.
…uidance

The stream-open label is two leading unsigned-int items, not a fixed
two bytes: an epoch of 24 or more takes a wider head. The observe
module doc now describes the label by its structure, and
tests/observe.rs parses captured labels through the stream_label
helper instead of hardcoding the two-byte shape, so the suite stays
correct at any epoch.

Observer::session's "called once per session" now points at the
module docs' Protocol::V1 exclusion, matching the dialect contract
already stated there.

SessionKind is #[non_exhaustive]: it enumerates lifecycle operations
the crate could plausibly grow, and the crate marks open diagnostic
taxonomies non-exhaustive. No in-tree consumer matches on it
exhaustively.

The Link transport contract now carries the one operational sentence
readers of the framing internals already had: reads are exact and
item-granular, so wrap an unbuffered transport's read half in
tokio::io::BufReader — caller-owned buffering outlives a session and
is safe across session boundaries.
… no ordinal vestige

OverbatchedRun's declared field is the frame's charged wire size — its
run body plus the SUPPLY_FRAME_OVERHEAD envelope at its widest — which
may exceed the actual frame by the envelope's head slack (at most
4 bytes). The field doc now says so, and the error text reads
"charges {declared} wire bytes" instead of presenting the charge as an
occupancy fact. The error-atlas snapshot re-accept is exactly that
deliberate diagnostic-vocabulary rewording: the four display/source
lines carrying the phrase, nothing else — the atlas pins error
rendering, not wire bytes, and no wire byte moves.

The greeting's uint reader now returns a per-key diagnostic ("set_len
is not an unsigned int" and siblings) through its previously unused
parameter, replacing the shared "greeting size entry" string.

Bootstrap::observe's doc no longer speaks of session ordinals the hook
does not carry: an observer that numbers sessions counts the join as
the first session it sees.

The epilogue test doc claims the marker's bytes, not a byte count that
rotted once already.
Extend the typed-error taxonomy one layer below the preamble, so an
application can programmatically distinguish a dead transport from a
counterparty that sent malformed bytes at the identity hand-off and the
greeting, without inspecting io::ErrorKind.

- New public HandOffDefect enum (mirroring PreambleDefect's style),
  carried by the new Error::HandOffMalformed; a clean close before or
  inside the promised hand-off is the new Error::HandOffTruncated.
  Wired through the V2 receive path (receive_v2, read_head,
  decode_party); the frozen V1 dialect keeps its io::Error vocabulary.
- ReadGreetingError::Decode now carries the typed GreetingError
  (public, with ListingIssue), surfaced through the mirror's
  HandshakeDecode variant as a typed source; GreetingError::Version
  carries the before decode error directly.
- Error doc table gains the two rows; the disruption honesty
  classifier admits HandOffTruncated as an honest cut while malformed
  stays dishonest; the party and greeting suites re-pin every failure
  to its typed variant, with new pins for the non-byte-string,
  malformed-head, truncated-listing, and version-defect arms.

No wire change: zero bytes move, all snapshots unchanged.
…5799 spellings to it

TAG_SELF_DESCRIBED is consumed only by the test-gated capture renderer,
so the constant now carries the same cfg gate; a bare default-feature
'cargo check -p rumors' is warning-free again. The two production
writers that pin the tag's rendered bytes inside literals are each held
to the constant by a committed pin test: prefix_matches_the_writers now
spells the tag through the constant instead of a third literal, and a
new bookmark pin test asserts the frame's opening bytes equal the head
writer's rendering of it.

The gate gains a bare-lib clippy leg (cargo clippy -p rumors --lib):
an invocation that also builds test targets compiles the lib with
cfg(test)-gated modules alive, so an item dead only in the shipped
default-feature lib never surfaced there. Demonstrated live: with the
constant left ungated, the --lib --tests leg passes while the bare-lib
leg fails on the dead constant.
… and the three bookmark frame defects

The greeting codec's shortest-form rejection and the bookmark frame's
Integrity, PayloadTag, and PayloadByteString defects were covered only
positionally (the corruption sweep rejects the bytes) but never asserted
as their typed variants, so a misclassification could pass unseen. Four
targeted constructions now name each variant in a matches!:

- a greeting whose set_len value head is re-spelled in the widened
  0x18 form parses to GreetingError::Head(HeadError::NotShortest);
- a flipped integrity-item header byte is FrameDefect::Integrity, the
  offset computed from the frame's fixed shape rather than hardcoded;
- a flipped payload tag byte is FrameDefect::PayloadTag, likewise
  computed;
- a widened payload byte-string head, with the hash recomputed over the
  re-spelled covered region so only the spelling check can reject, is
  FrameDefect::PayloadByteString.
…resolve to a live generating test

Modeled on the seed-liveness sweep: the corpus is the wire-format pin,
and an orphaned .snap — its test renamed, moved, or retired — is a dead
instrument that still looks like a byte-for-byte guarantee. The sweep
reverses insta's path resolution for both layouts (tests/snapshots
suite__name stems; src-side snapshots directories keyed by module
path), requires the resolved source to contain the generating function
or the explicit snapshot name, and convicts naming the orphan and the
file searched. Nothing under a snapshots directory is skipped: a
committed .snap.new fails loudly as unaccepted insta output, and so
does any other stray file. Fixture tests commit the conviction paths
(missing generator, deleted suite, pending output) alongside live
duals, so the sweep itself cannot rot green.
…nd gate-wire the digestshare vocabulary check

The session corpus pinned only QueryEmpty on the wire (nonempty
listings rode only inside greetings), so the one frame form that lists
a disputed node's children had no session-level byte pin. The new
fixture shared_subtree_dispute_pins_a_nonempty_query stages two peers
holding the same two-child branch under one root radix, then adds a
third leaf on one side: the subtree hashes differ while neither side
is absent and no listing is empty, so the dispute answer lists
children — a Query frame carrying a nonempty {radix => digest} listing
(three children in the accepted snapshot). An in-test liveness floor
asserts the nonempty listing before the snapshot comparison, so the
fixture cannot silently degrade back to QueryEmpty under a future
corpus change.

This change adds the one new snapshot
(gossip_snapshot__shared_subtree_dispute_pins_a_nonempty_query.snap)
and re-accepts nothing: every existing snapshot is byte-identical.

The gate's lint tier gains a digestshare leg: the tool's exit code
already carries a liveness verdict (V2 captures with zero countable
wire bytes or zero digests mean the renderer vocabulary moved out from
under its patterns), so wiring it in makes that check fire on every
commit instead of only on interactive runs. The leg checks the
renderer-vocabulary contract, never a threshold on the measured
ratio.
The module doc claimed ingress validates definite lengths everywhere;
the two positions a general CBOR reader decodes — a record's version
atom (its byte-string head) and the application payload — accept
indefinite spellings, refuted by construction. Rescope the sentence:
structure is validated everywhere, the hand-parsed heads reject
indefinite lengths and non-shortest spellings, and the general-reader
positions judge neither spelling rule. Drop the parenthetical roster of
hand-parsed positions rather than completing it (it omitted the
preamble and greeting heads and would rot as positions move).

Commit both indefinite-length acceptances as boundary pins beside the
existing widened-head pin: an indefinite-length version-atom byte
string and an indefinite-length payload map each decode Ok, so flipping
either to rejection is a deliberate contract change, not drift.
The atlas's module doc claimed witnesses for every codec error, but the
codec module also publishes the handshake layer's GreetingError and
ListingIssue, neither inventoried here. Scope the claim to the
frame-stream taxonomies the describe_* matches inventory, and hold the
two handshake-layer types as reasoned EXEMPT_MARKERS entries naming
greeting/tests.rs as their witness home, so a witness landing here is
flagged for promotion.

Add the one reachable collapse the atlas lacked: a wire query listing
whose key is a well-formed head of the wrong kind reaches the listing
gate and surfaces as Malformed(part=QueryChildren). The atlas snapshot
gains exactly the two new listing-key witness blocks (one per speaker);
every existing line is byte-identical.

Three prose corrections in the same file: the FramePart marker comment
no longer claims every part rides the encode Write witnesses (Signal
renders only from decode-side witnesses); the snapshot test's doc no
longer claims every witness pins an origin (the record-level witnesses
carry none); and the coverage failure message now names both required
edits for exempting a variant (the marker must also leave
WITNESS_MARKERS).
Six testdoc corrections toward what the bodies actually pin:

- The run-budget proptest's rejection is decided from at most the first
  record's heads, not before any body byte is read (the budget check
  reads those heads out of the body); its doc and the early-rejection
  comment now say so, and the unexercised flush-rule complement claim is
  gone (that shared boundary lives in the budget module).
- The structural-decode proptest pins byte identity of the carried run;
  deferral of record decoding is pinned by the zero-length-record test,
  and the doc now points there instead of claiming laziness this body
  cannot distinguish.
- The async-EOF truncation cases now decode through both decoders via
  decode_both, making the doc's sync-parity claim exercised rather than
  asserted; the clean close stays async-only and the doc says why.
- The chunk-boundary truncation doc now names every seeded cut,
  including the zero-byte, one-byte, and one-short-of-total cuts.
- The empty-listing doc no longer implies this test exercises the 256
  ceiling; the new oversized_query_listing_is_rejected pins the ceiling
  in both decoders, decided on the map head alone.
- The Stream constructor pin moves out of invalid_signals_are_rejected
  into its own documented test.

Also: the untagged-version arm now asserts its deterministic
InvalidData kind; the LeafRun records doc enumerates every content
failure class (tag defects and general-reader version-item failures
included); the observe module doc reflows an over-wide line; and the
greeting shape diagnostic drops its hand-maintained entry count.
…uals

The digestshare liveness check rode gate-lints but not the ci recipe,
so GitHub CI (which runs just ci) never executed it and a PR skipping
the local gate merged unchecked; add it to ci's dependency list.

Two defensive-variant exemption notes, per the handshake suite's house
pattern: party/tests.rs documents that HandOffDefect::UnaddressableLength
has no construction (the u64-to-usize length conversion cannot fail on a
64-bit host; only a 32-bit target such as wasm32 reaches the arm, and
the suite has no 32-bit test host), and greeting/tests.rs documents the
same for the greeting's two unaddressable-length shape diagnostics.

The snapshot-liveness module doc now states the containment rule's
limits as the pairing design's accepted residual: substring matching
keeps a renamed test's orphan green when a same-named function, string,
or comment survives, and an assertion-less survivor function also
passes.
…et path

The zero- and one-byte cuts exercise the earliest truncation seams, but
this test's declared run sits inside the default budget, so the
over-budget head read never runs here and the filler body carries no
record heads; the doc no longer claims either.
…de-side limit

Payload nesting depth becomes a configurable setup value. A new public
`PayloadDepthLimit` newtype (default `DEFAULT_PAYLOAD_DEPTH_LIMIT`, 256
scopes: exactly the bound ciborium's `from_reader` applied implicitly,
so a fleet at the default sees no acceptance change on existing
content) is selected by builder-style knobs on `Peer`, `Bootstrap`, and
the `BookmarkedBootstrap` passthrough, and follows the peer through
`into_rumors`, cloning, reunion, bookmarking, and retirement like every
other setup value.

The bare minted `PayloadDeserializer` a peer carried becomes a minted
`PayloadCodec`: a small `Copy` struct pairing the minted fn pointer
with the configured limit, threaded everywhere the deserializer
traveled (peer config, both dialects' sessions, `Message::from_wire`).
A plain fn pointer cannot capture a runtime value, so the limit rides
as data beside it, and sessions stay non-generic.

Decode-side enforcement: the minted deserializer's inner parse is now
`ciborium::de::from_reader_with_recursion_limit` at the codec's limit.
Parses that stay at the library default are each structurally flat:
V1's outer byte-string unwrap, the version atoms, and the bookmark
payload walk. The V1 wire freeze is byte-level and this change moves no
byte on either dialect: only the local acceptance bound becomes
configurable, symmetrically with V2.

`Message::from_slice`/`from_bytes` take the limit as an explicit
parameter: their bytes are the caller's trust domain, and an
application on a raised fleet limit must be able to rehydrate its own
stored deep messages, which an implicit default would refuse. The depth
failure surfaces through their existing io::Result as invalid data.

Tests: rehydration honors the explicit limit in both directions, and a
hand-crafted over-deep supplied record dies typed at wire ingress while
the same shape at exactly the limit decodes clean.
… scope

The send side of the symmetric bound: `Message::try_new` serializes,
runs an O(n) iterative depth scan over the produced bytes (the wire's
head grammar with an explicit stack of remaining-child counts, so
input-controlled depth never recurses), and returns a typed
`PayloadDepthError` past the limit. A `Serialize`-impl failure keeps
`Message`'s documented panic contract (programmer error); a depth
violation is the typed error (the value's shape can carry end-user
data). The scan's accounting is not transcribed into prose or
constants: a committed differential proptest generates values nested to
depths around the limit (arrays, maps, and tags mixed, the decoder's
big-integer form included) and holds the scanner and
`from_reader_with_recursion_limit::<ciborium::Value>` to the same
accept/reject verdict at the limit and on either side of it.

The minted codec gains its serializing half: a `PayloadSerializer` fn
pointer minted beside the deserializer, so all of `T`'s serde bounds
concentrate at `Peer` construction (`Serialize` joins
`DeserializeOwned` there) and drop from `Rumors::send`/`Batch::send`.
Every message creation in the peer's orbit now flows through the one
codec value carrying the configured limit.

`Rumors::batch` becomes a closure scope: `fn batch<R, E>(&self, f) ->
Result<R, E>` runs `f` with an exclusive `&mut Batch` handle and
commits everything queued, via `send_if_modified`, iff `f` returns
`Ok`. The scope type's `Drop` impl is deleted (with the
`thread::panicking()` guard); the lifecycle collapses to commit-on-Ok,
all-or-nothing: a depth error propagated out cancels everything, a user
`Err` cancels deliberately, a panic unwinds past the commit call, and
the async-cancellation prefix-commit hazard is unrepresentable — a
cancellation lands between polls and the synchronous closure runs
inside one poll. `a_cancelled_batch_commits_its_prefix` is deleted with
the hazard it pinned. Leak-proofing is static: the higher-ranked
`for<'s>` bound with `R`/`E` quantified outside, no `Clone`/`Default`/
public constructor on the scope type, private fields, and two
`compile_fail` doctests pinning the stash and return escape routes.
Batches nest (building holds no lock; inner commits land before the
outer batch), documented and tested.

`Batch::send(&mut self, T) -> Result<(), PayloadDepthError>` mints
through the codec; `Batch::redact(&mut self, &Version)` loses its
chaining return. Single-action sugar stays on `Rumors`: fallible
`send`, infallible `redact`, each committing immediately. The tutorial,
doctests, and every in-tree caller migrate to the closure form or the
sugar (tests share a `batch_send` helper); the READMEs re-derive.

Tests: the batch lifecycle pins above; the boundary suite
(tests/payload_depth.rs) round-trips a payload at exactly the default
depth peer-to-peer, rejects one scope past it at send with the typed
error, and gossips deep content clean across a fleet raised in concert;
try_new's exact boundary and the codec's carried-limit serialization
are pinned unit-wise.
…ality

The deliberate, owner-ruled pre-release wire format change: the V2
greeting map gains a `"payload_depth_limit"` entry (an unsigned int, the
sender's configured limit in scopes), recomputing the deterministic key
order — at 19 characters it ties `"target_message_size"` on length and
sorts before it bytewise — and widening `parse_greeting`'s exact roster
to the seven-entry map (the diagnostic stays count-free). The wire value
is authored by the proxy from the session's payload codec at send, so
the one configuration every parse already runs under is also the one
the wire declares.

A session proceeds only if the two exchanged limits are exactly equal.
The check runs in `complete_connect` and `accept` symmetrically, after
both greetings are in hand and before the equal-versions resolution, so
mixed configurations surface even on converged, no-op sessions; the
failure is the new typed `Error::PayloadDepthMismatch { local, remote }`
on both sides (documented in the error table: the limit is a fleet-wide
parameter; align it and reconnect). Equality rather than negotiation is
the ruling of record: negotiating down is unsound — a peer whose
session bound dropped below its own configured limit may already hold
messages deeper than the negotiated bound, content it would then not be
allowed to gossip — so negotiation merely relocates the failure to
mid-session, conditional on which leaves differ. The frozen V1 greeting
cannot carry the parameter, so V1 sessions keep decode-side-only
enforcement (the knob's rustdoc states the carve-out).

With this, the knob's achieved invariant holds and is documented: no V2
session between conforming peers can fail on payload depth at all —
over-deep values are rejected at their author at the moment of choice,
and mismatched fleets are rejected at hello.

Snapshot corpus re-accepted for exactly this change: every moved line
across the 21 re-accepted pins is the greeting item's byte count, its
embedded-item length, its map entry count, or the new
`"payload_depth_limit"` entry (verified mechanically against the parent
render). The dispute-wire calibration cells are unmoved — measured
215/107/50 B per message against pins 215/107/50: the 23-byte greeting
growth amortizes below the cells' integer quotients at 8,192 divergent
messages per side. `tools/digestshare` re-measured: 9,853 wire B,
1,776 digest B, 74 digests (18.0%); the design doc's corpus paragraph
is re-derived (its previous figures had drifted from the corpus before
this change — the parent commit measures 8,887 wire B — and are
re-measured with the attribution split stated in place). The wire
design doc gains the greeting-table row and the decision-record entry.

Tests: the handshake-equality pair — mismatched limits abort both sides
with the typed error, no role elected, no data stream opened, on a
converged pair (proving the check precedes the equal-versions
short-circuit) — beside the existing equal-raised-limits control that
gossips deep content clean.
`design/payload-depth-limit.md` records the package's design and its
decision record: the depth limit as a property of the shared set (hence
pairwise equality, never negotiation, with the negotiate-down
unsoundness argument); eager, fallible send with the panic/typed-error
split; a failed batch commits nothing; the closure scope as the
no-await mechanism, with the three rejected fiats (`!Send` misstates
the type, `#[must_not_suspend]` is unstable on the pinned toolchain,
clippy's `await-holding-invalid-types` binds only in-repo runs) and
the commit-on-`Ok` inversion; the minted codec with the
Serialize-at-construction cost; the 256 default's rationale; and the
V1 carve-out. Per the house rules the doc cites code and code cites
nothing back; the knob's rustdoc carries every invariant inline. The
wire design doc's decision entry gains the cross-pointer.

The crate-level "Message payloads and compatibility" section states the
depth bound (a payload admitted anywhere is transferable everywhere)
and points at the setting; the READMEs re-derive. One typo in the same
paragraph ("compabitility") is corrected in passing.

Two smaller adjustments ride along: a prose simplification pass over
this feature's new rustdoc and design prose,
and `Batch::commit` drops its empty-action short-circuit — `Tree::act`
documents an empty batch as a complete no-op and its false changed flag
suppresses the wakeup, so the branch guarded nothing.
Send-side admission judged a payload's depth with a byte-level
structural scan, differentially tested against decoding the bytes as
ciborium::Value. But the decode engine's recursion accounting is
type-dependent — decoding a serde enum prices its variant scope, so a
unit variant costs a step no scan of the bytes finds — and wire ingress
decodes the user's T, not Value. An enum payload at exactly the limit
(the crate docs' own recommended versioning shape) was therefore
admitted at send and failed at every receiver's ingress: a
deterministic gossip wedge between conforming peers at equal limits.

Admission is now the ingress computation itself: Message::try_new runs
the peer-minted deserializer — the same fn every receiver's wire
ingress runs, same type, same limit, same engine — over the
just-serialized bytes and discards the decoded value. The structural
scan and its Value-oracle differential are deleted: with one
computation there is no second accounting to hold in agreement.

The send-side error becomes EncodeError (replacing PayloadDepthError
everywhere, public re-export included), typed in two cases: Depth for
the recursion bound, and Roundtrip for a payload type whose Deserialize
rejects its own Serialize output — a violation that now surfaces at the
author instead of at every receiver. Crate-internally the minted
deserializer returns message::DecodeError, preserving the engine's
RecursionLimitExceeded as a typed variant; wire ingress still folds to
io::Error (DecodeLeafError::Message's surface unchanged), with the
depth case's invalid-data message now naming the exceeded limit.

Docs are restated to the new mechanism: the knob's no-failure invariant
holds by construction within a decode-engine version; the wire-ingress
bullet is scoped to content rather than byte shapes the engine consumes
without recursing; the limit's unit is the engine's recursion steps;
the rehydration constructors are documented as crate-internal (the
message module is private, so no application could ever name them);
crate docs and the derived README follow.

Regression pins: the recursive-enum boundary round-trips peer-to-peer
at the deepest admissible depth and rejects one step past it typed, at
the author, in both the unit suite and the peer-to-peer suite; a
round-trip-violating payload type fails admission typed; and a sender
whose counterparty aborts mid-session on a decode failure and discards
its link exits typed (Error::Epilogue, its local commit intact), never
hanging.
…ulings; state the empty-batch contract in Tree::act's rustdoc

ciborium is pinned to =0.2.2 in both manifests that name it. Depth
admission and wire ingress run the same compiled deserializer, so the
decode-recursion accounting is symmetric within a binary by
construction; across a mixed-version fleet the no-failure invariant
holds only if every build shares one accounting, and ciborium documents
no accounting contract — the exact pin is what makes 'same engine' a
property of the build. The pin's manifest comment carries the condensed
rationale and the bump procedure (a bump is a deliberate,
fleet-coordinated event: two-build verdict differential over a seeded
corpus; verdict-changing bumps ship with a greeting accounting stamp;
strictening bumps additionally re-validate stored payloads before
gossiping). The lockfile already held 0.2.2, so resolution is a no-op.

The payload-depth design document's mechanism section now describes
admission as the receiver's exact decode, and its decision record gains
four rulings: the Value-oracle differential refuted by construction
(the engine's recursion accounting is type-dependent, so no byte-level
oracle can equal the T-decode), with the knob's number engine-defined
and the cross-version residual stated; the exact pin's rationale in
full; the engine bump playbook (the negotiate-down unsoundness argument
applied across time instead of across peers); and the vendor evaluation
of record (cbor2 evaluated and rejected on supply-chain provenance,
with successor criteria stated).

Tree::act's rustdoc now carries the empty-batch contract (a complete
no-op: nothing ticks, the flag is false) that batch.rs's commit path
cites; it lived only in a body comment before.
Payload types now mandate Eq — table stakes for a wire message type —
and send-side admission, after running the receiver's decode over the
just-serialized bytes, downcasts the decoded value and requires it to
equal the value sent. Inequality is the new typed
EncodeError::Unfaithful ('the payload value's encoding decodes to a
different value'). The check is send-side only: ingress holds no
original to compare against.

Why: rumors exists to synchronize causal messages so a fleet can
replicate a causally-convergent state machine from the stream. A
message that decodes to anything other than what was meant violates
that premise — any state machine driven by consuming the stream could
diverge arbitrarily — so admission now guarantees at runtime that no
implementation can pollute the set with a value replicas would read
differently. The canonical lossy shape, a nested Option holding
Some(None) (serializes to CBOR null, decodes as None), is invisible to
any byte-level check: re-serializing the decoded None is
byte-identical, so the value space is finer than the byte space and
value equality is the only total instrument; the design doc's decision
record carries that argument and the rejected byte-fixpoint
alternative.

The T: Eq bound joins Serialize + DeserializeOwned at the codec mint
and sweeps through every construction-side signature (Peer, Bootstrap,
gossip's join mint); sessions stay non-generic. The Eq mandate (rather
than PartialEq) excludes NaN-capable float fields by design: equality
must be an equivalence relation for the check to be total and never
spurious.

The payload-type contract is now documented publicly, consolidated in a
crate-root 'Choosing a payload type' section: the bounds and what each
obligates — serializability with the PathBuf-class warning (previously
stated only on the crate-private Message type), faithfulness with the
Some(None) example, the float exclusion, and the depth-limit pointer.
Rumors::send/Batch::send panic sections and the tutorial point at that
contract instead of restating it; READMEs re-derived.

Test fixtures migrate off ciborium::Value as a payload type (Value has
no Eq: its Float variant): array spines become a nested newtype struct
whose decode prices identically (ciborium's newtype and transparent
paths do not recurse), the codec unit test's map spine becomes the
recursive enum, the ingress pin's hand-crafted bytes keep their depth
with an empty-array leaf, and the handshake/sender-exit pins use u64.
Value remains in decode-only rehydration tests, which carry no Eq
bound. The canonical faithfulness regression sends Some(None)
peer-level: rejected typed, nothing stored, faithful values of the
same type admitted.
The error module's public flattening carried the streaming adapter's
EncodeError/DecodeError under their bare names while the crate root's
EncodeError was the send-side admission error: two public EncodeErrors
whose meaning depended on the path. The unqualified name now goes to
the type applications touch, at both paths — the send-side EncodeError
is re-exported from rumors::error too, so the error module is the
complete one-stop taxonomy and agrees with the crate root — and the
adapter pair carries its scope in the public flattening, per the
module's own precedent for the codec pair (CodecEncodeError,
CodecDecodeError): ReplyEncodeError and ReplyDecodeError, named for
what they diagnose — the adapter's own docs state each type's domain
as one scoped protocol reply crossing the wire-frame boundary ('a
protocol reply could not be rendered faithfully as wire frames', 'wire
frames could not be reconstructed into one scoped protocol reply').
Module-internal declared names are unchanged, so no Debug rendering
moves and no snapshot is touched.

Crate-internally, message::DecodeError becomes PayloadDecodeError for
greppability; no public effect.

Cross-references follow: the OversizedVersion link in streaming's
message docs, the proxy declaration tests' public-path imports, and
the frame-fuzz design doc's taxonomy tour now name the flattened
public spellings.
Both documents shipped: the CBOR-legible wire and the payload-depth
package are implemented on this branch, their invariants stated inline
at the code they govern, so the documents move to the notes directory
as provenance, byte-identical, with orientation READMEs resolving their
mutual citation. The notes directory's LLM ground rules move from
README.md into new AGENTS.md/CLAUDE.md files so agents working under
the directory read the right instructions; README.md keeps the
human-facing caveat.
Comment thread crates/before/Cargo.toml Outdated
Comment thread src/peer/bootstrap.rs
Comment thread src/peer/gossip.rs Outdated
Comment thread src/tree/mirror/alternating/backend/remote/tests.rs Outdated
Comment thread src/peer.rs Outdated
Comment thread src/observe.rs Outdated
Comment thread src/message.rs Outdated
Comment thread src/tree/mirror/streaming/remote/proxy/error.rs Outdated
Comment thread src/tree/mirror/cbor.rs
The packet was the working spec for the fix-and-feature campaign:
findings with executable resolutions, seed dispositions, owner rulings,
and the payload-depth-limit implementation spec. The body is the
document's final revision, byte-identical; the README orients a reader
on its historical anchors (pre-resign SHAs, pre-rename vocabulary).
… calls

PR #38 review directive: six sites flagged as way too verbose. Every
stated invariant survives in fewer words; doc examples propagate
errors with ? (examples that ?-propagate both EncodeError and Error
return Box<dyn std::error::Error>, since Error has no From<EncodeError>).
README re-derived from the crate docs via just readme.
…e; PR #38 doc fixes

Review rulings from PR #38, the mechanical batch:

- The word "mint" no longer appears in the rumors crate's code, prose,
  comments, or test names: PayloadCodec::mint is now PayloadCodec::new
  (the idiomatic constructor name), and every prose use is reworded to
  plain construction/creation vocabulary in context. One occurrence
  remains in tests/session_stats.proptest-regressions, a comment line in
  a committed seed artifact that stays byte-stable.
- Every dependency resolves through [workspace.dependencies]: member
  manifests carry only workspace = true plus their own feature additions
  and optional flags. The exact ciborium pin and its rationale live once,
  at the root table. cargo tree --workspace --all-features -e all is
  byte-identical to the parent commit and Cargo.lock is untouched.
- Bootstrap's builder-mirror fields are uniformly documented: protocol,
  window, and run_budget now carry the same selected-by comments as
  payload_depth_limit and observe.
- The gossip Reconciliation codec doc states the codec's role in both
  wire directions, not ingress alone: ingress decodes through it, egress
  replays bytes it previously admitted, and its limit rides the greeting.
- tree::mirror::cbor's module doc states why hand-written head primitives
  exist instead of a general CBOR reader: head-level incremental parsing
  against the transport, O(1) skip by declared length, and canonical-
  spelling enforcement at the hand-parsed positions.
- The tutorial's examples propagate errors with ? (main returns
  Box<dyn Error>, since rumors::Error has no From<EncodeError>), and the
  send-never-errs digression is cut.
…the magic

A deliberate pre-release wire format change, owner-ruled on PR #38: the
V2 greeting map drops its "protocol" => "rumors" entry. The session's
protocol magic is the preamble's opening ["rumors", 2, ...] array, so
the greeting entry restated what every session had already checked; a
reader holding nothing but the bytes still finds the magic in the
preamble, one control item earlier.

- The encoder, the key roster, and the exact-roster parse drop the
  entry; the roster diagnostic stays count-free.
- The greeting-level wrong-magic test case dissolves: its subject is
  diagnosed at the preamble as Error::MagicMismatch, which the handshake
  suite pins with its own wrong-magic case.
- The wire-capture snapshot corpus is re-accepted for this change.
  Audit: across all 21 changed snapshots, every diff line is the removed
  "protocol" entry, a control-item byte-count header, or an
  embedded-item length annotation — verified mechanically; nothing else
  moved.
- tools/digestshare recomputes clean over the re-accepted corpus.
- The dispute-wire cost cells are exact pins and pass unchanged: the
  greeting shrink is per-session, amortized below every per-message
  quotient.
…rk pins

Two annotation-class snapshot re-accepts from PR #38 review; the wire
and on-disk bytes are untouched in both.

- The capture renderer's version-atom annotation now reads
  "causal version, event tree: <tree>": the rendered quantity is the
  version's whole ITC event tree in paper notation, never a scalar — a
  flat tree renders as its single uniform height (the corpus's
  "version 3" was that case), a forked one as the nested (n, e1, e2)
  form. The old word invited reading the flat case as a tally or a
  protocol version. Renderer-vocabulary re-accept witness: across all
  21 changed capture snapshots, the h'..' hex token sequence is
  byte-identical to the parent commit and every diff line is the
  version-annotation rewording — verified mechanically. The capture
  unit tests assert the new vocabulary.

- The bookmark format pins are now human-readable: each snapshot's
  first line is the frame's exact hex (the pin), followed by a decoded,
  annotated reading in the wire captures' idiom — format version,
  integrity digest sliced from the frame's own bytes (never
  recomputed), and the embedded record's networks and clocks.
  Witness: both pins' hex lines are byte-identical to the parent
  commit — frame_empty attests the on-disk format unchanged, and the
  round-trip/corruption suite passes unchanged.

tools/digestshare recomputes clean over the corpus: its patterns bind
the byte-count headers and digest annotations, which are untouched.
The renderer's annotation wording is the wire snapshots' to pin: a
reviewer judges wording at re-accept, and a unit test duplicating the
phrase forces a lockstep edit on every rewording while adding no second
check. The test now asserts the load-bearing structure alone: exactly
one rendered line moves, and it carries the version's exact rendering.
Containment is made collision-proof by a punctuated (non-flat) fixture
whose two renderings are length-preserving on the wire.
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.

Extend #12 so that the entire protocol is CBOR (proposal)

1 participant