diff --git a/.agent-notes/2026-08-19-height-erasure/README.md b/.agent-notes/2026-08-19-height-erasure/README.md new file mode 100644 index 000000000..262c16b44 --- /dev/null +++ b/.agent-notes/2026-08-19-height-erasure/README.md @@ -0,0 +1,133 @@ +# Height erasure for the streaming mirror's plumbing: the record + +Status: implemented (the four migration steps below); this note is the +decision and measurement record. The mechanism's documentation of record +is the rustdoc — start at `streaming::erased` and the `materialized` and +`remote` module docs. + +## What was built + +The streaming (V2) mirror builds its executor-free runtime out of the +type system, and before this work it built it once per trie height: the +height-indexed vocabulary flowed through height-indexed channels, +generators, pumps, and adapters, so each of ~31 heights instantiated the +whole tokio mpsc stack, two async_stream state machines, and per-height +encode/decode adapters — all re-monomorphized per payload type per +downstream binary. + +The height parameter was phantom at every layer that matters (one Arc'd +node representation for every height; prefix length as runtime data; a +wire that never sees H), so erasure was re-tagging, not +re-representation: no unsafe, no transmutes, no copies, zero wire-format +change. The byte-pinned snapshots were the acceptance test for every +step and never moved. + +What landed, by commit: + +1. **The seam** (`streaming: add the height-erasure seam on Backend`): + `Backend::{Erased, erase, assume}`, the `ErasedNode` observation + trait, `ErasedPrefix` (length = height witness), implementations for + `Local` and the three test/conformance wrapper backends. +2. **Channels** (`streaming: erase the materialized channels' payloads`): + erased channel payloads behind per-edge typed facades. + Measured: 2,884,887 → 2,609,789 IR lines on `--test pairwise` + (debug, default features); rows naming tokio's mpsc 632k → 235k. +3. **The walk** (`streaming: erase the materialized walk's workers`): + the vocabulary itself went erased (`Query`/`Resolution`/ + `Resolve` over `Backend::Erased`), the level loops, answerer, + resolver, assembler, and deletion filter became shared bodies behind + thin typed shells, and step 2's facades dissolved into them. Erased + code reaches the typed `Backend` GATs through the 33-arm + runtime-to-type dispatch in `erased::ops`, keyed on prefix length. + Measured: → 1,717,836 lines; materialized-labeled rows 662k → 274k. +4. **The proxy** (`streaming: erase the remote proxy's scopes, adapter, + and pumps`): `Scope` erased to one type, the adapter's encode/decode + and the pump/encode workers shared, with `ops::{leaves, assemble}` + dispatching the two stream-shaped backend ops. + Measured: → 1,039,534 lines / 41,172 copies. + +Cumulative: **2,884,887 → 1,039,534 IR lines (−64%), 109,613 → 41,172 +copies** on the `pairwise` binary. (The design's original baseline of +2.04M had drifted to 2.88M before this work began; the drift is +pre-existing growth, not part of this attribution.) + +Runtime pin: `gossip_fixed_bidir_insertions/V2/5000`, parent vs. +result, measured back-to-back on ox-east-1 under `pset-run -n 8` +(scheduler-quiet reserved cores; local attempts were discarded as +contended by concurrent builds and are not comparable): + +- parent: 54.461–54.553 ms (point estimate 54.506 ms) +- erased: 54.031–54.182 ms (point estimate 54.095 ms) + +A −0.75% point delta with nearly touching confidence intervals: no +runtime movement. + +Compile cost, measured on the same box (stable 1.96.1, no build cache, +dependencies warm, then `cargo clean -p rumors` and a non-incremental +`cargo build --locked --tests` — the fleet-wide cost of compiling the +crate's own code into every test binary): + +- parent: 7,819 CPU-seconds (7,426 user + 392 sys), 423 s wall +- erased: 3,093 CPU-seconds (2,906 user + 186 sys), 271 s wall + +−60% CPU, −36% wall. CPU-seconds are the load-tolerant figure; wall +clock is bounded below by the dependency graph's critical path. + +## Step 5: the `Tagged` collapse — declined + +The optional fifth step (flipping `Backend::Node` to a mirror-layer +`Tagged` wrapper so the GAT and the seam collapse) was +conditioned on the typed facades feeling heavy after 1–4 settled. They +do not: the surviving typed surface is the protocol typestates (which +must stay typed — they are the compile-time schedule proof), one +request-erasure map and one reply re-tag per stage, and the fixed-height +root re-tags. Collapsing the GAT would reshape a public-ish trait to +remove code that no longer shows up in the measurements. Revisit only if +a future backend's `Node` stops being phantom-convertible. + +## What the types stopped proving, and what catches it instead + +Cross-height pairing inside the walk and proxy is now a +runtime-witnessed property rather than a compile error: every +`ErasedPrefix::assume` debug-asserts its byte length against the claimed +height, `erased::ops` derives its dispatch height from that same length +(coordinate and witness cannot drift), every channel keeps its +`QueueRole` height label, and the behavioral pins (alternating oracle, +violation/capacity suites, byte-pinned wire snapshots) exercise exactly +these pairings. The schedule itself — phase order, role alternation, +bottoming at `Z` — stays compile-time. Peer input cannot reach a +mispairing: wire prefixes decode through height-typed readers. + +## Resolutions of the sketch's open questions + +- **Coherence tricks on distinct `Node` types**: nothing relied on + them; the `Leaf` bound at `Z` survives at the typed shells. +- **`future_size.rs`**: verified in a release run after step 4 — all + three pins pass unchanged. The public futures were already + type-erased, and the budget is a generous order-of-magnitude + tripwire. +- **The fused `mirror!`/`seq!` drivers**: still linear-in-height inside + one function; after erasure they are no longer among the largest + functions (~5k lines/copy, one copy per peer pairing), so the boxing + follow-up stays unneeded. + +## Incident log + +(Retained from the design sketch, as history.) + +- **2026-07-17: the lib test binary crossed the memwatch limit.** The + link axis, not the height axis: every distinct `Link` type driven into + `remote::Handshaking::start` instantiates the whole proxy tower, and + the in-crate tests had accumulated fixture-wrapped link types. One + additional tower instantiation cost +137k IR lines but +0.7 GiB of + rustc peak memory; the tripwire's default was raised from 8 to 12 GiB. + The durable fix named then was this height erasure, which shrank every + tower from the inside. + +## What followed: the T axis + +Height erasure deliberately kept the payload axis. The follow-on work +erased `T` at the leaf conversion boundary so the subsystem compiles +once into the rlib — its record is +[`2026-08-20-item-erasure`](../2026-08-20-item-erasure/README.md) — +and height erasure shrank what that phase had to move. diff --git a/.agent-notes/2026-08-20-item-erasure/README.md b/.agent-notes/2026-08-20-item-erasure/README.md new file mode 100644 index 000000000..944ba2a1c --- /dev/null +++ b/.agent-notes/2026-08-20-item-erasure/README.md @@ -0,0 +1,22 @@ +# Item-type erasure: `T` leaves the tree and session (part II) + +The payload type erased behind `Arc`: the tree and both mirror +protocols compile once into the rlib, with thin typed facades +downcasting at the crate's API boundary. Headline numbers: a minimal +one-`gossip`-call consumer pays 34,699 IR lines instead of 1,095,377 +(height-erased) or 3,401,418 (pre-erasure); the warm-except-`rumors` +gate falls 249 s → 140 s; the runtime pin does not move. The sealing +lesson is recorded inside: extracting non-generic functions alone moves +nothing, because an `async fn` body codegens into whichever crate polls +it — the seal is returning the future boxed (the `dyn` coercion pins +the tower in the defining crate) plus `#[inline(never)]` against +cross-crate MIR inlining. + +- [`item-erasure.md`](item-erasure.md) — the problem, the design + options, the owner rulings, the staged implementation with per-stage + measurements, and the acceptance numbers. + +--- + +Retired from `design/item-erasure.md` with part II complete; the body is +the design document as it finished, already record-shaped. diff --git a/.agent-notes/2026-08-20-item-erasure/item-erasure.md b/.agent-notes/2026-08-20-item-erasure/item-erasure.md new file mode 100644 index 000000000..866a94a9d --- /dev/null +++ b/.agent-notes/2026-08-20-item-erasure/item-erasure.md @@ -0,0 +1,266 @@ +# Item-type erasure at the leaf boundary: the record + +Status: implemented (option B, with the rulings below), the four staged +commits gate-clean on the `height-erasure` branch; this note is the +decision and measurement record. The mechanism's documentation of record +is the rustdoc — start at `Message`'s type docs and `peer::gossip`'s +`Reconciliation`. Part I of the erasure arc has its own record in +[`2026-08-19-height-erasure`](../2026-08-19-height-erasure/README.md). +Numbers marked *measured* come from `cargo llvm-lines --test pairwise` +(debug, default features) on the height-erased tree. + +## The problem, precisely + +Height erasure removed the per-height axis: the streaming session's +channels, walks, and proxy pumps instantiate once per backend. The axis +it deliberately kept is the payload type `T`. Everything the session +touches is still generic over `T`: + +- `Backend::Erased = untyped::Node`: nodes hold `Message` at + their leaves, so every erased worker, channel, and dispatch arm + re-monomorphizes per `T`; +- the codec's `Frame` / `LeafRun` and the leaf conversion boundary + (`Leaf::leaf(Version, Message)`, `Node::message() -> &Message`); +- the tree itself (`untyped::Node`, the traversals, the CRDT ops). + +Because this code is generic, none of it is compiled into the `rumors` +rlib: every downstream binary re-instantiates it. Measured: the +`pairwise` test binary carries ~1.04M IR lines after height erasure, and +essentially all of it is generic re-instantiation — the marginal cost of +each additional payload type *or* additional binary. The fleet effect is +the 24 session-exercising test binaries each re-buying the subsystem. + +"Compiles only once" means: the session subsystem (and ideally the tree) +becomes non-generic code, codegen'd once into the rlib, with a thin +typed facade per `T`. + +## The load-bearing observation: the wire is already erased + +On the wire a leaf is `(Version, payload bytes)`: the codec serializes +`Message` to canonical CBOR at encode and deserializes at decode — +`T` exists on the wire only as bytes. Likewise the tree's identity +layer: a leaf's path derives from its version, and its hash commits to +canonical bytes, not to `T`'s shape. The only operations that genuinely +need `T` are the user-facing reads (`Rumors::iter`, `get`) and +insertion. + +## Design options + +The decision is where `Message ⇄ canonical bytes` conversion lives. + +### Option A: erase at the session boundary only + +The tree keeps storing `Message`; sessions convert each supplied leaf +to bytes at encode and construct `Message` at decode (what the codec +already does). The session core becomes generic over an opaque +`Payload = raw canonical bytes` instead of `T`. + +- Buys: the streaming subsystem compiles once. The tree stays generic. +- Costs: nothing new at runtime (the encode/decode conversions already + happen at exactly these points). +- Residue: the tree + CRDT ops still re-instantiate per `T` per binary. + Measured share (rows naming the tree layers in the height-erased + `pairwise` binary, overlapping): `tree::typed` 728k of the 1.04M + total, `typed::untyped` 318k, `tree::traverse` 186k — the tree, not + the session, is now the larger half of the re-bought code, which + argues for option B (or C's second phase) rather than stopping at A. + +### Option B (recommended; Finch's shape): erase the stored value +behind `Arc` + +`Message` already stores `{ message: Arc, serialized: Bytes }`, +and everything the tree and session do with a payload other than the +typed reads — the hash preimage, wire encode, size accounting — reads +the cached canonical bytes, never the value. So the erased message is +simply + +```rust +struct Message /* erased */ { + message: Arc, // the same allocation, + // unsized: +8 B fat pointer + serialized: Bytes, // unchanged, and already + // everything the erased + // core consumes +} +``` + +with the typed boundary reading by checked downcast (a `TypeId` +compare; a failed downcast is a *caught* mispairing — a stronger +tripwire than the height seam's debug-only prefix asserts). The one +per-`T` residue beyond the facade is a payload deserializer +(`fn(&[u8]) -> Result, _>`) threaded from +peer construction to the wire-decode boundary, which keeps malformed +payloads failing at ingress as `DecodeError::Record` exactly as today. + +- Buys: tree + session compile once — the full "compiles only once" — + with today's runtime behavior preserved at every point: reads free, + ingress single-decode, hash/encode off the cached bytes. +- Costs: a fat pointer per `Message` handle and a `TypeId` compare per + typed read; the `gossip_fixed` bench pin guards the claim that this + is nothing. +- Public API movement, owner-ruled (see the rulings): `Message` itself + is crate-internal (nothing re-exports it; verified against the + public rustdoc surface), but two of the crate's faces move. The + observers already speak owned `(Version, Arc)`, and the + `Snapshot` faces join them: a coerced `Arc` is a fat + pointer sharing the `T` allocation, with no `Arc` object anywhere + to lend, so the former `&Arc`-lending faces become owned — each + yielded item one `Arc::downcast::()`, a refcount bump plus the + `TypeId` check, exactly what a keeper paid before. And `Any` demands + `T: 'static`, which the insert paths previously did not. + +A decode-on-read variant (store the bytes only, decode at the typed +boundary) loses to this on every axis: it saves the fat pointer but +charges every read a CBOR decode that today is free. + +### Option C: A now, B later + +A is strictly smaller and proves the session seam; B builds on it. But +the measured residue above says the tree is the larger half, so +stopping at A forfeits most of the prize. + +## What the types stop proving + +`T`'s type safety at the session boundary is today only the guarantee +that both ends of an in-process session speak the same `T`; on the wire +it was never checked beyond CBOR well-formedness. Erasure moves nothing +security-relevant: payload validation stays exactly where it is +(deserialization at the read boundary). + +## Acceptance + +- Wire snapshots byte-identical (the wire never sees the change). +- The oracle and violation suites as behavioral pins. +- `cargo llvm-lines` per step, plus the new headline number: IR lines of + a *minimal downstream binary* (one `gossip` call), before and after — + the "what does the next consumer pay" meter. +- The `gossip_fixed_bidir_insertions/5000` bench as the runtime pin. + +## Rulings (owner-resolved) + +1. **Deserializer minting**: at `Peer` construction — one payload + deserializer per peer lifetime, stored on the peer and threaded to + every session. `DeserializeOwned` lives at construction; the gossip + entry points carry no serde bounds. +2. **Sealing**: non-generic core functions in the rlib, with the + public API unchanged — byte-for-byte the same signatures. If the + non-generic sealing turns structurally hairy, back it out and fall + back to generic shells that erase immediately: legibility outranks + structural purity here. +3. **Scope**: one stroke — tree and session erase together, staged as + gate-clean commits (the part I pattern); no transient + typed-tree/erased-session seam. +4. **Branch**: continues on `height-erasure`, stacking on part I. +5. **Holder shape and the payload faces**: the single coerced + `Arc` (the same allocation, unsized in + place), with the `Snapshot` faces going owned: `iter`/`range` yield + `(&Version, Arc)` and `get` returns `Option>` — `get` no + longer echoes a version at all, because a leaf's path derives from + its version, so the hit's version is always the queried one. The + alternative (an extra box holding a concrete `Arc`, to keep + lending `&Arc`) was rejected: it charges the gossip path a + malloc per message at insert and wire ingress to subsidize + look-only application scans, and the box is pure plumbing whose + only justification is preserving a signature. +6. **`T: 'static` at the insert paths**: added. Safe type erasure is + `TypeId`-based and `Any` requires `'static`; gossip already + demanded it, and the local-only borrowed-payload usage that + previously compiled (verified by probe) was unintended surface. + +## Implementation plan (staged, each commit gate-clean) + +1. **The erased `Message`**: the crate-internal `Message` becomes + non-generic — `{ message: Arc, serialized: + Bytes }` — with typed constructors at the insert boundary and + checked typed accessors (downcast) at the read boundary. Measure. + *Measured*: `pairwise` at 1,039,827 lines / 41,186 copies — flat + against the 1,039,534 / 41,172 baseline, as this stage predicts: + the payload type leaves storage, but every instantiation still + exists because the tree and session stay generic over the now- + phantom `T`. The dedup is stage 2's and 3's to collect. +2. **The erased tree**: `untyped::Node` and the typed veneer drop + `T` (the stored `Message` was its only occurrence); iterators and + walks yield erased leaves; `Rumors`/`Snapshot`/observer facades + downcast at the door. The V1 oracle and the wire codecs sweep along. + Measure. + *Measured*: `pairwise` at 719,899 lines / 30,285 copies, from + 1,039,827 / 41,186 — −30.8% per consumer binary — after two moves: + the tree layers going non-generic (the `join`/`unknown` towers then + codegen once in the rlib: present in the lib's own measurement, + absent from the binary's), and the batch-apply entry going + *monomorphic* (`Vec` in, `&mut dyn FnMut` observer), because a + generic entry re-instantiated the whole per-height apply tower — + radix grouping included, ~155k lines — in every consumer despite + the payload erasure. The lib's own codegen grows 32,262 → ~102k: + the once-paid residence of what consumers stopped re-buying. + Measurement lens, corrected en route: `cargo llvm-lines` counts + only the named target's crate, so the per-binary number IS the + marginal cost, and substring attribution overcounts (session + workers' signatures mention tree type names). The residue is the + streaming session, still generic over `(B, T)` — stage 3's scope, + and where the trade's verdict lands. +3. **The erased session**: `Backend` drops `T`; the codec's record + decode keeps the wire bytes and builds payloads through the + deserializer (a plain `fn` pointer — `PayloadDeserializer`, minted + by `Message::deserializer::()` at peer construction); sessions + receive it at their handshake entry. The V1 oracle joins the same + regime (`DecodeNode::read_node` and the payload-bearing messages' + `DecodeWith` take the deserializer), which dissolves the phantom + decode-context fields stage 2 introduced, and the gossip entry + points drop their serde bounds entirely — `DeserializeOwned` lives + at `seed`/`bootstrap`, `Serialize` at `send`. One taxonomy + consequence, deliberately re-accepted in the error atlas: a record's + trailing payload bytes now classify as a malformed payload + (`DecodeLeafError::Message(InvalidData)`), because the payload runs + to the record's end and the deserializer owns the + exactly-one-value check; the record-level `TrailingBytes` variant + became unreachable and is gone. Measure. + *Measured*: the lib grows ~102k → 354,431 / 12,716 — the session + now compiles once, into the rlib — but `pairwise` sits flat at + 719,377 / 30,252, because `Peer::`'s session-driving methods + are still generic funnels: `gossip_inner::` monomorphizes in + the consumer crate and drags the whole (now `T`-free, still + `B`-generic) session tower with it. The cut lands with stage 4's + sealing of those entry points. +4. **Sealing**: the session core's entry points go non-generic over + the erased tree, deserializer, and dyn-erased link (public API + unchanged). The new meter lands here: IR lines of a minimal + downstream binary (one `gossip` call) — the "what the next consumer + pays" number. Measure; bench pin at the end. + *Measured*: `pairwise` falls 719,377 → 207,278 lines (30,252 → + 8,299 copies) — −71% in this stage, −80% across part II — and the + lib grows 354,431 → 867,372: the towers' once-paid residence, the + +513k there mirroring the −512k every consumer stops re-buying. + What remains in a consumer of `rumors`'s code is ~11k lines of + thin generic shells (the largest single item: `gossip_inner`'s + bookkeeping at ~1.5k). The headline meter, a minimal + one-`gossip`-call binary: 3,401,418 lines (102,771 copies) against + pre-erasure `rumors`, 1,095,377 (34,116) against the height-erased + tree, 34,699 (1,161) fully erased — −96.8% for part II alone, + −99.0% across both erasures. The sealing mechanism is subtler than + "extract non-generic functions", which alone moved *nothing*: an + `async fn` body is a closure item codegen'd into whichever crate + polls the future, so a bare non-generic `async fn` hands the state + machine right back to its caller's crate. What seals is returning + the future boxed — the `dyn` coercion inside this crate pins the + vtable, the poll function, and everything the body awaits into this + crate's object code — plus `#[inline(never)]` on the small shells, + without which optimized builds' automatic cross-crate MIR inlining + would move the coercion (and the tower behind it) back into the + consumer. + *Runtime pin*: `gossip_fixed_bidir_insertions/V2/5000` at 15.67 ms + against the height-erased tree vs 15.62 ms fully erased — + identical within the confidence intervals (both sides back-to-back + on the same machine under the same background load; a shared-host + run, so a coarse regression check rather than a precise + measurement, and it shows no movement to explain). + *Wall-clock corroboration*, measured across the whole branch (main + vs fully erased, back-to-back on one machine, kache replaced by a + pass-through `RUSTC_WRAPPER` in every phase so the numbers are pure + rustc): the warm-except-`rumors` gate — dependencies built, the + `rumors` package `cargo clean -p`'d, the developer's + edit-and-gate cost — runs 249 s on main and 140 s fully erased + (−44%); the from-scratch build-and-gate runs 370 s vs 239 s + (−35%). Since a fully warm gate runs ~130 s (the tests and checks + themselves), the `rumors` codegen share of the inner loop fell + from ~120 s to ~10 s. diff --git a/benches/in_memory.rs b/benches/in_memory.rs index c84c33483..fb4e2ccfa 100644 --- a/benches/in_memory.rs +++ b/benches/in_memory.rs @@ -41,7 +41,7 @@ use std::hint::black_box; use criterion::{BatchSize, BenchmarkId, Criterion, Throughput, criterion_group, criterion_main}; -use futures::FutureExt; +use futures::{FutureExt, StreamExt}; use rumors::{CausalMessages, Peer, Rumors, UnorderedMessages, Version, causally}; // The shared grid module exposes a superset of helpers; each bench binary uses @@ -76,7 +76,7 @@ fn build(n: usize) -> (Rumors<()>, Vec) { /// many messages were yielded. fn drain(observer: &mut UnorderedMessages<()>) -> usize { let mut count = 0usize; - while let Some(Some(item)) = observer.borrow_next().now_or_never() { + while let Some(Some(item)) = observer.next().now_or_never() { black_box(item); count += 1; } @@ -259,7 +259,7 @@ fn bench_observer_delta(c: &mut Criterion) { /// many messages were yielded: [`drain`]'s twin for the causal face. fn drain_causal(observer: &mut CausalMessages<()>) -> usize { let mut count = 0usize; - while let Some(Some(item)) = observer.borrow_next().now_or_never() { + while let Some(Some(item)) = observer.next().now_or_never() { black_box(item); count += 1; } diff --git a/design/height-erasure.md b/design/height-erasure.md deleted file mode 100644 index f4a954d52..000000000 --- a/design/height-erasure.md +++ /dev/null @@ -1,248 +0,0 @@ -# Height erasure for the streaming mirror's plumbing - -Status: sketch, not implemented. Companion to the compile-time work of -2026-07-16 (transport erasure at the session boundary; `protocol-v1` -feature gate). Numbers below are *measured* with `cargo llvm-lines` on -`--test pairwise` (debug profile, default features) unless marked -*derived*. - -## The problem, precisely - -The streaming (V2) mirror gets fixed memory and pipelining without an -executor: every logical stream keeps moving under plain polling, with no -`tokio::spawn`. It builds that runtime out of the type system — and it -builds it **once per trie height**. The height-indexed vocabulary -(`message::Reply`, `Query`, `Resolution`) -flows through height-indexed channels, generators, pumps, and adapters, -so each of the ~31 heights instantiates: - -- ~6 typed mpsc channels (`materialized/work/queues.rs`), each dragging - tokio's whole mpsc stack (~1k IR lines per payload type); -- two `async_stream` generator state machines (`work/levels.rs`, - the proxy pumps), each with its own drop glue; -- per-height encode/decode adapters and reply pumps on the proxy side. - -Measured, in one test binary after V1 gating: **2.04M lines of LLVM IR -total**, of which ~696k is streaming-marked symbols, ~446k is -`tokio::sync::mpsc` + `async_stream` machinery instantiated on the -height axis, and a large share of the remaining `core`/`alloc` glue and -12k+ `drop_in_place` symbols is induced by the same height-indexed -types. All of it re-monomorphizes per payload type `T`, per downstream -binary (24 test binaries exercise sessions). - -Boxing cannot fix this: `BoxResponses` already caps how deeply the -stream *types nest* (the one genuinely exponential hazard, long since -fixed), but boxing does not reduce the instantiation *count* — every -boxed stream still has a height-indexed item type. - -## The load-bearing observation: the runtime is already erased - -The height parameter is phantom at every layer that matters: - -- `typed::Node` wraps `untyped::Node` (one `Arc`'d - representation for every height; the prefix length is runtime data) — - `src/tree/typed/node.rs`, `src/tree/typed/untyped.rs`. -- `Prefix` is `PhantomData H>` over an - `ArrayVec<[u8; 32]>` whose length (`32 - H::HEIGHT`) is runtime data — - `src/tree/typed/prefix.rs`. -- The wire never sees `H`: the V2 codec's runtime signal byte - (`state * 17 + stream`) names the logical stream on every frame — - under the Link transport nothing multiplexes, so it is no longer a - demux key but per-stream redundancy, validated to exact equality - against the claimed label (`streaming-wire-deadlock.md` §8.6) — and - the channel layer already threads `H::HEIGHT` as a runtime `u8` for - diagnostics (`QueueRole::new(kind, H::HEIGHT)` in - `streaming/materialized/work/queues.rs`). - -So height erasure is **re-tagging, not re-representation**: every -conversion is a `PhantomData` swap over the value the program already -holds. No `unsafe`, no transmutes, no copies, and — because the encoder -never consumed `H` — **zero wire-format change**. The byte-pinned -snapshots must not move; that is the acceptance test for every step. - -## The design - -Keep the typed phase schedule as the interface; erase everything that -flows *through* it. - -### What stays typed (the guarantees we keep) - -`protocol.rs`'s traits (`Initiator`, `Responder`, `Reply`, -`CompleteResponder`, `CompleteInitiator`, `ReplyHeight`), the -`Descending` typestates, and the `mirror!` driver -schedule. These are what prove, at compile time, that phases run in -order, each side speaks when it must, and the descent bottoms out at -`Z` instead of trusting a depth counter. They are also *cheap*: each -state is a small struct and a thin method once the workers beneath them -are shared. - -### The erased seam on `Backend` - -```rust -pub trait Backend: ... { - type Node: ...; // unchanged - /// One runtime representation shared by every height's `Node`. - type Erased: Clone + Send + 'static; - /// Forget a node's height tag. Free for `Local` (a field move). - fn erase(node: Self::Node) -> Self::Erased; - /// Re-tag an erased node at height `H`. - /// - /// Contract: `assume::(erase::(n)) == n`. Debug builds check - /// the runtime height (prefix length) and panic on a cross-level - /// value; release builds trust the erased module's internal - /// invariant (see "What the types stop proving"). - fn assume(erased: Self::Erased) -> Self::Node; -} -``` - -For `Local`: `Erased = untyped::Node`, and both conversions are the -existing phantom wrap/unwrap. A hypothetical backend with genuinely -different per-height representations supplies an enum. (`Backend` is -public; adding an associated type is a breaking change — acceptable -pre-1.0, and the crate docs already reserve API reshaping.) - -`Prefix` gets the same pair (`Prefix::erase() -> ErasedPrefix`, -`ErasedPrefix::assume::()` with a `debug_assert_eq!(len, -32 - H::HEIGHT)`), where `ErasedPrefix` is the `ArrayVec` it already -is. - -### The erased vocabulary and channels - -One module (say `streaming/erased.rs`) defines the height-free frames: - -```rust -struct Reply { replies: Vec> } -enum Reaction { Supply(u8, B::Erased), Match, Query(Vec<(u8, Hash)>) } -struct Query { prefix: ErasedPrefix, ours: Vec<(u8, B::Erased)> } -struct Resolution { prefix: ErasedPrefix, resolved: ... } -``` - -`queues.rs` mints channels of *these* — one mpsc instantiation per -`(B, T)` instead of per `(B, T, H)`. The typed facade is a pair of -one-line wrappers: - -```rust -struct TypedSender(Sender, PhantomData H>); -``` - -whose `send` erases and whose `recv` re-tags. The per-height residue is -these adapters and the `BoxResponses` map-streams at the schedule -boundary — one small map per height instead of the full machinery. - -### The erased workers - -`internal_level` (and `leaf_parent_level`, `leaf_level`, -`answer::internal`, `Resolver`, `children_of`, `assemble`, the -`unknown` walk, the `convert` folds, and the proxy's -`encode`/`decode`/`internal_replies` pumps) each become a thin typed -shell — capture `H::HEIGHT` as a `u8`, erase the inputs, delegate, -re-tag the outputs — over one shared worker. Two workers keep a runtime -branch the types used to make static: - -- the leaf boundary: leaves carry `Message` and content-addressing; - the erased worker branches where `untyped::Children::Leaf` already - discriminates, and `height == 0` must agree with that discriminant - (debug-asserted); -- the `unknown` recursion: a loop over runtime height replaces 31 - `BoxFuture` instantiations (that module already boxes each level, so - this is strictly less machinery). - -### What the types stop proving, and what catches it instead - -Today, sending a level-5 resolution into a level-6 queue is a compile -error. After erasure it is a bug class again — confined as follows: - -1. **Locality.** The typed facade is the only entry to the erased - module; a mispairing can only be authored inside it. The audit - surface is one module, not the codebase. -2. **Runtime witnesses.** Every `assume` debug-asserts the prefix - length; `QueueRole` already labels every channel with its height, so - a violation names itself. -3. **The oracle and the violation suite.** The alternating oracle tests - (`streaming/tests`), the adversarial-schedule backend, and - `work/tests/violations.rs` exercise exactly these pairings; the - byte-pinned wire snapshots pin the external behavior. -4. **Unchanged threat model.** Peer-controlled input was always - validated at runtime (the typed layer never protected the wire); - the schedule itself — phase order, role alternation, bottoming at - `Z` — stays compile-time. - -### What it buys (derived) - -- mpsc/`async_stream` machinery: ~446k lines ÷ ~31 heights → ~15–30k. -- Streaming-marked symbols: the per-height workers dominate the ~696k; - shared workers leave roughly 150–250k (schedule shells + one worker - set + leaf specifics). -- Drop glue and `core`/`alloc` glue shrink proportionally (they are - per-type artifacts of the erased population). - -Estimate: `pairwise` drops from ~2.04M to roughly **0.7–1.0M lines**, -and — more important for the fleet — each *additional* payload type or -test binary re-buys only the residue, not the tower. The axis this does -NOT remove is `T` itself: compiling the whole subsystem once, inside -`rumors`, additionally requires content erasure at the leaf (the -conversion boundary already named in the streaming module docs — leaves -cross the wire as canonical borsh bytes today). That is phase 2, -orthogonal, and composes: height erasure shrinks what phase 2 would -move. - -## Migration plan (each step lands gate-clean, snapshots byte-identical) - -1. **Seam.** Add `Backend::Erased` + `erase`/`assume`, `ErasedPrefix`, - and the `Local` impl. No caller changes; pure addition. -2. **Channels.** Erase `queues.rs`/`channel.rs` payloads behind - `TypedSender`/`TypedReceiver`. Measure (expect the mpsc block to - collapse). -3. **Materialized workers.** One erased worker behind - `internal_level`/`leaf_parent_level`/`leaf_level`; - erase `answer`/`Resolver`/`assemble`/`unknown`. The oracle suite is - the behavioral pin. Measure. -4. **Proxy workers.** Erase the adapter encode/decode and reply pumps - (the codec beneath them is already runtime-indexed). Wire snapshots - are the pin. Measure. -5. **Optional cleanup.** If the facades feel heavy, consider flipping - `Backend::Node` to a mirror-layer `Tagged` wrapper - so the GAT and the seam collapse into one shape. Bigger surface - change; only worth it once 1–4 have settled. - -## Incident log - -- **2026-07-17: the lib test binary crossed the memwatch limit.** The - link axis, not the height axis: every distinct `Link` type driven into - `remote::Handshaking::start` instantiates the whole proxy tower, and - the in-crate tests had accumulated fixture-wrapped link types (memory, - adversarial, scripted; a reordering acceptor tipped it over). - - Measured on the lib test target (stable 1.96.1, all features): one - additional tower instantiation cost +137k IR lines but **+0.7 GiB of - rustc peak memory** — the cost is type-tree and collector state, not - codegen volume — and under incremental CGU partitioning (every tower - lands in the proxy module's CGU) the same delta took the compile from - ~7.3 GB to the 9.4 GB memwatch kill. - - Resolution: the tripwire's default was raised from 8 to 12 GiB - (`tools/memwatch`). The guard exists to catch runaway exponential type - growth (its motivating incident was 25+ GiB); this was measured - *linear* growth in an intentional axis — each fixture link type is one - more tower at a bounded, known cost. A test-only `Link::into_erased` - funnel was tried and worked (7 → 4 towers, peak back to 7.7 GB) but - was rejected as contorting test code to an arbitrary threshold. If the - tower count keeps growing, that owned erased carrier is the shape to - revive — and it must be *owning*: a borrowed carrier leaves the - concrete connector alive in the caller, so the peer's supply never - closes and every supply-closure test stalls. The durable fix remains - this document's height erasure, which shrinks every tower from the - inside. - -## Open questions - -- Does any code rely on `B::Node` being *distinct types* per height - for coherence tricks (blanket impls keyed on `Node: Leaf`)? The - `Leaf` bound at `Z` survives (the typed shells still see `Node`), - but step 5 would need care here. -- `future_size.rs` pins session-future sizes; the erased state machines - are smaller, so the pins move once (deliberately). -- The fused `mirror!`/`seq!` drivers stay linear-in-height inside one - function (~5k IR lines/copy). If they remain the largest single - functions after erasure, box each phase step — a small, independent - follow-up. diff --git a/examples/swarm.rs b/examples/swarm.rs index 331a455b2..15f619975 100644 --- a/examples/swarm.rs +++ b/examples/swarm.rs @@ -134,7 +134,7 @@ use std::time::{Duration, Instant}; use arc_swap::ArcSwap; use clap::Parser; -use futures::FutureExt; +use futures::{FutureExt, StreamExt}; use rand::rngs::SmallRng; use rand::{Rng, RngCore, SeedableRng}; use ratatui::Terminal; @@ -618,7 +618,7 @@ fn run_party( /// its version into the pool. Each message is yielded exactly once across /// the party's lifetime, so the pool never holds duplicates. fn drain_versions(observer: &mut UnorderedMessages, pool: &mut Vec) { - while let Some(Some((version, _))) = observer.borrow_next().now_or_never() { + while let Some(Some((version, _))) = observer.next().now_or_never() { pool.push(version.clone()); } } diff --git a/src/batch.rs b/src/batch.rs index d56f0b7dc..8c648e7ff 100644 --- a/src/batch.rs +++ b/src/batch.rs @@ -44,7 +44,7 @@ use serde::Serialize; /// synchronizes them itself. pub struct Batch<'a, T: Send + Sync> { inner: &'a watch::Sender>, - actions: Vec>, + actions: Vec, } impl<'a, T: Send + Sync> Batch<'a, T> { @@ -63,9 +63,9 @@ impl<'a, T: Send + Sync> Batch<'a, T> { /// commit: the failure surfaces at the offending call. pub fn send(&mut self, message: T) -> &mut Self where - T: Serialize, + T: Serialize + 'static, { - self.actions.push(Action::Insert(Message::from(message))); + self.actions.push(Action::Insert(Message::new(message))); self } diff --git a/src/conformance/backend.rs b/src/conformance/backend.rs index 6d8390b26..488ddb78c 100644 --- a/src/conformance/backend.rs +++ b/src/conformance/backend.rs @@ -60,7 +60,7 @@ use crate::{ message::Message, tree::{ mirror::streaming::{ - self, Backend, BoxNodeStream, Leaf, Node, NodeStream, Root, + self, Backend, BoxNodeStream, ErasedNode, Leaf, Node, NodeStream, Root, convert::Convert, materialized, window::{FAN, WindowConfig}, @@ -79,7 +79,7 @@ use crate::{ /// keeps alive per session reference. It is the ground truth the /// [`node_bytes`](Backend::node_bytes) contract is checked against, so /// it must not consult the cost function it validates. -pub(crate) trait Measure: Backend: Leaf> { +pub(crate) trait Measure: Backend: Leaf> { /// The actual resident bytes of one node value, measured. fn measure(node: &Self::Node) -> usize; } @@ -196,11 +196,10 @@ impl Drop for ChargedNode { } } -impl Node for ChargedNode +impl Node for ChargedNode where - T: Send + Sync + 'static, - N: Node + Clone + Send + 'static, - N::Backend: Measure, + N: Node + Clone + Send + 'static, + N::Backend: Measure, { type Backend = Charged; type Height = N::Height; @@ -222,28 +221,41 @@ where } } -impl Leaf for ChargedNode +impl ErasedNode for ChargedNode { + fn span(&self) -> Span<'_> { + self.inner().span() + } + + fn hash(&self) -> Hash { + self.inner().hash() + } + + fn len(&self) -> usize { + self.inner().len() + } +} + +impl Leaf for ChargedNode where - T: Send + Sync + 'static, - N: Leaf + Clone + Send + 'static, - N::Backend: Measure, + N: Leaf + Clone + Send + 'static, + N::Backend: Measure, { - fn message(&self) -> &Message { + fn message(&self) -> &Message { self.inner().message() } async fn leaf( version: Version, - message: Message, - ) -> Result>::Error> { + message: Message, + ) -> Result::Error> { let node = N::leaf(version, message).await?; - let measured = >::measure::(&node); + let measured = ::measure::(&node); // The pointwise contract at the leaf seam: after construction has // had its chance to persist the payload, the cost function at // `children = 0` and the node's own bounds must cover what the // handle keeps resident — this is the price the session budget // charges every decode-fan slot. - let priced = >::node_bytes(0, bound_bytes::(&node)); + let priced = ::node_bytes(0, bound_bytes(&node)); if measured > priced { ledger::violation(format!( "underpriced leaf: measured {measured} B, node_bytes priced {priced} B", @@ -254,23 +266,36 @@ where } /// The two encoded version bounds a node keeps resident, in bytes. -fn bound_bytes(node: &N) -> usize +fn bound_bytes(node: &N) -> usize where - T: Send + Sync + 'static, - N: Node, + N: Node, { let bounds = node.span(); bounds.hi().as_bytes().len() + bounds.lo().as_bytes().len() } -impl Backend for Charged +impl Backend for Charged where - B: Measure + Backend: Leaf>, - T: Send + Sync + 'static, + B: Measure + Backend: Leaf>, { type Node = ChargedNode>; + type Erased = ChargedNode; type Error = B::Error; + // Both conversions settle the wrapper's ledger entry and open an + // identical one around the re-tagged handle: the running total dips by + // one node's bytes between the two calls and never rises, so the + // census peak is untouched. + fn erase(node: Self::Node) -> Self::Erased { + let bytes = node.bytes; + ChargedNode::wrap(B::erase(node.into_inner()), bytes) + } + + fn assume(erased: Self::Erased) -> Self::Node { + let bytes = erased.bytes; + ChargedNode::wrap(B::assume(erased.into_inner()), bytes) + } + fn node_bytes(children: usize, version_bound: usize) -> usize { B::node_bytes(children, version_bound) } @@ -302,7 +327,7 @@ where let measured = B::measure(&node); // The pointwise contract: the cost function evaluated at this // node's own fan and bounds must cover its measured bytes. - let priced = B::node_bytes(fan, bound_bytes::(&node)); + let priced = B::node_bytes(fan, bound_bytes(&node)); if measured > priced { ledger::violation(format!( "underpriced node: fan {fan} measured {measured} B, \ @@ -335,11 +360,7 @@ where })) } - fn children( - self, - prefix: Prefix>, - parent: Self::Node>, - ) -> impl NodeStream + fn children(self, prefix: Prefix>, parent: Self::Node>) -> impl NodeStream where H: Height, S: Height, @@ -359,7 +380,7 @@ where self, prefix: Prefix, node: Self::Node, - ) -> impl NodeStream { + ) -> impl NodeStream { // Delegate to the wrapped backend's own override: the bulk walk // is the path the wire encoder runs, so its yields must land on // the census and answer the walked node's aggregates. @@ -377,7 +398,7 @@ where // The pointwise contract at the walk: a walked leaf // is a fan slot the session budget prices at // `children = 0`. - let priced = B::node_bytes(0, bound_bytes::(&leaf)); + let priced = B::node_bytes(0, bound_bytes(&leaf)); if measured > priced { ledger::violation(format!( "underpriced walked leaf: measured {measured} B, \ @@ -408,15 +429,15 @@ where fn assemble<'a, H: Convert>( self, - leaves: BoxNodeStream<'a, Self, T, Z>, - ) -> impl NodeStream + 'a { + leaves: BoxNodeStream<'a, Self, Z>, + ) -> impl NodeStream + 'a { // Delegate to the wrapped backend's own override: bulk assembly // is the path the wire decoder runs. The wrapper records each // maximal same-prefix run as it feeds the inner stream, then // holds every assembled node to account for its run. let runs: Arc, Run>>> = Arc::default(); let recorded = Arc::clone(&runs); - let supplied: BoxNodeStream<'a, B, T, Z> = Box::pin(leaves.map(move |item| { + let supplied: BoxNodeStream<'a, B, Z> = Box::pin(leaves.map(move |item| { item.map(|(prefix, leaf)| { let mut runs = recorded .lock() @@ -446,7 +467,7 @@ where "unsupplied assembly: a node yielded at {prefix:?} \ without a leaf run", )), - Some(run) => check_assembled::(&node, measured, &run), + Some(run) => check_assembled::(&node, measured, &run), } (prefix, ChargedNode::wrap(node, measured)) }); @@ -481,10 +502,9 @@ struct Run { } /// Hold one bulk-assembled node to its run's account. -fn check_assembled(node: &B::Node, measured: usize, run: &Run) +fn check_assembled(node: &B::Node, measured: usize, run: &Run) where - B: Backend: Leaf>, - T: Send + Sync + 'static, + B: Backend: Leaf>, H: Height, { // The len aggregate is exact: a run's node answers its leaf count. @@ -516,7 +536,7 @@ where // the radix (`FAN`) or the run's leaf count, and the contract // requires `node_bytes` monotone in fan, so a measurement above // this price is above the price at the node's own fan too. - let priced = B::node_bytes(run.leaves.min(FAN), bound_bytes::(node)); + let priced = B::node_bytes(run.leaves.min(FAN), bound_bytes(node)); if measured > priced { ledger::violation(format!( "bulk-assembled node over-holds: measured {measured} B, \ @@ -563,7 +583,7 @@ fn sweep_bounds() -> Vec { /// bounds, and every adjacent bound pair at each swept fan. fn node_bytes_monotone() where - B: Measure + Clone, + B: Measure + Clone, B::Error: std::fmt::Debug, { let bounds = sweep_bounds(); @@ -625,7 +645,7 @@ const DIVERGENT: usize = 1_024; /// - the session failed to converge the corpora. pub(crate) async fn check(backend: B, budget_bytes: usize) where - B: Measure + Clone, + B: Measure + Clone, B::Error: std::fmt::Debug, { node_bytes_monotone::(); @@ -652,7 +672,7 @@ where /// measured bytes above the resting corpora. async fn run(backend: B, window: WindowConfig) -> usize where - B: Measure + Clone, + B: Measure + Clone, B::Error: std::fmt::Debug, { let charged = Charged::new(backend); @@ -706,9 +726,9 @@ where } /// Drain one corpus's bulk leaf walk so the walk seam's checks run. -async fn walk(charged: &Charged, corpus: &Root, u64>) +async fn walk(charged: &Charged, corpus: &Root>) where - B: Measure + Clone, + B: Measure + Clone, B::Error: std::fmt::Debug, { let Some(root) = corpus.root.clone() else { @@ -727,16 +747,16 @@ where async fn corpus( charged: &Charged, messages: impl Iterator, -) -> Root, u64> +) -> Root> where - B: Measure + Clone, + B: Measure + Clone, B::Error: std::fmt::Debug, { let mut leaves: Vec<(Prefix, ChargedNode>)> = Vec::new(); for (version, payload) in messages { let message = Message::new(*payload); let path = Path::for_leaf(version); - let leaf = > as Leaf>::leaf(version.clone(), message) + let leaf = > as Leaf>::leaf(version.clone(), message) .await .expect("corpus leaves construct at rest"); leaves.push((Prefix::from(path), leaf)); diff --git a/src/conformance/backend/tests.rs b/src/conformance/backend/tests.rs index 758069884..8637518a6 100644 --- a/src/conformance/backend/tests.rs +++ b/src/conformance/backend/tests.rs @@ -17,7 +17,7 @@ use crate::{ message::Message, tree::{ mirror::streaming::{ - Backend, BoxNodeStream, Leaf, Local, Node, NodeStream, convert::Convert, + Backend, BoxNodeStream, ErasedNode, Leaf, Local, Node, NodeStream, convert::Convert, }, typed::{ self, Hash, Prefix, @@ -111,7 +111,7 @@ impl Drop for Dishonest { } } -impl Measure for Local { +impl Measure for Local { fn measure(node: &Self::Node) -> usize { // A `Local` node is an `Arc` handle into the session-resident // tree: its shallow size is everything it keeps resident. @@ -206,9 +206,8 @@ impl MaterializedNode { } } -impl Node for MaterializedNode> +impl Node for MaterializedNode> where - T: Send + Sync + 'static, H: Height, { type Backend = Materializing; @@ -242,15 +241,12 @@ where } } -impl Leaf for MaterializedNode> -where - T: Send + Sync + 'static, -{ - fn message(&self) -> &Message { +impl Leaf for MaterializedNode> { + fn message(&self) -> &Message { self.inner.message() } - async fn leaf(version: Version, message: Message) -> Result { + async fn leaf(version: Version, message: Message) -> Result { // Eager persistence at the conversion boundary: the payload is // written to the store here, so the resident row keeps only the // header and bounds — the thin-handle shape the leaf seam prices @@ -262,19 +258,52 @@ where } /// The two encoded bounds of a freshly built node, in bytes. -fn bounds_of(node: &typed::Node) -> usize { +fn bounds_of(node: &typed::Node) -> usize { node.ceiling().as_bytes().len() + node.floor().as_bytes().len() } -impl Backend for Materializing -where - T: Send + Sync + 'static, -{ - type Node = MaterializedNode>; +// The erased observations pass through the row wrapper; the row itself +// carries no readable state. +impl ErasedNode for MaterializedNode { + fn span(&self) -> Span<'_> { + self.inner.span() + } + + fn hash(&self) -> Hash { + self.inner.hash() + } + + fn len(&self) -> usize { + self.inner.len() + } +} + +impl Backend for Materializing { + type Node = MaterializedNode>; + type Erased = MaterializedNode; type Error = Infallible; + // Erasure re-tags the store's handle; the resident row rides along + // unchanged, so the census this backend exists to exercise sees no + // movement from either conversion. + fn erase(node: Self::Node) -> Self::Erased { + let MaterializedNode { inner, row } = node; + MaterializedNode { + inner: inner.into_untyped(), + row, + } + } + + fn assume(erased: Self::Erased) -> Self::Node { + let MaterializedNode { inner, row } = erased; + MaterializedNode { + inner: typed::Node::from_untyped(inner), + row, + } + } + fn node_bytes(children: usize, version_bound: usize) -> usize { - let priced = std::mem::size_of::>>() + let priced = std::mem::size_of::>>() + PRICED_HEADER.get() + ROW_ENTRY * children + version_bound; @@ -301,24 +330,20 @@ where .into_iter() .map(|(radix, child)| (radix, child.map(|child| child.inner))) .collect(); - let parent = Local.parent(prefix, children).await?; + let parent = ::parent(Local, prefix, children).await?; Ok(parent.map(|node| { let row = ROW_HEADER + ROW_ENTRY * fan + bounds_of(&node); MaterializedNode::wrap(node, row) })) } - fn children( - self, - prefix: Prefix>, - parent: Self::Node>, - ) -> impl NodeStream + fn children(self, prefix: Prefix>, parent: Self::Node>) -> impl NodeStream where H: Height, S: Height, { stream! { - let mut children = pin!(Local.children(prefix, parent.inner)); + let mut children = pin!(::children(Local, prefix, parent.inner)); while let Some(child) = children.next().await { yield child.map(|(prefix, node)| { // A lazily loaded row: header and bounds, its child @@ -334,12 +359,12 @@ where self, prefix: Prefix, node: Self::Node, - ) -> impl NodeStream { + ) -> impl NodeStream { // The reference bulk walk: the default explosion behind knobs // that drop leaves ([`WALK_SKIPS`]) and inflate the yielded rows // ([`WALK_SLACK`]) — honest at rest, the negative controls' // subject when set. - H::explode( + H::explode::( self, Box::pin(futures_stream::once(async move { Ok((prefix, node)) })), ) @@ -354,13 +379,13 @@ where fn assemble<'a, H: Convert>( self, - leaves: BoxNodeStream<'a, Self, T, Z>, - ) -> impl NodeStream + 'a { + leaves: BoxNodeStream<'a, Self, Z>, + ) -> impl NodeStream + 'a { // The reference bulk assembly: the default fold behind knobs // that drop supplied leaves ([`ASSEMBLE_SKIPS`]) and inflate the // assembled rows ([`ASSEMBLE_SLACK`]) — honest at rest, the // negative controls' subject when set. - let supplied: BoxNodeStream<'a, Self, T, Z> = Box::pin(leaves.skip(ASSEMBLE_SKIPS.get())); + let supplied: BoxNodeStream<'a, Self, Z> = Box::pin(leaves.skip(ASSEMBLE_SKIPS.get())); H::assemble(self, supplied).map(|item| { item.map(|(prefix, mut node)| { node.row.resize(node.row.len() + ASSEMBLE_SLACK.get(), 0); @@ -370,10 +395,7 @@ where } } -impl Measure for Materializing -where - T: Send + Sync + 'static, -{ +impl Measure for Materializing { fn measure(node: &Self::Node) -> usize { std::mem::size_of_val(node) + node.row.len() } @@ -503,7 +525,7 @@ fn dipping_node_bytes_fails_the_monotonicity_sweep() { fn leaf_underpricing_fails_at_construction() { let _dishonest = PRICED_HEADER.set(0); let leaf = pollster::block_on( - >> as Leaf>::leaf( + >> as Leaf>::leaf( Version::new(), Message::new(7), ), @@ -530,14 +552,12 @@ fn ledger_settles_over_clone_and_drop() { ledger::reset_peak(); ledger::peak() }; - let leaf = pollster::block_on( - > as Leaf>::leaf( - Version::new(), - Message::new(7), - ), - ) + let leaf = pollster::block_on(> as Leaf>::leaf( + Version::new(), + Message::new(7), + )) .expect("a local leaf constructs infallibly"); - let handle = std::mem::size_of::>(); + let handle = std::mem::size_of::>(); let clone = leaf.clone(); assert_eq!( ledger::peak(), @@ -547,7 +567,8 @@ fn ledger_settles_over_clone_and_drop() { drop(leaf); drop(clone); - let node = typed::Node::leaf(Version::new(), Message::new(7)); + let node: typed::Node = + typed::Node::leaf(Version::new(), Message::new(7)); let charged = Charged::::new(Local); let _ = &charged; let wrapped = super::ChargedNode::wrap(node, 100); diff --git a/src/message.rs b/src/message.rs index dd8779615..dec9a7e07 100644 --- a/src/message.rs +++ b/src/message.rs @@ -1,4 +1,4 @@ -use std::cmp::Ordering; +use std::any::Any; use std::fmt; use std::hash::{Hash, Hasher}; use std::io; @@ -6,48 +6,57 @@ use std::sync::Arc; use bytes::Bytes; -use serde::Deserialize; -use serde::Deserializer; use serde::Serialize; use serde::Serializer; use serde::de::DeserializeOwned; -/// A message of type `T` paired with its cached serialization. +/// A stored message: a type-erased payload paired with its cached +/// serialization. /// -/// The cache avoids repeated roundtrips through serialization: a `Message` -/// always carries the exact CBOR bytes its `T` was encoded to or decoded -/// from. Cloning is cheap, because the serialized bytes are shared and the -/// message is enclosed in an `Arc`. +/// The payload is held as `Arc` — the caller's own +/// `Arc` allocation, unsized in place — so the tree and the gossip +/// sessions handle messages without being generic over the payload type: +/// they compile once, and only the thin typed facades at the crate's API +/// boundary name `T`. Construction goes through the typed constructors +/// ([`new`](Self::new), [`from_slice`](Self::from_slice), ...); reads at +/// the typed boundary go through the checked downcast +/// ([`arc`](Self::arc)). +/// +/// The cache avoids repeated roundtrips through serialization: a `Message` +/// always carries the exact CBOR bytes its payload was encoded to or +/// decoded from, and every identity-blind consumer — the wire encoders, +/// size accounting — reads the cached bytes, never the payload. Cloning is +/// cheap: both fields are shared handles. /// /// The payload encoding is CBOR (via [`ciborium`]): self-describing, so /// field and variant *names* are the wire contract — a decoder pairs fields /// by name, tolerating reordering — and no canonical encoding is required -/// of `T`, because payload bytes carry no identity (a leaf's identity is -/// its version). +/// of the payload type, because payload bytes carry no identity (a leaf's +/// identity is its version). /// /// # Panics /// -/// Every value of `T` must serialize: methods that serialize (`new`, -/// `from_arc`, `From`) panic if `T`'s [`serde::Serialize`] -/// implementation reports an error. Encoding runs into an in-memory -/// buffer and CBOR imposes no format-driven failures (any map key, any -/// nesting), so the only trigger is the implementation itself declining a -/// value — which this crate treats as a bug in `T`, exactly as `Ord`'s -/// totality is trusted. Types whose `Serialize` is data-dependently -/// fallible (for example `std::path::PathBuf`, which errors on non-UTF-8 -/// paths) violate that obligation and must not be used as message types. -pub struct Message { - message: Arc, +/// Every payload value must serialize: methods that serialize +/// ([`new`](Self::new), [`from_arc`](Self::from_arc)) panic if the +/// payload's [`serde::Serialize`] implementation reports an error. +/// Encoding runs into an in-memory buffer and CBOR imposes no +/// format-driven failures (any map key, any nesting), so the only trigger +/// is the implementation itself declining a value — which this crate +/// treats as a bug in the payload type, exactly as `Ord`'s totality is +/// trusted. Types whose `Serialize` is data-dependently fallible (for +/// example `std::path::PathBuf`, which errors on non-UTF-8 paths) violate +/// that obligation and must not be used as message types. +/// +/// The typed read panics on a payload type mismatch; see +/// [`arc`](Self::arc). +#[derive(Clone)] +pub struct Message { + message: Arc, serialized: Bytes, } -impl Clone for Message { - fn clone(&self) -> Self { - Self { - message: self.message.clone(), - serialized: self.serialized.clone(), - } - } -} +/// Deserializes one exact CBOR payload encoding into a type-erased payload +/// value; see [`Message::deserializer`]. +pub(crate) type PayloadDeserializer = fn(&[u8]) -> io::Result>; /// Map a ciborium deserialization failure into `io::Error`, keeping the /// truncation/corruption split callers classify by: a reader's own error @@ -73,16 +82,16 @@ fn to_vec(value: &T) -> Vec { buf } -impl Message { +impl Message { /// Creates a `Message` pairing the given object with its cached /// serialization. /// /// # Panics /// /// If the message cannot be serialized (see [`Message`]). - pub fn new(message: T) -> Self + pub fn new(message: T) -> Self where - T: Serialize, + T: Serialize + Send + Sync + 'static, { Message { serialized: Bytes::from(to_vec(&message)), @@ -91,14 +100,14 @@ impl Message { } /// Creates a `Message` pairing the given serialized bytes with the - /// object derived by deserializing them. + /// object derived by deserializing them as a `T`. /// /// The bytes must be exactly one CBOR value: trailing bytes are /// rejected as invalid data, so the cache is always the value's exact /// encoding. - pub fn from_slice(bytes: &[u8]) -> io::Result + pub fn from_slice(bytes: &[u8]) -> io::Result where - T: DeserializeOwned, + T: DeserializeOwned + Send + Sync + 'static, { let mut input = bytes; let message: T = ciborium::de::from_reader(&mut input).map_err(de_error)?; @@ -114,28 +123,56 @@ impl Message { }) } - /// Pairs an already-decoded object with the exact bytes it was decoded - /// from. + /// Decodes wire payload bytes into a `Message` through a + /// [`PayloadDeserializer`]: the one deserialization every gossip + /// ingress performs, with the deserializer carrying the payload type + /// the peer was constructed with. /// - /// The caller certifies the pairing: `serialized` must be exactly the - /// CBOR encoding `message` was parsed out of (the wire codec's record - /// parser upholds this — it hands over precisely the bytes its parse - /// consumed). - pub(crate) fn from_decoded(message: T, serialized: Bytes) -> Self { - Message { - message: Arc::new(message), - serialized, + /// The deserializer validates the bytes are exactly one CBOR value of + /// its type ([`from_slice`](Self::from_slice)'s contract), so the + /// cache is always the payload's exact encoding and a malformed + /// payload fails here, at the wire boundary. + pub(crate) fn from_wire(bytes: Bytes, deserializer: PayloadDeserializer) -> io::Result { + Ok(Message { + message: deserializer(&bytes)?, + serialized: bytes, + }) + } + + /// The deserializer for payloads of type `T`: what a + /// [`Peer`](crate::Peer) mints at construction and threads to every + /// session's wire ingress ([`from_wire`](Self::from_wire)). + /// + /// A plain function pointer, so everything that carries it stays + /// non-generic: the payload type's only residue in a running session. + pub(crate) fn deserializer() -> PayloadDeserializer + where + T: DeserializeOwned + Send + Sync + 'static, + { + fn deserialize( + bytes: &[u8], + ) -> io::Result> { + let mut input = bytes; + let message: T = ciborium::de::from_reader(&mut input).map_err(de_error)?; + if !input.is_empty() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("{} trailing bytes after the message payload", input.len()), + )); + } + Ok(Arc::new(message)) } + deserialize:: } /// Creates a `Message` from already-shared serialized bytes, without /// copying. /// - /// The bytes are deserialized to produce the paired object, under - /// [`from_slice`](Self::from_slice)'s exactly-one-value contract. - pub fn from_bytes(bytes: Bytes) -> io::Result + /// The bytes are deserialized as a `T` to produce the paired object, + /// under [`from_slice`](Self::from_slice)'s exactly-one-value contract. + pub fn from_bytes(bytes: Bytes) -> io::Result where - T: DeserializeOwned, + T: DeserializeOwned + Send + Sync + 'static, { let mut input = bytes.as_ref(); let message: T = ciborium::de::from_reader(&mut input).map_err(de_error)?; @@ -151,14 +188,15 @@ impl Message { }) } - /// Creates a `Message` from an existing [`Arc`], without copying. + /// Creates a `Message` from an existing [`Arc`], without copying: the + /// same allocation, unsized in place. /// /// # Panics /// /// If the message cannot be serialized (see [`Message`]). - pub fn from_arc(arc: Arc) -> Self + pub fn from_arc(arc: Arc) -> Self where - T: Serialize, + T: Serialize + Send + Sync + 'static, { Message { serialized: Bytes::from(to_vec(&*arc)), @@ -166,20 +204,38 @@ impl Message { } } - /// Returns a reference to the object represented by this message. - pub fn message(&self) -> &T { - &self.message + /// Reads one `Message` off a byte stream, consuming exactly its bytes. + /// + /// The shape is one CBOR byte string wrapping the payload's own CBOR + /// encoding (the same shape [`Serialize`] writes), decoded through + /// the peer's payload deserializer. Trailing data after the byte + /// string survives for the next field: the property the wire codec's + /// mid-stream decodes rest on. Gated to the alternating protocol's + /// codec, its only production consumer. + #[cfg(any(test, feature = "protocol-v1"))] + pub(crate) fn from_reader(reader: R, deserializer: PayloadDeserializer) -> io::Result + where + R: io::Read, + { + let bytes: Vec = ciborium::de::from_reader(reader).map_err(de_error)?; + Self::from_wire(Bytes::from(bytes), deserializer) } - /// Returns a reference to the shared [`Arc`] holding this message's - /// object, without cloning it. + /// Clones out an owned handle to the payload: a reference bump on the + /// same shared allocation. /// - /// Used by enumeration paths (e.g. [`Tree::iter`]) that hand out - /// borrowed `&Arc` exactly as the public observers do. + /// # Panics /// - /// [`Tree::iter`]: crate::tree::Tree::iter - pub fn as_arc(&self) -> &Arc { - &self.message + /// If the payload is not a `T`. A mismatch is always a crate bug, + /// never an input: every message reachable from a typed facade was + /// constructed with that facade's payload type — local sends through + /// the same `Peer`'s type, wire ingress through its typed decode — + /// so no gossip input can place a differently-typed payload here. + pub fn arc(&self) -> Arc { + self.message + .clone() + .downcast::() + .unwrap_or_else(|_| panic!("a message's payload type matches its tree's")) } /// Returns the serialized bytes corresponding to this message. @@ -191,110 +247,48 @@ impl Message { pub fn bytes(&self) -> &Bytes { &self.serialized } - - /// Consumes the message and returns the inner object, dropping the cached - /// serialization. - pub fn into_inner(self) -> Arc { - self.message - } - - /// Consumes the message and returns the inner object, dropping the cached - /// serialization and cloning the inner object if necessary. - pub fn clone_into_inner(self) -> T - where - T: Clone, - { - Arc::unwrap_or_clone(self.message) - } - - /// Consumes the message and returns the inner object along with the - /// shared serialized bytes. - pub fn into_parts(self) -> (Arc, Bytes) - where - T: Clone, - { - (self.message, self.serialized) - } -} - -impl From for Message { - /// Creates a `Message` pairing the given object with its cached - /// serialization. - /// - /// # Panics - /// - /// If the message cannot be serialized (see [`Message`]). - fn from(message: T) -> Self { - Self::new(message) - } -} - -impl AsRef for Message { - fn as_ref(&self) -> &T { - &self.message - } -} - -impl AsRef> for Message { - fn as_ref(&self) -> &Arc { - &self.message - } } -// Manual trait implementations that treat `Message` as a transparent wrapper -// around `T`, ignoring the cached serialized bytes. Two messages holding equal -// `T` values compare equal even if their cached bytes differ (e.g. produced by -// different serializer versions). - -impl fmt::Debug for Message { +/// Shows the cached serialization, not the payload: the payload's type is +/// erased here, so its own `Debug` is out of reach. +impl fmt::Debug for Message { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - self.message.fmt(f) + f.debug_struct("Message") + .field("serialized", &hex::encode(&self.serialized)) + .finish_non_exhaustive() } } -impl PartialEq for Message { +// Equality, and the `Hash` that must agree with it, compare the cached +// serialization: with the payload's type erased, its bytes are the whole +// observable content. Two messages built from the same value by the same +// constructor always carry equal bytes. + +impl PartialEq for Message { fn eq(&self, other: &Self) -> bool { - self.message == other.message + self.serialized == other.serialized } } -impl Eq for Message {} +impl Eq for Message {} -impl PartialOrd for Message { - fn partial_cmp(&self, other: &Self) -> Option { - self.message.partial_cmp(&other.message) - } -} - -impl Ord for Message { - fn cmp(&self, other: &Self) -> Ordering { - self.message.cmp(&other.message) - } -} - -impl Hash for Message { +impl Hash for Message { fn hash(&self, state: &mut H) { - self.message.hash(state); + self.serialized.hash(state); } } -// The serde form lets `Message` nest inside larger CBOR values without -// re-encoding: one byte string wrapping the cached CBOR payload. The -// wrapper is what makes a nested message self-delimiting wherever the -// container does not delimit it. - -impl Serialize for Message { +/// One CBOR byte string wrapping the cached CBOR payload, so a message +/// nests inside larger CBOR values without re-encoding. +/// +/// The wrapper is what makes a nested message self-delimiting wherever +/// the container does not delimit it; [`from_reader`](Message::from_reader) +/// is the typed decoder of the same shape. +impl Serialize for Message { fn serialize(&self, serializer: S) -> Result { serializer.serialize_bytes(&self.serialized) } } -impl<'de, T: DeserializeOwned> Deserialize<'de> for Message { - fn deserialize>(deserializer: D) -> Result { - let bytes = >::deserialize(deserializer)?; - Message::from_slice(&bytes).map_err(serde::de::Error::custom) - } -} - #[cfg(test)] mod tests; diff --git a/src/message/tests.rs b/src/message/tests.rs index 5d8451960..c857eb242 100644 --- a/src/message/tests.rs +++ b/src/message/tests.rs @@ -41,13 +41,13 @@ fn cbor_vec(value: &T) -> Vec { proptest! { /// After construction via `new`, the cached serialized bytes are exactly - /// the value's CBOR encoding. + /// the value's CBOR encoding, and the typed read recovers the value. #[test] fn new_caches_cbor_serialization(p in payload()) { let m = Message::new(p.clone()); let direct = cbor_vec(&p); prop_assert_eq!(m.bytes(), direct.as_slice()); - prop_assert_eq!(m.message(), &p); + prop_assert_eq!(&*m.arc::(), &p); } /// `from_slice` reconstructs the inner value and stores exactly the input @@ -55,8 +55,8 @@ proptest! { #[test] fn from_slice_roundtrips(p in payload()) { let bytes = cbor_vec(&p); - let m = Message::::from_slice(&bytes).unwrap(); - prop_assert_eq!(m.message(), &p); + let m = Message::from_slice::(&bytes).unwrap(); + prop_assert_eq!(&*m.arc::(), &p); prop_assert_eq!(m.bytes(), bytes.as_slice()); } @@ -65,8 +65,8 @@ proptest! { #[test] fn from_bytes_matches_from_slice(p in payload()) { let bytes = cbor_vec(&p); - let a = Message::::from_slice(&bytes).unwrap(); - let b = Message::::from_bytes(Bytes::from(bytes.clone())).unwrap(); + let a = Message::from_slice::(&bytes).unwrap(); + let b = Message::from_bytes::(Bytes::from(bytes.clone())).unwrap(); prop_assert_eq!(&a, &b); prop_assert_eq!(a.bytes(), b.bytes()); } @@ -77,13 +77,13 @@ proptest! { fn trailing_bytes_are_rejected(p in payload(), trailer in proptest::collection::vec(any::(), 1..8)) { let mut bytes = cbor_vec(&p); bytes.extend_from_slice(&trailer); - prop_assert!(Message::::from_slice(&bytes).is_err()); - prop_assert!(Message::::from_bytes(Bytes::from(bytes)).is_err()); + prop_assert!(Message::from_slice::(&bytes).is_err()); + prop_assert!(Message::from_bytes::(Bytes::from(bytes)).is_err()); } - /// The serde form of a `Message` is one CBOR byte string wrapping - /// the cached payload bytes — never a re-encoding of `T` — so nesting - /// a message in a larger CBOR value costs one length header. + /// The serde form of a `Message` is one CBOR byte string wrapping + /// the cached payload bytes — never a re-encoding of the payload — so + /// nesting a message in a larger CBOR value costs one length header. #[test] fn serde_form_wraps_cached_bytes(p in payload()) { struct Bstr<'a>(&'a [u8]); @@ -98,32 +98,17 @@ proptest! { prop_assert_eq!(wrapped, direct); } - /// A `Message` roundtrips through its serde form: deserializing a + /// A `Message` roundtrips through its serde form: `from_reader` on a /// serialized message yields an equal message with equal cached bytes. #[test] fn serde_roundtrip(p in payload()) { let m = Message::new(p); let bytes = cbor_vec(&m); - let back: Message = ciborium::de::from_reader(bytes.as_slice()).unwrap(); + let back = Message::from_reader(bytes.as_slice(), Message::deserializer::()).unwrap(); prop_assert_eq!(&m, &back); prop_assert_eq!(m.bytes(), back.bytes()); } - /// `Message` nests correctly inside other CBOR containers: a - /// `Vec>` roundtrips and preserves each element's cached - /// bytes. - #[test] - fn nested_in_vec_roundtrips(ps in proptest::collection::vec(payload(), 0..8)) { - let msgs: Vec> = - ps.into_iter().map(Message::new).collect(); - let bytes = cbor_vec(&msgs); - let back: Vec> = ciborium::de::from_reader(bytes.as_slice()).unwrap(); - prop_assert_eq!(&msgs, &back); - for (a, b) in msgs.iter().zip(back.iter()) { - prop_assert_eq!(a.bytes(), b.bytes()); - } - } - /// Reading a message off a stream consumes exactly the message's own /// bytes: trailing data after the CBOR value survives for the next /// field (the property the wire codec's mid-stream decodes rest on). @@ -135,13 +120,13 @@ proptest! { combined.extend_from_slice(&trailer); let mut slice: &[u8] = &combined; - let back: Message = ciborium::de::from_reader(&mut slice).unwrap(); + let back = Message::from_reader(&mut slice, Message::deserializer::()).unwrap(); prop_assert_eq!(back.bytes(), m.bytes()); prop_assert_eq!(slice, trailer.as_slice()); prop_assert_eq!(combined.len() - slice.len(), expected.len()); } - /// Equal `Message` values hash identically, so `Hash` agrees with + /// Equal `Message`s hash identically, so `Hash` agrees with /// `PartialEq` as required by the standard library contract. #[test] fn eq_implies_hash_eq(p in payload()) { @@ -151,14 +136,21 @@ proptest! { prop_assert_eq!(hash_of(&a), hash_of(&b)); } - /// `into_parts` returns exactly the inner value and cached bytes, matching - /// what `message()` and `bytes()` would have returned. + /// `arc` hands out the same shared allocation `new` stored, not a + /// copy: unsizing erased the type, never the identity. #[test] - fn into_parts_matches_accessors(p in payload()) { - let m = Message::new(p.clone()); - let expected_bytes = m.bytes().to_vec(); - let (inner, bytes) = m.into_parts(); - prop_assert_eq!(&*inner, &p); - prop_assert_eq!(bytes.as_ref(), expected_bytes.as_slice()); + fn arc_shares_the_stored_allocation(p in payload()) { + let stored = std::sync::Arc::new(p); + let m = Message::from_arc(stored.clone()); + prop_assert!(std::sync::Arc::ptr_eq(&stored, &m.arc::())); } } + +/// A typed read with the wrong payload type panics: the mispairing is a +/// crate bug, and the downcast is the tripwire that catches it. +#[test] +#[should_panic(expected = "payload type matches")] +fn mismatched_downcast_panics() { + let m = Message::new(0u64); + let _ = m.arc::(); +} diff --git a/src/peer.rs b/src/peer.rs index ed0e2b2ab..f5270c49d 100644 --- a/src/peer.rs +++ b/src/peer.rs @@ -11,6 +11,7 @@ use tokio::sync::{Mutex, watch}; use crate::bookmark::{BookmarkError, Bookmarked, NoBookmark}; use crate::link::{Acceptor, Connector, Link}; +use crate::message::{Message, PayloadDeserializer}; use crate::tree::Tree; pub use crate::tree::mirror::streaming::remote::DEFAULT_TARGET_MESSAGE_SIZE; use crate::tree::mirror::streaming::remote::RunBudget; @@ -157,6 +158,10 @@ pub struct Peer { /// Separate from `inner` because persisting is `async` and the record is /// `!Clone`; see [`Bookmarked`]. pub(crate) bookmark: Arc>>, + /// The payload deserializer minted at construction: the typed ingress + /// every gossip session's supplied leaf records decode through (see + /// [`Message::deserializer`](crate::message::Message::deserializer)). + pub(crate) deserializer: PayloadDeserializer, } /// The replica's shared mutable state, behind the `watch` channel every @@ -184,11 +189,16 @@ impl std::fmt::Debug for Peer { } } -impl Peer { +impl Peer { /// Create the distinguished seed rumor set: the single root from which /// every other participant must [`bootstrap`](Peer::bootstrap). /// /// Call this exactly once per universe of cooperating peers. + /// + /// The payload type's [`DeserializeOwned`] lives here, at + /// construction: the peer mints its payload deserializer once, and + /// every gossip session decodes through it, so the gossip entry + /// points themselves carry no serde bounds. pub fn seed() -> Self { Self::seed_rng(&mut OsRng) } @@ -207,6 +217,7 @@ impl Peer { tree: Tree::new(), }), bookmark: Arc::new(Mutex::new(Bookmarked::new(NoBookmark))), + deserializer: Message::deserializer::(), } } } @@ -275,7 +286,7 @@ impl Peer { /// promises](crate::link::Link#what-a-session-promises). pub async fn retire(self, link: &mut Link) -> Retire where - T: DeserializeOwned + Serialize + Send + Sync + 'static, + T: Send + Sync + 'static, CR: AsyncRead + Unpin + Send, CW: AsyncWrite + Unpin + Send, C: Connector, @@ -544,7 +555,7 @@ impl Peer { pub(crate) fn send(&self, message: T) -> Batch<'_, T> where - T: Serialize + Send + Sync, + T: Serialize + Send + Sync + 'static, { let mut batch = self.batch(); batch.send(message); diff --git a/src/peer/bootstrap.rs b/src/peer/bootstrap.rs index 46f33746c..da574953c 100644 --- a/src/peer/bootstrap.rs +++ b/src/peer/bootstrap.rs @@ -14,7 +14,6 @@ use crate::{Error, Peer, Protocol}; use super::gossip::Unbookmarked; -use serde::Serialize; use serde::de::DeserializeOwned; /// Configuration for joining an existing universe: the builder behind /// [`Peer::bootstrap`]. @@ -217,7 +216,7 @@ impl Bootstrap { link: &mut Link, ) -> Result>, Error> where - T: DeserializeOwned + Serialize + Send + Sync + 'static, + T: DeserializeOwned + Send + Sync + 'static, CR: AsyncRead + Unpin + Send, CW: AsyncWrite + Unpin + Send, C: Connector, @@ -297,7 +296,7 @@ impl BookmarkedBootstrap { /// in every outcome that never used it. pub async fn join(self, link: &mut Link) -> Joined where - T: DeserializeOwned + Serialize + Send + Sync + 'static, + T: DeserializeOwned + Send + Sync + 'static, CR: AsyncRead + Unpin + Send, CW: AsyncWrite + Unpin + Send, C: Connector, diff --git a/src/peer/gossip.rs b/src/peer/gossip.rs index 6ec7c3c98..1194055ef 100644 --- a/src/peer/gossip.rs +++ b/src/peer/gossip.rs @@ -8,7 +8,7 @@ use std::pin::Pin; use std::sync::Arc; -use before::Party; +use before::{Party, Ticks}; use futures::{Stream, future::BoxFuture}; use futures_util::StreamExt; use tokio::{ @@ -20,6 +20,7 @@ use crate::link::{ Acceptor, Connector, Link, SessionState, erased::{DynAcceptor, DynConnector}, }; +use crate::message::{Message, PayloadDeserializer}; #[cfg(any(test, feature = "protocol-v1"))] use crate::tree::mirror::{ alternating::{self, local as alternating_local, remote as alternating_remote}, @@ -33,15 +34,16 @@ use crate::{ handshake::{self, Intent}, party, streaming::{ - self, Local, materialized, remote as streaming_remote, + self, Local, materialized, + remote::{self as streaming_remote, RunBudget}, stats::{Recorder, SessionStats}, + window::WindowConfig, }, }, }; use super::{Inner, Peer, bootstrap::Bootstrap}; -use serde::Serialize; use serde::de::DeserializeOwned; /// Magic bytes that open every `rumors` gossip session's preamble frame. pub const PROTOCOL_MAGIC: [u8; 6] = *b"RUMORS"; @@ -66,7 +68,9 @@ const EPILOGUE_MARKER: u8 = b'.'; /// instantiation would otherwise re-instantiate both towers — and, because /// generic code monomorphizes in the crate that supplies the concrete types, /// it would do so once per downstream binary per instantiation. Erasing here -/// caps that at one instantiation per payload type. The price is one vtable +/// — with the protocol bodies behind the non-generic [`Reconciliation`] and +/// bootstrap drivers — caps that at one instantiation, compiled once into +/// this crate. The price is one vtable /// call per stream open/accept and per `poll_read`/`poll_write` beneath the /// framing layers, which buffer whole frames on both sides. type DynRead<'a> = &'a mut (dyn AsyncRead + Unpin + Send + 'a); @@ -211,7 +215,7 @@ impl Peer { link: &'a mut Link, ) -> BoxFuture<'a, Result, Error>> where - T: DeserializeOwned + Serialize + Send + Sync + 'static, + T: DeserializeOwned + Send + Sync + 'static, CR: AsyncRead + Unpin + Send, CW: AsyncWrite + Unpin + Send, C: Connector, @@ -238,10 +242,14 @@ impl Peer { link: DynLinkParts<'a>, ) -> BoxFuture<'a, Result, Error>> where - T: DeserializeOwned + Serialize + Send + Sync + 'static, + T: DeserializeOwned + Send + Sync + 'static, { Box::pin(async move { let (read, write, connector, acceptor, epoch) = link; + // The peer-to-be's payload deserializer, minted before it + // exists: the bootstrap session's ingress decodes through it, + // and the constructed peer inherits it. + let deserializer = Message::deserializer::(); // Magic/version/network/intent preamble first, before either protocol // is allowed to trust peer-declared frame lengths. let mut staged = handshake::Staged::new(); @@ -264,72 +272,20 @@ impl Peer { // Reconcile from an empty tree using the selected wire protocol. Both // branches return the same lifecycle boundary: a materialized root and // the raw control halves positioned at the trailing party frame. - // `BoxFuture` is the compile-time boundary: `Box::pin` alone would - // allocate the state while still exposing its enormous concrete type. - #[allow(clippy::type_complexity)] - let reconcile: BoxFuture< - '_, - Result, DynRead<'a>, DynWrite<'a>)>, Error>, - > = match config.protocol { - Protocol::V2 => Box::pin(async move { - let local_root: streaming::Root = tree::Root::default().into(); - // The window choice is passed for uniformity with gossip, - // but no choice can widen this session: disputes require - // joint occupancy and this side's replica is empty, so - // every derived capacity floors at one slot regardless. - // The message-size target is the operative knob: the - // greeting advertises it, and the provider's supply runs - // are built at the exchanged minimum. - let local = materialized::Handshaking::start(Local, local_root) - .window(config.window) - .target_message_size(config.run_budget.bytes() as u64); - let carrier = Link::for_session(read, write, connector, acceptor, epoch); - let proxy = - streaming_remote::Handshaking::start(Local, carrier).window(config.window); - let handshaken = streaming::handshake(local, proxy) - .await - .map_err(streaming_error)?; - // A counterparty that is itself bootstrapping has nothing - // to hand us, but the session still ends with the - // epilogue. Both trees are empty, so the versions are - // equal and `reconcile` resolves to the untouched control - // halves without opening a data stream; the marker - // exchange then certifies the mutual bail to both sides. - // The equal-version resolution is itself guarded: a - // fellow claimant must be as newborn as we are. - let both_bootstrapping = remote.network.is_bootstrap(); - if both_bootstrapping { - bootstrap_claimant_is_newborn(&handshaken.peer().version)?; - } - let descent: BoxFuture<'_, _> = Box::pin(handshaken.reconcile()); - let (root, (mut read, mut write)) = descent.await.map_err(streaming_error)?; - if both_bootstrapping { - epilogue(&mut read, &mut write).await?; - return Ok(None); - } - Ok(Some((root.into(), read, write))) - }), + // The protocol bodies are the non-generic [`bootstrap_v2`] and + // [`bootstrap_v1`], which return their futures boxed; see + // [`Reconciliation::v2`] for the boxing and inlining discipline. + let both_bootstrapping = remote.network.is_bootstrap(); + let reconcile = match config.protocol { + Protocol::V2 => bootstrap_v2( + (read, write, connector, acceptor, epoch), + deserializer, + config.window, + config.run_budget, + both_bootstrapping, + ), #[cfg(any(test, feature = "protocol-v1"))] - Protocol::V1 => Box::pin(async move { - let local = alternating_local::Exchange::start(tree::Root::default()); - let proxy = alternating_remote::Exchange::start( - FrameRead::new(read), - FrameWrite::new(write), - ); - let handshaken = alternating::handshake(local, proxy) - .await - .map_err(alternating_error)?; - // The frozen V1 wire has no epilogue: a mutual bootstrap - // bails right here, exactly as V1 always has — once the - // fellow claimant proves as newborn as we are. - if remote.network.is_bootstrap() { - bootstrap_claimant_is_newborn(&handshaken.peer().version)?; - return Ok(None); - } - let descent: BoxFuture<'_, _> = Box::pin(handshaken.reconcile()); - let (root, (read, write)) = descent.await.map_err(alternating_error)?; - Ok(Some((root, read.into_inner(), write.into_inner()))) - }), + Protocol::V1 => bootstrap_v1(read, write, deserializer, both_bootstrapping), }; let Some((root, mut read, mut write)) = reconcile.await? else { return Ok(None); @@ -351,9 +307,10 @@ impl Peer { run_budget: config.run_budget, inner: watch::Sender::new(Inner { party: Some(party), - tree: Tree { root }, + tree: Tree::from_root(root), }), bookmark: Arc::new(Mutex::new(Bookmarked::new(NoBookmark))), + deserializer, }; Ok(Some(peer)) }) @@ -370,6 +327,7 @@ impl Peer { window, run_budget, inner, + deserializer, .. } = self; let peer = Peer { @@ -379,6 +337,7 @@ impl Peer { run_budget, inner, bookmark: Arc::new(Mutex::new(Bookmarked::new(bookmark))), + deserializer, }; // A pristine seed has no identity worth recording yet; persisting it @@ -407,6 +366,7 @@ impl Peer { run_budget: peer.run_budget, inner: peer.inner, bookmark: Arc::new(Mutex::new(Bookmarked::new(NoBookmark))), + deserializer: peer.deserializer, }, error, }), @@ -437,7 +397,7 @@ impl Peer { link: &mut Link, ) -> Retire where - T: DeserializeOwned + Serialize + Send + Sync + 'static, + T: Send + Sync + 'static, CR: AsyncRead + Unpin + Send, CW: AsyncWrite + Unpin + Send, C: Connector, @@ -476,7 +436,7 @@ impl Peer { link: &mut Link, ) -> Result> where - T: DeserializeOwned + Serialize + Send + Sync + 'static, + T: Send + Sync + 'static, CR: AsyncRead + Unpin + Send, CW: AsyncWrite + Unpin + Send, C: Connector, @@ -606,9 +566,10 @@ impl Peer { /// /// [`gossip_when`]: crate::Rumors::gossip_when /// - /// Takes the link pre-erased ([`DynLinkParts`]): every generic caller funnels - /// through here, so the protocol towers this drives instantiate once per - /// payload type, not once per link instantiation. + /// Takes the link pre-erased ([`DynLinkParts`]): every generic caller + /// funnels through here, and the reconciliation itself runs behind the + /// non-generic [`Reconciliation`], so the protocol towers it drives + /// codegen exactly once, in this crate. async fn gossip_inner<'a>( &self, intent: Intent, @@ -616,9 +577,10 @@ impl Peer { link: DynLinkParts<'a>, ) -> (Intent, Result<(Version, SessionStats), Error>) where - T: DeserializeOwned + Serialize + Send + Sync + 'static, + T: Send + Sync + 'static, { let (read, write, connector, acceptor, epoch) = link; + let deserializer = self.deserializer; // The session's stats recorder: under V2, both protocol // participants below share it (the walk counts disputes, gains, // sheds, and the window grant; the proxy's codec seam counts @@ -732,65 +694,26 @@ impl Peer { // Reconcile using this peer's selected protocol. Both branches meet at // the lifecycle boundary the surrounding transaction needs: a local // root plus raw transport halves positioned after reconciliation. - // The explicit `BoxFuture` coercion prevents either concrete protocol - // state machine from becoming part of this outer session future. - let network = self.network; - let window = self.window; - let run_budget = self.run_budget; - let session_stats = stats.clone(); - #[allow(clippy::type_complexity)] - let reconcile: BoxFuture< - '_, - Result<(tree::Root, DynRead<'a>, DynWrite<'a>), Error>, - > = match self.protocol { - Protocol::V2 => Box::pin(async move { - let local = materialized::Handshaking::start(Local, prior_tree.root.into()) - .window(window) - .target_message_size(run_budget.bytes() as u64) - .stats(session_stats.clone()); - let carrier = Link::for_session(read, write, connector, acceptor, epoch); - let proxy = streaming_remote::Handshaking::start(Local, carrier) - .window(window) - .stats(session_stats); - let handshaken = streaming::handshake(local, proxy) - .await - .map_err(streaming_error)?; - if peer_bootstrapping { - bootstrap_claimant_is_newborn(&handshaken.peer().version)?; - } else if remote.network != network { - return Err(Error::NetworkMismatch { - remote_network: remote.network, - remote_min_events: handshaken.peer().version.min_ticks(), - local_min_events, - }); - } - let descent: BoxFuture<'_, _> = Box::pin(handshaken.reconcile()); - let (root, (read, write)) = descent.await.map_err(streaming_error)?; - Ok((root.into(), read, write)) - }), + // The protocol bodies live behind the non-generic [`Reconciliation`], + // whose methods return their futures boxed: neither concrete + // protocol state machine becomes part of this outer session future, + // or of the consumer crate that instantiates it. + let reconciliation = Reconciliation { + root: prior_tree.root, + link: (read, write, connector, acceptor, epoch), + deserializer, + window: self.window, + run_budget: self.run_budget, + stats: stats.clone(), + peer_bootstrapping, + remote_network: remote.network, + network: self.network, + local_min_events, + }; + let reconcile = match self.protocol { + Protocol::V2 => reconciliation.v2(), #[cfg(any(test, feature = "protocol-v1"))] - Protocol::V1 => Box::pin(async move { - let local = alternating_local::Exchange::start(prior_tree.root); - let proxy = alternating_remote::Exchange::start( - FrameRead::new(read), - FrameWrite::new(write), - ); - let handshaken = alternating::handshake(local, proxy) - .await - .map_err(alternating_error)?; - if peer_bootstrapping { - bootstrap_claimant_is_newborn(&handshaken.peer().version)?; - } else if remote.network != network { - return Err(Error::NetworkMismatch { - remote_network: remote.network, - remote_min_events: handshaken.peer().version.min_ticks(), - local_min_events, - }); - } - let descent: BoxFuture<'_, _> = Box::pin(handshaken.reconcile()); - let (root, (read, write)) = descent.await.map_err(alternating_error)?; - Ok((root, read.into_inner(), write.into_inner())) - }), + Protocol::V1 => reconciliation.v1(), }; let (root, read, write) = match reconcile.await { Ok(reconciled) => reconciled, @@ -861,7 +784,7 @@ impl Peer { // The reconciled tree's frontier is the converged version: what both // replicas hold the instant this commits, *before* the join below // mixes in any commits that ran concurrently with the session. - let merged = Tree { root }; + let merged = Tree::from_root(root); let converged = merged.latest().clone(); let mut party_overlap = false; self.inner.send_if_modified(|inner| { @@ -964,7 +887,7 @@ impl Peer { link: &'a mut Link, ) -> impl Stream>> + Unpin + 'a where - T: DeserializeOwned + Serialize + Send + Sync + 'static, + T: Send + Sync + 'static, CR: AsyncRead + Unpin + Send, CW: AsyncWrite + Unpin + Send, C: Connector, @@ -1109,6 +1032,237 @@ impl Peer { } } +/// One gossip reconciliation's inputs, fully erased. +/// +/// [`Peer::gossip_inner`] assembles this and immediately consumes it through +/// [`v2`](Self::v2) or [`v1`](Self::v1). The struct exists so those bodies +/// are non-generic: `gossip_inner` is generic over the payload and bookmark +/// types, and a reconciliation written inline there would monomorphize the +/// entire protocol tower it drives into every consumer crate, once per +/// instantiation. Behind this boundary the towers codegen exactly once, into +/// this crate's own object code. +struct Reconciliation<'a> { + /// The local replica's root, snapshotted inside the session transaction's + /// critical section: exactly what the local participant reconciles from. + root: tree::Root, + /// The session's erased link. + link: DynLinkParts<'a>, + /// The peer's payload deserializer, applied at wire ingress. + deserializer: PayloadDeserializer, + /// The window policy the V2 session negotiates under. + window: WindowConfig, + /// The V2 supply-run sizing budget; its byte target rides the greeting. + run_budget: RunBudget, + /// The session's stats recorder, shared by both V2 participants. + stats: Recorder, + /// Whether the remote's preamble declared it a bootstrap claimant. + peer_bootstrapping: bool, + /// The network the remote's preamble declared. + remote_network: Network, + /// The local network, which a non-bootstrapping remote must match. + network: Network, + /// The local greeting frontier's event floor, for the mismatch report. + local_min_events: Ticks, +} + +impl<'a> Reconciliation<'a> { + /// Drive one V2 (streaming) reconciliation to the lifecycle boundary the + /// session transaction resumes from: the reconciled local root plus the + /// raw control halves, positioned after the descent. + /// + /// Returns the future boxed: an `async fn` body is codegen'd into + /// whichever crate polls it, so a bare future here would hand the whole + /// protocol state machine right back to every consumer. The `dyn` + /// coercion pins it — vtable, poll, and everything the body awaits — in + /// this crate's own object code. `inline(never)` guards the same + /// boundary in optimized builds: the shell is small enough for rustc's + /// automatic cross-crate MIR inlining, which would move the coercion — + /// and the tower behind it — back into the consumer. + #[inline(never)] + fn v2(self) -> BoxFuture<'a, Result<(tree::Root, DynRead<'a>, DynWrite<'a>), Error>> { + Box::pin(async move { + let Self { + root, + link, + deserializer, + window, + run_budget, + stats, + peer_bootstrapping, + remote_network, + network, + local_min_events, + } = self; + let (read, write, connector, acceptor, epoch) = link; + let local = materialized::Handshaking::<_, _>::start(Local, root.into()) + .window(window) + .target_message_size(run_budget.bytes() as u64) + .stats(stats.clone()); + let carrier = Link::for_session(read, write, connector, acceptor, epoch); + let proxy = streaming_remote::Handshaking::start(Local, carrier, deserializer) + .window(window) + .stats(stats); + let handshaken = streaming::handshake(local, proxy) + .await + .map_err(streaming_error)?; + if peer_bootstrapping { + bootstrap_claimant_is_newborn(&handshaken.peer().version)?; + } else if remote_network != network { + return Err(Error::NetworkMismatch { + remote_network, + remote_min_events: handshaken.peer().version.min_ticks(), + local_min_events, + }); + } + let descent: BoxFuture<'_, _> = Box::pin(handshaken.reconcile()); + let (root, (read, write)) = descent.await.map_err(streaming_error)?; + Ok((root.into(), read, write)) + }) + } + + /// Drive one V1 (alternating) reconciliation to the same lifecycle + /// boundary as [`v2`](Self::v2), boxed and `inline(never)` for the same + /// reasons. + /// + /// The frozen V1 wire has no window, budget, or stats vocabulary, and + /// its proxy runs on the control halves alone, so those inputs are + /// dropped unread. + #[cfg(any(test, feature = "protocol-v1"))] + #[inline(never)] + fn v1(self) -> BoxFuture<'a, Result<(tree::Root, DynRead<'a>, DynWrite<'a>), Error>> { + Box::pin(async move { + let Self { + root, + link, + deserializer, + peer_bootstrapping, + remote_network, + network, + local_min_events, + .. + } = self; + let (read, write, ..) = link; + let local = alternating_local::Exchange::start(root); + let proxy = alternating_remote::Exchange::start( + FrameRead::new(read), + FrameWrite::new(write), + deserializer, + ); + let handshaken = alternating::handshake(local, proxy) + .await + .map_err(alternating_error)?; + if peer_bootstrapping { + bootstrap_claimant_is_newborn(&handshaken.peer().version)?; + } else if remote_network != network { + return Err(Error::NetworkMismatch { + remote_network, + remote_min_events: handshaken.peer().version.min_ticks(), + local_min_events, + }); + } + let descent: BoxFuture<'_, _> = Box::pin(handshaken.reconcile()); + let (root, (read, write)) = descent.await.map_err(alternating_error)?; + Ok((root, read.into_inner(), write.into_inner())) + }) + } +} + +/// Drive one V2 bootstrap reconciliation from an empty local replica. +/// +/// Free and non-generic for the same reason [`Reconciliation`] is: the +/// generic [`Peer::bootstrap_erased`] shell funnels through here, so the +/// protocol tower codegens once, in this crate. +/// +/// `Ok(None)` is the mutual-bootstrap bail: the counterparty is itself +/// bootstrapping, so there is no donation to receive, and the epilogue has +/// already been exchanged. `Ok(Some(..))` hands back the reconciled root and +/// the control halves positioned at the trailing party frame. +/// +/// Boxed and `inline(never)` for [`Reconciliation::v2`]'s reasons: the `dyn` +/// coercion is what pins the protocol state machine in this crate. +#[inline(never)] +#[allow(clippy::type_complexity)] +fn bootstrap_v2<'a>( + link: DynLinkParts<'a>, + deserializer: PayloadDeserializer, + window: WindowConfig, + run_budget: RunBudget, + both_bootstrapping: bool, +) -> BoxFuture<'a, Result, DynWrite<'a>)>, Error>> { + Box::pin(async move { + let (read, write, connector, acceptor, epoch) = link; + let local_root: streaming::Root = tree::Root::default().into(); + // The window choice is passed for uniformity with gossip, but no + // choice can widen this session: disputes require joint occupancy + // and this side's replica is empty, so every derived capacity floors + // at one slot regardless. The message-size target is the operative + // knob: the greeting advertises it, and the provider's supply runs + // are built at the exchanged minimum. + let local = materialized::Handshaking::start(Local, local_root) + .window(window) + .target_message_size(run_budget.bytes() as u64); + let carrier = Link::for_session(read, write, connector, acceptor, epoch); + let proxy = + streaming_remote::Handshaking::start(Local, carrier, deserializer).window(window); + let handshaken = streaming::handshake(local, proxy) + .await + .map_err(streaming_error)?; + // A counterparty that is itself bootstrapping has nothing to hand + // us, but the session still ends with the epilogue. Both trees are + // empty, so the versions are equal and `reconcile` resolves to the + // untouched control halves without opening a data stream; the marker + // exchange then certifies the mutual bail to both sides. The + // equal-version resolution is itself guarded: a fellow claimant must + // be as newborn as we are. + if both_bootstrapping { + bootstrap_claimant_is_newborn(&handshaken.peer().version)?; + } + let descent: BoxFuture<'_, _> = Box::pin(handshaken.reconcile()); + let (root, (mut read, mut write)) = descent.await.map_err(streaming_error)?; + if both_bootstrapping { + epilogue(&mut read, &mut write).await?; + return Ok(None); + } + Ok(Some((root.into(), read, write))) + }) +} + +/// Drive one V1 bootstrap reconciliation from an empty local replica; see +/// [`bootstrap_v2`] for the lifecycle boundary and the boxing and inlining +/// discipline. +/// +/// The frozen V1 wire has no epilogue: a mutual bootstrap (`Ok(None)`) +/// bails right after the handshake, exactly as V1 always has — once the +/// fellow claimant proves as newborn as we are. +#[cfg(any(test, feature = "protocol-v1"))] +#[inline(never)] +#[allow(clippy::type_complexity)] +fn bootstrap_v1<'a>( + read: DynRead<'a>, + write: DynWrite<'a>, + deserializer: PayloadDeserializer, + both_bootstrapping: bool, +) -> BoxFuture<'a, Result, DynWrite<'a>)>, Error>> { + Box::pin(async move { + let local = alternating_local::Exchange::start(tree::Root::default()); + let proxy = alternating_remote::Exchange::start( + FrameRead::new(read), + FrameWrite::new(write), + deserializer, + ); + let handshaken = alternating::handshake(local, proxy) + .await + .map_err(alternating_error)?; + if both_bootstrapping { + bootstrap_claimant_is_newborn(&handshaken.peer().version)?; + return Ok(None); + } + let descent: BoxFuture<'_, _> = Box::pin(handshaken.reconcile()); + let (root, (read, write)) = descent.await.map_err(alternating_error)?; + Ok(Some((root, read.into_inner(), write.into_inner()))) + }) +} + /// Erase a caller's link into one session's [`DynLinkParts`], opening the /// session on the link's [`SessionState`]: each call is exactly one session. /// diff --git a/src/peer/gossip/tests.rs b/src/peer/gossip/tests.rs index 9d9b6dc08..5cc72fed0 100644 --- a/src/peer/gossip/tests.rs +++ b/src/peer/gossip/tests.rs @@ -30,6 +30,7 @@ //! [`gossip_inner`]: super::Peer::gossip_inner //! [`Network::BOOTSTRAP`]: Network +use crate::message::Message; use before::Party; use futures::future::BoxFuture; use tokio::io::{duplex, split}; @@ -143,7 +144,7 @@ fn bytes_after_the_marker_stay_untouched() { /// the ceiling and leaves no tombstone, so committing and then redacting /// `events` messages leaves an empty root carrying a genuine `events`-tick /// version. -fn redacted_history_root(events: u64) -> tree::Root { +fn redacted_history_root(events: u64) -> tree::Root { let donor = Peer::::seed(); { let mut batch = donor.batch(); @@ -180,7 +181,7 @@ fn redacted_history_root(events: u64) -> tree::Root { /// tree if the counterparty serves the session to completion. async fn claim_bootstrap_v2( link: &mut MemoryLink, - root: tree::Root, + root: tree::Root, ) -> Result<(Party, Tree), Error> { let (read, write, connector, acceptor, epoch) = erase(link)?; let mut staged = handshake::Staged::new(); @@ -194,10 +195,11 @@ async fn claim_bootstrap_v2( ) .await .map_err(Error::from)?; - let local_root: streaming::Root = root.into(); + let local_root: streaming::Root = root.into(); let local = materialized::Handshaking::start(Local, local_root); let carrier = Link::for_session(read, write, connector, acceptor, epoch); - let proxy = streaming_remote::Handshaking::start(Local, carrier); + let proxy = + streaming_remote::Handshaking::start(Local, carrier, Message::deserializer::()); let handshaken = streaming::handshake(local, proxy) .await .map_err(streaming_error)?; @@ -205,7 +207,7 @@ async fn claim_bootstrap_v2( let (root, (mut read, mut write)) = descent.await.map_err(streaming_error)?; let party = party::receive(&mut read).await?; epilogue(&mut read, &mut write).await?; - Ok((party, Tree { root: root.into() })) + Ok((party, Tree::from_root(root.into()))) } /// Drive one V1 session as a bootstrap claimant whose greeting version comes @@ -215,7 +217,7 @@ async fn claim_bootstrap_v2( /// no epilogue. async fn claim_bootstrap_v1( link: &mut MemoryLink, - root: tree::Root, + root: tree::Root, ) -> Result<(Party, Tree), Error> { let (read, write, _connector, _acceptor, _epoch) = erase(link)?; let mut staged = handshake::Staged::new(); @@ -230,7 +232,11 @@ async fn claim_bootstrap_v1( .await .map_err(Error::from)?; let local = alternating_local::Exchange::start(root); - let proxy = alternating_remote::Exchange::start(FrameRead::new(read), FrameWrite::new(write)); + let proxy = alternating_remote::Exchange::start( + FrameRead::new(read), + FrameWrite::new(write), + Message::deserializer::(), + ); let handshaken = alternating::handshake(local, proxy) .await .map_err(alternating_error)?; @@ -238,7 +244,7 @@ async fn claim_bootstrap_v1( let (root, (read, _write)) = descent.await.map_err(alternating_error)?; let mut read = read.into_inner(); let party = party::receive(&mut read).await?; - Ok((party, Tree { root })) + Ok((party, Tree::from_root(root))) } /// A provider holding `values`, plus its pre-session root hash. @@ -282,9 +288,7 @@ fn v2_bootstrap_claimant_declaring_history_is_rejected() { let provider = provider_with(&[1, 2, 3]); let hash_before = provider.snapshot().hash(); let party_before = party_of(&provider); - let claimant_tree = Tree { - root: redacted_history_root(8), - }; + let claimant_tree = Tree::<()>::from_root(redacted_history_root(8)); let claimed_min_events = claimant_tree.latest().min_ticks(); assert!( provider.snapshot().latest() < claimant_tree.latest(), @@ -347,9 +351,7 @@ fn v1_bootstrap_claimant_declaring_history_is_rejected() { let provider = provider_with(&[1, 2, 3]).protocol(Protocol::V1); let hash_before = provider.snapshot().hash(); let party_before = party_of(&provider); - let claimant_tree = Tree { - root: redacted_history_root(8), - }; + let claimant_tree = Tree::<()>::from_root(redacted_history_root(8)); let claimed_min_events = claimant_tree.latest().min_ticks(); let provider_ref = &provider; diff --git a/src/rumors.rs b/src/rumors.rs index a2a4fabea..504fea473 100644 --- a/src/rumors.rs +++ b/src/rumors.rs @@ -18,7 +18,6 @@ use tokio::{ }; use serde::Serialize; -use serde::de::DeserializeOwned; /// A handle for [`send`](Rumors::send)ing and [`redact`](Rumors::redact)ing /// messages, and [`gossip`](Rumors::gossip)ing the result with peers. /// @@ -70,6 +69,7 @@ impl Clone for Rumors { run_budget: self.peer.run_budget, inner: self.peer.inner.clone(), bookmark: Arc::clone(&self.peer.bookmark), + deserializer: self.peer.deserializer, }, extant: self.extant.clone(), } @@ -162,7 +162,7 @@ impl Rumors { /// If `message` fails to serialize (see [`Batch::send`]). pub fn send(&self, message: T) -> Batch<'_, T> where - T: Serialize + Send + Sync, + T: Serialize + Send + Sync + 'static, { self.peer.send(message) } @@ -393,7 +393,7 @@ impl Rumors { link: &mut Link, ) -> Result> where - T: DeserializeOwned + Serialize + Send + Sync + 'static, + T: Send + Sync + 'static, CR: AsyncRead + Unpin + Send, CW: AsyncWrite + Unpin + Send, C: Connector, @@ -509,7 +509,7 @@ impl Rumors { link: &'a mut Link, ) -> impl Stream>> + Unpin + 'a where - T: DeserializeOwned + Serialize + Send + Sync + 'static, + T: Send + Sync + 'static, CR: AsyncRead + Unpin + Send, CW: AsyncWrite + Unpin + Send, C: Connector, diff --git a/src/rumors/causal.rs b/src/rumors/causal.rs index caac9c337..bc2942947 100644 --- a/src/rumors/causal.rs +++ b/src/rumors/causal.rs @@ -20,6 +20,11 @@ use super::unordered::{Channel, TryNext}; /// order, which may differ between [`gossip`](crate::Rumors::gossip)ing /// replicas of the same [`Rumors`](crate::Rumors). /// +/// Each message arrives as an owned `(Version, Arc)` — both handles are +/// cheap reference bumps into shared storage — through the [`Stream`] impl +/// or [`try_next`](Self::try_next), exactly as for +/// [`UnorderedMessages`](super::UnorderedMessages). +/// /// Unlike [`UnorderedMessages`](super::UnorderedMessages), this costs an /// extra amortized factor, logarithmic in the number of messages the set /// holds, in memory and in the time to retrieve each message, and both @@ -55,10 +60,7 @@ pub struct CausalMessages { /// deterministic. Always the residue of a *single* ingest (a new pass /// opens only once this empties), whose range start was `checkpoint` /// and whose ceiling is `ingested`. - staged: BTreeMap<(Rank, Vec), Leaf>, - /// The most recently delivered leaf, kept alive so its version and - /// value can be lent to the caller until the next call. - current: Option>, + staged: BTreeMap<(Rank, Vec), Leaf>, } impl CausalMessages { @@ -68,7 +70,6 @@ impl CausalMessages { ingested: since.clone(), checkpoint: since, staged: BTreeMap::new(), - current: None, } } @@ -82,7 +83,7 @@ impl CausalMessages { /// complete. The watch read guard lives only long enough to freeze the /// walk and capture the ceiling; the walk itself runs unlocked. fn ingest( - staged: &mut BTreeMap<(Rank, Vec), Leaf>, + staged: &mut BTreeMap<(Rank, Vec), Leaf>, ingested: &mut Version, rx: &mut watch::Receiver>, ) where @@ -102,66 +103,6 @@ impl CausalMessages { *ingested |= &ceiling; } - /// Pop the causally least staged message, parking it in `current` so - /// its borrows survive the return. - /// - /// Never moves the resume point, even when this empties the backlog: - /// the popped message is still unhandled in the caller's hands, so the - /// catch-up is deferred to the next call, exactly as - /// [`UnorderedMessages`](super::UnorderedMessages) defers a drained - /// pass's ceiling. - fn pop(&mut self) -> Option<(&Version, &Arc)> { - let (_, leaf) = self.staged.pop_first()?; - let leaf = self.current.insert(leaf); - Some((leaf.version(), leaf.value())) - } - - /// Advance to the next message in causal order and lend it. - pub(crate) async fn borrow_next_inner(&mut self) -> Option<(&Version, &Arc)> - where - T: Send + Sync, - { - loop { - // Deliver the staged backlog before consulting the channel: - // everything staged became deliverable when its pass finished - // ingesting. (Polonius limitation: returning `self.pop()` here - // would hold the borrow across the loop, so flag-and-break.) - if !self.staged.is_empty() { - break; - } - match self.channel.as_mut().expect("channel state present") { - // Finish a wait the `Stream` face left in flight. - Channel::Waiting(wait) => { - let (closed, rx) = wait.as_mut().await; - self.channel = Some(Channel::Ready(rx)); - if closed { - return None; - } - } - Channel::Ready(rx) => { - // The backlog is empty here (the loop head breaks - // otherwise), so the previous pass is fully delivered - // and its last message is back out of the caller's - // hands: the deferred catch-up runs now, before the - // next pass opens against the caught-up boundary. - self.checkpoint = self.ingested.clone(); - Self::ingest(&mut self.staged, &mut self.ingested, rx); - if self.staged.is_empty() { - // Nothing new: the resume point covers the pass's - // content-free ceiling advance too; await the next - // change. `Err` means every sender is gone and the - // ingest above saw the final state. - self.checkpoint = self.ingested.clone(); - if rx.changed().await.is_err() { - return None; - } - } - } - } - } - self.pop() - } - /// The sound resume point: the causal frontier *behind* any internally /// staged backlog, suitable for persisting across processes or handing to /// another replica of the same network. @@ -184,29 +125,17 @@ impl CausalMessages { } } -impl CausalMessages { - /// Advance to the next message in causal order, lending its version and - /// value until the following call. - /// - /// Awaits quietly while the set is unchanged; resolves [`None`] once no - /// further change is possible and the backlog has drained. - pub async fn borrow_next(&mut self) -> Option<(&Version, &Arc)> - where - T: Send + Sync, - { - self.borrow_next_inner().await - } +impl CausalMessages { /// Take one non-blocking step: a message if one is ready, [`Quiet`] (ask /// again later) if not, [`Ended`] if no further message is possible. /// + /// One [`Stream`] poll with a no-op waker, rendered as the trichotomy. + /// /// [`Quiet`]: TryNext::Quiet /// [`Ended`]: TryNext::Ended - pub fn try_next(&mut self) -> TryNext<'_, T> - where - T: Send + Sync, - { - use futures::FutureExt; - match self.borrow_next_inner().now_or_never() { + pub fn try_next(&mut self) -> TryNext { + use futures::{FutureExt, StreamExt}; + match self.next().now_or_never() { None => TryNext::Quiet, Some(None) => TryNext::Ended, Some(Some(message)) => TryNext::Message(message), @@ -214,9 +143,8 @@ impl CausalMessages { } } -/// The owned-item face: `(Version, Arc)` per item, popped from the -/// same staged backlog [`borrow_next`](CausalMessages::borrow_next) lends -/// from. +/// Yields owned `(Version, Arc)` pairs popped from the staged backlog +/// in causal order: cheap handles into the shared storage. /// /// `T: 'static` because the quiet-period wait is materialized as an /// owned future, exactly as in [`UnorderedMessages`](super::UnorderedMessages). @@ -226,12 +154,13 @@ impl Stream for CausalMessages { fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { let this = self.get_mut(); loop { - // As in `pop`, the resume point stays put even when this pop - // empties the backlog: the yielded message is unhandled until - // the stream is polled again, so the catch-up defers to the - // next poll's ingest. + // The resume point stays put even when this pop empties the + // backlog: the yielded message is unhandled until the stream + // is polled again, so the catch-up defers to the next poll's + // ingest, exactly as UnorderedMessages defers a drained pass's + // ceiling. if let Some((_, leaf)) = this.staged.pop_first() { - return Poll::Ready(Some((leaf.version().clone(), leaf.value().clone()))); + return Poll::Ready(Some((leaf.version().clone(), leaf.value::()))); } match this.channel.as_mut().expect("channel state present") { Channel::Waiting(wait) => match wait.as_mut().poll(cx) { diff --git a/src/rumors/unordered.rs b/src/rumors/unordered.rs index e67b54a57..9cab13eb3 100644 --- a/src/rumors/unordered.rs +++ b/src/rumors/unordered.rs @@ -1,4 +1,4 @@ -use crate::tree::{Leaf, RangeOwned}; +use crate::tree::RangeOwned; use crate::{Version, causally}; use futures::Stream; use std::pin::Pin; @@ -16,12 +16,11 @@ use tokio::sync::watch; /// and no further change is possible, it yields whatever remains and ends /// with `None`. /// -/// There are two ways to use it: +/// Each message arrives as an owned `(Version, Arc)` — both handles are +/// cheap reference bumps into shared storage — through either face: /// -/// - [`borrow_next`](Self::borrow_next) lends each message as -/// `(&Version, &Arc)`, the borrows living until the next call. -/// - The [`Stream`] impl (for `T: 'static`) yields owned -/// `(Version, Arc)`. +/// - the [`Stream`] impl, awaiting quietly while the set is unchanged; +/// - [`try_next`](Self::try_next), one non-blocking step at a time. /// /// Order is unspecified and does *not* follow the causal order: a message may /// be yielded before another that causally precedes it; use @@ -34,17 +33,13 @@ use tokio::sync::watch; pub struct UnorderedMessages { /// The watch channel, or the in-flight wait for it to change. /// - /// The wait future owns the receiver and hands it back: the `Stream` - /// face cannot hold a borrowing `changed()` future across polls - /// (recreating one per poll would drop its waker registration and lose - /// the wakeup), so the wait is materialized; `borrow_next` enters it - /// only to finish what a `Stream` poll started. + /// The wait future owns the receiver and hands it back: a `Stream` + /// cannot hold a borrowing `changed()` future across polls (recreating + /// one per poll would drop its waker registration and lose the + /// wakeup), so the wait is materialized. channel: Option>, checkpoint: Version, - pass: Option>, - /// The most recently yielded leaf, kept alive so its version and value - /// can be lent to the caller until the next call. - current: Option>, + pass: Option, } /// The outcome of [`UnorderedMessages::try_next`] or [`CausalMessages::try_next`]. @@ -53,10 +48,10 @@ pub struct UnorderedMessages { /// /// [`CausalMessages::try_next`]: super::CausalMessages::try_next #[derive(Debug)] -pub enum TryNext<'a, T> { - /// A message was ready, lent until the next call (as - /// [`borrow_next`](UnorderedMessages::borrow_next) lends it). - Message((&'a Version, &'a Arc)), +pub enum TryNext { + /// A message was ready: the same owned `(Version, Arc)` pair the + /// [`Stream`] face yields. + Message((Version, Arc)), /// No message is ready yet, but handles are still live: ask again later. Quiet, /// Every handle is gone and no further message is possible. @@ -69,7 +64,7 @@ type WaitForChange = Pin>)> + Send>>; /// An observer's hold on the watch channel: either the receiver itself, or -/// the materialized owned wait the `Stream` face left in flight (see the +/// the materialized owned wait a quiet poll left in flight (see the /// [`UnorderedMessages::channel`] field docs for why the wait must be owned). pub(super) enum Channel { /// The channel is in hand. @@ -80,8 +75,8 @@ pub(super) enum Channel { /// One in-progress pass: the frozen walk over its snapshot, and the /// snapshot's ceiling to absorb into the checkpoint when the walk drains. -struct Pass { - walk: RangeOwned, +struct Pass { + walk: RangeOwned, ceiling: Version, } @@ -91,7 +86,6 @@ impl UnorderedMessages { channel: Some(Channel::Ready(inner.subscribe())), checkpoint: since, pass: None, - current: None, } } @@ -99,7 +93,7 @@ impl UnorderedMessages { /// watch read guard lives only long enough to freeze the walk (a root /// handle clone) and capture the ceiling. fn open_pass( - pass: &mut Option>, + pass: &mut Option, rx: &mut watch::Receiver>, checkpoint: &Version, ) where @@ -114,46 +108,6 @@ impl UnorderedMessages { } } - /// Advance to the next message and lend it until the following call. - pub(crate) async fn borrow_next_inner(&mut self) -> Option<(&Version, &Arc)> - where - T: Send + Sync, - { - loop { - match self.channel.as_mut().expect("channel state present") { - // Finish a wait the `Stream` face left in flight. - Channel::Waiting(wait) => { - let (closed, rx) = wait.as_mut().await; - self.channel = Some(Channel::Ready(rx)); - if closed { - return None; - } - } - Channel::Ready(rx) => { - Self::open_pass(&mut self.pass, rx, &self.checkpoint); - - // Lend the next leaf out of the walk, parking it in - // `current` so the borrows survive the return. - let pass = self.pass.as_mut().expect("opened above"); - if let Some((_, leaf)) = pass.walk.next() { - let leaf = self.current.insert(leaf); - return Some((leaf.version(), leaf.value())); - } - - // The pass drained: absorb its ceiling as completed, - // then await the next change; `Err` means every sender - // is gone and the drain above already saw the final - // state. - let Pass { ceiling, .. } = self.pass.take().expect("opened above"); - self.checkpoint |= &ceiling; - if rx.changed().await.is_err() { - return None; - } - } - } - } - } - /// The sound resume point: the causal frontier of the last *completed* /// pass, suitable for persisting across processes or handing to another /// replica of the same network. @@ -174,7 +128,7 @@ impl UnorderedMessages { /// # Examples /// /// ``` - /// use futures::FutureExt; + /// use futures::{FutureExt, StreamExt}; /// use rumors::{Peer, Version}; /// /// # tokio::runtime::Builder::new_current_thread() @@ -185,7 +139,7 @@ impl UnorderedMessages { /// rumors.send("one".to_string()); /// /// let mut observer = rumors.unordered_messages(); - /// let (_version, m) = observer.borrow_next().await.expect("one message"); + /// let (_version, m) = observer.next().await.expect("one message"); /// assert_eq!(m.as_str(), "one"); /// /// // Mid-pass, the checkpoint has not moved: resuming here would @@ -194,14 +148,14 @@ impl UnorderedMessages { /// /// // One more step finds nothing ready, completing the pass and /// // absorbing its frontier into the checkpoint. - /// assert!(observer.borrow_next().now_or_never().is_none()); + /// assert!(observer.next().now_or_never().is_none()); /// let checkpoint = observer.checkpoint().clone(); /// /// // A resume from it re-observes nothing from the completed pass and /// // everything not yet delivered. /// rumors.send("two".to_string()); /// let mut resumed = rumors.unordered_messages_since(checkpoint); - /// let (_version, m) = resumed.borrow_next().await.expect("only the new message"); + /// let (_version, m) = resumed.next().await.expect("only the new message"); /// assert_eq!(m.as_str(), "two"); /// # }); /// ``` @@ -210,27 +164,17 @@ impl UnorderedMessages { } } -impl UnorderedMessages { - /// Advance to the next message, lending its version and value until the - /// following call. Awaits quietly while the set is unchanged; resolves - /// [`None`] once no further change is possible. - pub async fn borrow_next(&mut self) -> Option<(&Version, &Arc)> - where - T: Send + Sync, - { - self.borrow_next_inner().await - } +impl UnorderedMessages { /// Take one non-blocking step: a message if one is ready, [`Quiet`] (ask /// again later) if not, [`Ended`] if no further message is possible. /// + /// One [`Stream`] poll with a no-op waker, rendered as the trichotomy. + /// /// [`Quiet`]: TryNext::Quiet /// [`Ended`]: TryNext::Ended - pub fn try_next(&mut self) -> TryNext<'_, T> - where - T: Send + Sync, - { - use futures::FutureExt; - match self.borrow_next_inner().now_or_never() { + pub fn try_next(&mut self) -> TryNext { + use futures::{FutureExt, StreamExt}; + match self.next().now_or_never() { None => TryNext::Quiet, Some(None) => TryNext::Ended, Some(Some(message)) => TryNext::Message(message), @@ -238,8 +182,9 @@ impl UnorderedMessages { } } -/// The owned-item face: `(Version, Arc)` per item, cloned out of -/// the same engine [`borrow_next`](UnorderedMessages::borrow_next) lends from. +/// Yields owned `(Version, Arc)` pairs: cheap handles into the shared +/// storage (the version's buffer and the message's allocation are shared, +/// not copied). /// /// `T: 'static` because the quiet-period wait is materialized as an owned /// future. @@ -264,7 +209,7 @@ impl Stream for UnorderedMessages { let pass = this.pass.as_mut().expect("opened above"); if let Some((_, leaf)) = pass.walk.next() { - return Poll::Ready(Some((leaf.version().clone(), leaf.value().clone()))); + return Poll::Ready(Some((leaf.version().clone(), leaf.value::()))); } // The pass drained: absorb its ceiling, then enter the diff --git a/src/snapshot.rs b/src/snapshot.rs index 2f72bcd37..aecb37264 100644 --- a/src/snapshot.rs +++ b/src/snapshot.rs @@ -2,7 +2,7 @@ use crate::{Network, Version, causally, tree::Tree}; use std::sync::Arc; /// The iterator of [`Snapshot::iter`], re-exported from the tree internals: -/// every live message as `(&Version, &Arc)`, unspecified order, +/// every live message as `(&Version, Arc)`, unspecified order, /// exact-size and double-ended. pub use crate::tree::Iter; @@ -84,11 +84,19 @@ impl Snapshot { /// Looks up the live message stamped with `version` (for example, a /// version an observer yielded earlier). Returns `None` when no live /// message carries it — never sent here, or since redacted. - pub fn get(&self, version: &Version) -> Option<(&Version, &Arc)> { + /// + /// The yielded handle is an owned reference bump into the shared + /// storage: cheap to take, and it keeps the message alive on its own. + pub fn get(&self, version: &Version) -> Option> + where + T: Send + Sync + 'static, + { self.tree.get(version) } - /// Iterates every live message as `(&Version, &Arc)`. + /// Iterates every live message as `(&Version, Arc)` — the version + /// borrowed from the snapshot, the payload an owned handle into the + /// shared storage. /// /// Order is unspecified, and in particular does *not* follow the causal /// order: a message may be yielded before another that causally precedes @@ -96,9 +104,9 @@ impl Snapshot { /// ordering consistent with causality. pub fn iter( &self, - ) -> impl DoubleEndedIterator)> + ExactSizeIterator + Send + Sync + ) -> impl DoubleEndedIterator)> + ExactSizeIterator + Send + Sync where - T: Send + Sync, + T: Send + Sync + 'static, { self.tree.iter() } @@ -145,9 +153,9 @@ impl Snapshot { pub fn range<'q, P: causally::Polarity>( &'q self, query: impl Into>, - ) -> impl DoubleEndedIterator)> + Send + Sync + ) -> impl DoubleEndedIterator)> + Send + Sync where - T: Send + Sync, + T: Send + Sync + 'static, { self.tree.range(query) } @@ -161,8 +169,8 @@ impl Snapshot { } } -impl<'a, T: Send + Sync> IntoIterator for &'a Snapshot { - type Item = (&'a Version, &'a Arc); +impl<'a, T: Send + Sync + 'static> IntoIterator for &'a Snapshot { + type Item = (&'a Version, Arc); type IntoIter = Iter<'a, T>; fn into_iter(self) -> Self::IntoIter { diff --git a/src/tests.rs b/src/tests.rs index 1ba9f5ac2..6b2419996 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -95,15 +95,14 @@ fn overlapping_retiree_party_is_rejected() { // region (not a disjoint fork), with an empty tree so its version equals the // survivor's and the survivor takes the absorb branch. let forged = Peer:: { + deserializer: crate::message::Message::deserializer::(), network: survivor.network, protocol: survivor.protocol, window: survivor.window, run_budget: survivor.run_budget, inner: watch::Sender::new(Inner { party: Some(party_of(&survivor)), - tree: Tree { - root: Root::default(), - }, + tree: Tree::from_root(Root::default()), }), bookmark: Arc::new(Mutex::new(Bookmarked::new(NoBookmark))), }; @@ -267,7 +266,7 @@ impl AsyncWrite for Fuse { fn greeting_frame_len(retiree: &Peer) -> usize { use crate::tree::mirror::streaming::{self, Local, materialized}; - let root: streaming::Root = retiree.inner.borrow().tree.clone().root.into(); + let root: streaming::Root = retiree.inner.borrow().tree.clone().root.into(); let fan = pollster::block_on(materialized::greeting_fan(&Local, root.root)) .unwrap_or_else(|never| match never {}); // The listing frame is raw radix-hash records: one byte plus a Merkle @@ -564,7 +563,7 @@ fn uncontained_supply_fails_gossip_and_poisons_the_link() { let (escaped_root, _, escaped) = crate::tree::arb::poisoned_root(&party_of(&poisoned), &base, Message::new(0u64)); poisoned.inner.send_modify(|inner| { - inner.tree.join(Tree { root: escaped_root }); + inner.tree.join(Tree::from_root(escaped_root)); }); assert!( !crate::tree::mirror::contained(&escaped, poisoned.inner.borrow().tree.latest()), diff --git a/src/tree.rs b/src/tree.rs index 4dacb3349..64f044e80 100644 --- a/src/tree.rs +++ b/src/tree.rs @@ -60,6 +60,7 @@ //! filter — so every convergence property can be tested in-memory and //! trusted on the wire. +use std::marker::PhantomData; use std::sync::Arc; pub(crate) mod traverse; @@ -81,7 +82,7 @@ pub mod mirror; pub use typed::{Leaf, RangeOwned}; /// A sparse Merkle radix trie with transparent path compression, whose -/// leaves store versioned [`Message`]s. +/// leaves store versioned [`Message`]s. /// /// The tree has a branching factor of 256 and a depth of 32, so a leaf's /// 32-byte path is the full-width hash of its version (see @@ -92,7 +93,13 @@ pub use typed::{Leaf, RangeOwned}; /// which conforming peers cannot do. #[derive(Debug, Eq)] pub struct Tree { - pub(crate) root: Root, + pub(crate) root: Root, + /// The payload type this tree's typed faces read leaves at. + /// + /// Storage is erased ([`Message`] holds `dyn Any`); the facade's `T` + /// names the type its faces downcast to (as `fn() -> T`, so + /// auto-traits never descend into `T`). + payload: PhantomData T>, } /// A tree's root pair: the node structure (absent when empty) and the @@ -101,31 +108,22 @@ pub struct Tree { /// The ceiling outlives the nodes — it advances on effectual redactions and /// survives a tree emptying out — which is exactly what deletion honoring /// compares against. -#[derive(Debug, Eq)] -pub struct Root { +#[derive(Clone, Debug, Eq)] +pub struct Root { ceiling: Version, - root: Option>, + root: Option, } -impl From> for Option> { - fn from(value: Root) -> Self { +impl From for Option { + fn from(value: Root) -> Self { value.root } } -impl Clone for Root { - fn clone(&self) -> Self { - Self { - ceiling: self.ceiling.clone(), - root: self.root.clone(), - } - } -} - /// The empty root: the empty [`Version`] over no nodes. The state a mirror /// exchange starts from when the local side holds nothing yet: a /// bootstrapping peer mirrors the provider's tree into it. -impl Default for Root { +impl Default for Root { fn default() -> Self { Root { ceiling: Version::new(), @@ -134,7 +132,7 @@ impl Default for Root { } } -impl PartialEq for Root { +impl PartialEq for Root { fn eq(&self, other: &Self) -> bool { self.ceiling == other.ceiling && self.root == other.root } @@ -144,6 +142,7 @@ impl Clone for Tree { fn clone(&self) -> Self { Self { root: self.root.clone(), + payload: PhantomData, } } } @@ -162,26 +161,29 @@ impl Default for Tree { /// An action to perform on the tree, locally. #[derive(Clone, Debug)] -pub enum Action { +pub enum Action { /// Insert some value, tagged at the current version by your own party. - Insert(Message), + Insert(Message), /// Forget the leaf at a version-derived path. Forget(typed::Path), } -/// The iterator of [`Snapshot::iter`](crate::Snapshot::iter): -/// a lazy depth-first walk over every live message as -/// `(&Version, &Arc)`, in unspecified order. +/// The iterator of [`Snapshot::iter`](crate::Snapshot::iter): a lazy +/// depth-first walk over every live message as `(&Version, Arc)`, in +/// unspecified order. +/// +/// The version is borrowed from the tree; the payload is an owned handle +/// into the shared storage. /// /// An [`ExactSizeIterator`] (the live-message count is known up front) and a /// [`DoubleEndedIterator`]. -pub struct Iter<'a, T>(typed::Iter<'a, T>); +pub struct Iter<'a, T>(typed::Iter<'a>, PhantomData T>); -impl<'a, T> Iterator for Iter<'a, T> { - type Item = (&'a Version, &'a Arc); +impl<'a, T: Send + Sync + 'static> Iterator for Iter<'a, T> { + type Item = (&'a Version, Arc); fn next(&mut self) -> Option { - self.0.next().map(|(v, m)| (v, m.as_arc())) + self.0.next().map(|(v, m)| (v, m.arc::())) } fn size_hint(&self) -> (usize, Option) { @@ -189,13 +191,13 @@ impl<'a, T> Iterator for Iter<'a, T> { } } -impl<'a, T> DoubleEndedIterator for Iter<'a, T> { +impl<'a, T: Send + Sync + 'static> DoubleEndedIterator for Iter<'a, T> { fn next_back(&mut self) -> Option { - self.0.next_back().map(|(v, m)| (v, m.as_arc())) + self.0.next_back().map(|(v, m)| (v, m.arc::())) } } -impl<'a, T> ExactSizeIterator for Iter<'a, T> {} +impl<'a, T: Send + Sync + 'static> ExactSizeIterator for Iter<'a, T> {} impl Tree { /// Creates a new, empty tree carrying the empty [`Version`]. @@ -206,11 +208,15 @@ impl Tree { /// plain [`clone`](Clone); any party split happens on the owning /// [`Peer`](crate::Peer). pub fn new() -> Self { + Self::from_root(Root::default()) + } + + /// Wrap an already-built root pair as a typed tree facade: the caller + /// asserts the payload type its leaves were constructed with. + pub(crate) fn from_root(root: Root) -> Self { Tree { - root: Root { - ceiling: Version::new(), - root: None, - }, + root, + payload: PhantomData, } } @@ -277,13 +283,19 @@ impl Tree { /// Looks up the live message stamped with `version`, by its /// version-derived path. - pub fn get(&self, version: &Version) -> Option<(&Version, &Arc)> { + /// + /// The stored version is not echoed back: a leaf's path derives from + /// its version, so the hit's version is the queried one. + pub fn get(&self, version: &Version) -> Option> + where + T: Send + Sync + 'static, + { let path = <[u8; 32]>::from(typed::Path::for_leaf(version)); self.root .root .as_ref()? .get(&path) - .map(|(version, message)| (version, message.as_arc())) + .map(|(_, message)| message.arc::()) } /// Forces every lazily-memoized structural value — the observable hash @@ -305,10 +317,10 @@ impl Tree { } /// Lazily iterates every live leaf currently in the tree as - /// `(&Version, &Arc)`, in unspecified order. + /// `(&Version, Arc)`, in unspecified order. pub fn iter(&self) -> Iter<'_, T> where - T: Send + Sync, + T: Send + Sync + 'static, { Iter( self.root @@ -316,6 +328,7 @@ impl Tree { .as_ref() .map(typed::node::Root::iter) .unwrap_or_else(typed::Iter::empty), + PhantomData, ) } @@ -330,7 +343,7 @@ impl Tree { pub fn range_owned<'q, P: causally::Polarity>( &self, query: impl Into>, - ) -> RangeOwned { + ) -> RangeOwned

{ typed::node::Root::range_owned(self.root.root.as_ref(), query.into().into_owned()) } @@ -347,15 +360,15 @@ impl Tree { pub fn range<'q, P: causally::Polarity>( &'q self, query: impl Into>, - ) -> impl DoubleEndedIterator)> + Send + Sync + ) -> impl DoubleEndedIterator)> + Send + Sync where - T: Send + Sync, + T: Send + Sync + 'static, { typed::node::Root::range(self.root.root.as_ref(), query.into()) - // The shared walk yields the full `&Message`; the public - // contract hands out only the `&Arc` value, a cheap projection - // of it. - .map(|(v, m)| (v, m.as_arc())) + // The shared walk yields the full `&Message`; the public + // contract hands out the owned payload handle, one reference + // bump on the shared allocation. + .map(|(v, m)| (v, m.arc::())) } /// Applies the specified actions as a batch to the tree, advancing its @@ -407,7 +420,7 @@ impl Tree { pub fn act(&mut self, party: &before::Party, actions: I) -> bool where T: Send + Sync, - I: IntoIterator>, + I: IntoIterator, { // Track the running version across the batch, ticking the owning party // once per action so that (a) content-identical messages occupy @@ -466,7 +479,7 @@ impl Tree { fn react(&mut self, reactions: I) -> bool where T: Send + Sync, - M: Into>>, + M: Into>, I: IntoIterator, { // Materialize the caller's action stream before the commit section @@ -512,7 +525,7 @@ impl Tree { // internal unwind via the injected fuse. let mut changed = false; let mut new_ceiling = self.root.ceiling.clone(); - let new_root = traverse::act(self.root.root.clone(), actions, |v: &Version| { + let new_root = traverse::act(self.root.root.clone(), actions, &mut |v: &Version| { new_ceiling |= v; changed = true; }); diff --git a/src/tree/arb.rs b/src/tree/arb.rs index 0d15b0d19..ac27d5a2d 100644 --- a/src/tree/arb.rs +++ b/src/tree/arb.rs @@ -59,7 +59,7 @@ pub fn arb_version() -> BoxedStrategy { pub fn arb_root_node( party: usize, leaves: impl Into, -) -> BoxedStrategy>> { +) -> BoxedStrategy>> { vec(any::<()>(), leaves) .prop_map(move |draws| { // Tick this tree's party once per leaf, so the leaves carry a @@ -79,7 +79,7 @@ pub fn arb_root_node( (path, version.clone(), Action::Insert(message)) }) .collect(); - act(None, actions, |_| ()) + act(None, actions, &mut |_| ()) }) .boxed() } @@ -92,7 +92,7 @@ pub fn arb_root_node( pub fn arb_tree_root( party: usize, leaves: impl Into, -) -> BoxedStrategy> { +) -> BoxedStrategy { (arb_root_node(party, leaves), 0u64..8) .prop_map(move |(node, extra_ticks)| { // The wrapper version must be a causal upper bound on every version @@ -129,7 +129,7 @@ pub fn arb_tree_root( /// while the other still holds them (which the merge must drop by version /// dominance, the entire deletion mechanism). With zero shared inserts the two /// sides are fully disjoint, so this one generator also covers that case. -pub fn arb_divergent_pair() -> BoxedStrategy<(crate::tree::Root<()>, crate::tree::Root<()>)> { +pub fn arb_divergent_pair() -> BoxedStrategy<(crate::tree::Root, crate::tree::Root)> { use crate::tree::{Action, Tree}; ( @@ -147,7 +147,7 @@ pub fn arb_divergent_pair() -> BoxedStrategy<(crate::tree::Root<()>, crate::tree // Common base; at this point the tree holds exactly the shared // inserts, so its live keys are the shared keys each side may // redact. - let mut base = Tree::new(); + let mut base = Tree::<()>::new(); base.act( &p_s, (0..n_shared).map(|_| Action::Insert(Message::new(()))), @@ -190,7 +190,7 @@ pub fn arb_divergent_pair() -> BoxedStrategy<(crate::tree::Root<()>, crate::tree /// is instead the deterministic [`early_first_child_dispute_pair`] fixture, /// which performs that search once; this strategy provides breadth around /// it. -pub fn arb_wide_divergent_pair() -> BoxedStrategy<(crate::tree::Root<()>, crate::tree::Root<()>)> { +pub fn arb_wide_divergent_pair() -> BoxedStrategy<(crate::tree::Root, crate::tree::Root)> { use crate::tree::{Action, Tree}; ( @@ -205,7 +205,7 @@ pub fn arb_wide_divergent_pair() -> BoxedStrategy<(crate::tree::Root<()>, crate: let p_a = nth_party(1); let p_b = nth_party(2); - let mut base = Tree::new(); + let mut base = Tree::<()>::new(); base.act( &p_s, (0..n_shared).map(|_| Action::Insert(Message::new(()))), @@ -241,7 +241,7 @@ pub fn arb_wide_divergent_pair() -> BoxedStrategy<(crate::tree::Root<()>, crate: /// widths draw zero too, so subset, identical, and ceiling-only merges — /// a changed flag's `false` arm — are sampled at depth alongside the /// gains. -pub fn arb_deep_divergent_pair() -> BoxedStrategy<(crate::tree::Root<()>, crate::tree::Root<()>)> { +pub fn arb_deep_divergent_pair() -> BoxedStrategy<(crate::tree::Root, crate::tree::Root)> { (0usize..32, 0u8..5, 0u8..5) .prop_map(|(depth, a_width, b_width)| { let path_at = |branch: u8| { @@ -259,7 +259,7 @@ pub fn arb_deep_divergent_pair() -> BoxedStrategy<(crate::tree::Root<()>, crate: shared_version.clone(), Action::Insert(Message::new(())), )], - |_| (), + &mut |_| (), ); // One side: `width` sibling leaves diverging at `depth`, all on @@ -280,7 +280,7 @@ pub fn arb_deep_divergent_pair() -> BoxedStrategy<(crate::tree::Root<()>, crate: let node = if leaves.is_empty() { base.clone() } else { - act(base.clone(), leaves, |_| ()) + act(base.clone(), leaves, &mut |_| ()) }; root_with_ceiling(node, shared_version.clone() | version) }; @@ -303,7 +303,7 @@ pub fn arb_deep_divergent_pair() -> BoxedStrategy<(crate::tree::Root<()>, crate: /// counts vary per attempt, each attempt's honestly-built pair is checked /// against the geometry, and the first satisfying pair wins. Hashing is /// deterministic, so the search — and therefore the fixture — is too. -pub fn early_first_child_dispute_pair() -> (crate::tree::Root<()>, crate::tree::Root<()>) { +pub fn early_first_child_dispute_pair() -> (crate::tree::Root, crate::tree::Root) { use crate::tree::{Action, Tree}; /// Leaves per side: enough on the left for wide roots with collisions, @@ -385,7 +385,7 @@ pub fn early_first_child_dispute_pair() -> (crate::tree::Root<()>, crate::tree:: }; if left_under.min(right_under) >= 1 && left_under.max(right_under) >= 2 && provisions >= 6 { let build = |party: &Party, base: Version, live: usize| { - let mut tree = Tree::new(); + let mut tree = Tree::<()>::new(); tree.root.ceiling = base; tree.act(party, (0..live).map(|_| Action::Insert(Message::new(())))); tree @@ -424,7 +424,7 @@ pub fn early_first_child_dispute_pair() -> (crate::tree::Root<()>, crate::tree:: /// receiver adopts, or the receiver's own later redact ticks — ever /// contains it. Returns the two roots plus the escaped leaf's /// version-derived path and its version. -pub fn uncontained_supply_pair() -> (crate::tree::Root<()>, crate::tree::Root<()>, Path, Version) { +pub fn uncontained_supply_pair() -> (crate::tree::Root, crate::tree::Root, Path, Version) { /// How far the escaped version outruns both declared ceilings, per /// party: an upper bound on the honest ticks a test performs after /// the pair is built. @@ -470,7 +470,7 @@ pub fn uncontained_supply_pair() -> (crate::tree::Root<()>, crate::tree::Root<() receiver_version.clone(), Action::Insert(receiver_message), )], - |_| (), + &mut |_| (), ), receiver_version.clone(), ); @@ -493,7 +493,7 @@ pub fn uncontained_supply_pair() -> (crate::tree::Root<()>, crate::tree::Root<() act( None, vec![(path, escaped.clone(), Action::Insert(message))], - |_| (), + &mut |_| (), ), declared, ); @@ -514,7 +514,7 @@ fn leaf_sibling_path(last: u8) -> Path { /// Wrap an optional root node in a [`tree::Root`](crate::tree::Root) with the /// given ceiling. -fn root_with_ceiling(node: Option>, ceiling: Version) -> crate::tree::Root { +fn root_with_ceiling(node: Option>, ceiling: Version) -> crate::tree::Root { crate::tree::Root { ceiling, root: node, @@ -531,11 +531,11 @@ fn root_with_ceiling(node: Option>, ceiling: Version) -> crate: /// honest ticks a test may perform afterward without containing the /// escape. Returns the root plus the escaped leaf's version-derived path /// and its version. -pub fn poisoned_root( +pub fn poisoned_root( party: &Party, base: &Version, - message: Message, -) -> (crate::tree::Root, Path, Version) { + message: Message, +) -> (crate::tree::Root, Path, Version) { /// How far the escaped version outruns `base`: an upper bound on the /// honest ticks a test performs after the root is planted. const ESCAPE_MARGIN: usize = 64; @@ -549,7 +549,7 @@ pub fn poisoned_root( act( None, vec![(path, escaped.clone(), Action::Insert(message))], - |_| (), + &mut |_| (), ), Version::new(), ); @@ -564,11 +564,7 @@ pub fn poisoned_root( /// down to `S` holds exactly one child on each side and disputes at every /// height: the difference survives to the closing rounds, where each side /// must provide its own extra and absorb the other's. -pub fn leaf_parent_dispute_pair() -> ( - crate::tree::Root<()>, - crate::tree::Root<()>, - crate::tree::Root<()>, -) { +pub fn leaf_parent_dispute_pair() -> (crate::tree::Root, crate::tree::Root, crate::tree::Root) { // The shared leaf: one tick on party 0, literally the same node in both // trees (each side is built on top of `base`). let mut shared_version = Version::new(); @@ -580,7 +576,7 @@ pub fn leaf_parent_dispute_pair() -> ( shared_version.clone(), Action::Insert(Message::new(())), )], - |_| (), + &mut |_| (), ); // Each side's extra rides its own disjoint party, so both extras are @@ -594,7 +590,7 @@ pub fn leaf_parent_dispute_pair() -> ( a_version.clone(), Action::Insert(Message::new(())), )], - |_| (), + &mut |_| (), ); let mut b_version = Version::new(); @@ -604,9 +600,9 @@ pub fn leaf_parent_dispute_pair() -> ( b_version.clone(), Action::Insert(Message::new(())), ); - let b_node = act(base, vec![b_extra.clone()], |_| ()); + let b_node = act(base, vec![b_extra.clone()], &mut |_| ()); - let union = act(a_node.clone(), vec![b_extra], |_| ()); + let union = act(a_node.clone(), vec![b_extra], &mut |_| ()); let a_ceiling = shared_version.clone() | a_version; let b_ceiling = shared_version | b_version; @@ -626,11 +622,7 @@ pub fn leaf_parent_dispute_pair() -> ( /// `b` lacks the leaf, so reconciliation must delete it from `a` too — with /// no tombstone to say so, only the version bounds. The surviving tree is /// `b`'s: the concurrent insert alone. -pub fn leaf_parent_redaction_pair() -> ( - crate::tree::Root<()>, - crate::tree::Root<()>, - crate::tree::Root<()>, -) { +pub fn leaf_parent_redaction_pair() -> (crate::tree::Root, crate::tree::Root, crate::tree::Root) { // a's only leaf, on party 0. let mut a_version = Version::new(); a_version.tick(&nth_party(0)); @@ -641,7 +633,7 @@ pub fn leaf_parent_redaction_pair() -> ( a_version.clone(), Action::Insert(Message::new(())), )], - |_| (), + &mut |_| (), ); // b: built on a's history, inserts a concurrent sibling, then forgets @@ -657,16 +649,16 @@ pub fn leaf_parent_redaction_pair() -> ( let mut forget_version = b_version.clone(); forget_version.tick(&nth_party(1)); let b_node = act( - act(a_node.clone(), vec![b_insert.clone()], |_| ()), + act(a_node.clone(), vec![b_insert.clone()], &mut |_| ()), vec![( leaf_sibling_path(0x00), forget_version.clone(), Action::Forget, )], - |_| (), + &mut |_| (), ); - let survivor = act(None, vec![b_insert], |_| ()); + let survivor = act(None, vec![b_insert], &mut |_| ()); let b_ceiling = a_version.clone() | forget_version; let expected = root_with_ceiling(survivor, a_version.clone() | b_ceiling.clone()); diff --git a/src/tree/mirror/alternating.rs b/src/tree/mirror/alternating.rs index 02a6643cc..b93b9be87 100644 --- a/src/tree/mirror/alternating.rs +++ b/src/tree/mirror/alternating.rs @@ -164,14 +164,13 @@ macro_rules! x { // The inner mirror protocol, between an initiator and a responder (who may or // may not correspond with the original client/server distinction). -async fn mirror_connected( +async fn mirror_connected( i: I, r: R, ) -> Result<(I::Output, R::Output), Error> where - T: Send + Sync, - I: Peer, - R: Peer, + I: Peer, + R: Peer, { x! { let x = i.initiator() } x! { i.open_initiator <=x== r.responder } @@ -190,19 +189,18 @@ where /// The client's exchange after the connect phase: the [`Peer`] it has descended /// to once `connect` then `complete_connect` have run. -pub(crate) type ClientConnected = <>::Next as CompleteConnect>::Next; +pub(crate) type ClientConnected = <::Next as CompleteConnect>::Next; /// The server's exchange after the connect phase: the [`Peer`] it has descended /// to once `accept` has run. -pub(crate) type ServerConnected = >::Next; +pub(crate) type ServerConnected = ::Next; /// The result of the connect phase ([`handshake`]): the causal versions have /// been exchanged and either agree or are ready for descent. -pub(crate) enum Handshaken +pub(crate) enum Handshaken where - T: Send + Sync, - C: Client, - S: Server, + C: Client, + S: Server, { /// The two versions were equal: already converged, no descent. Carries the /// client's reconciled root and the server's output (the remote side's @@ -215,18 +213,17 @@ where /// The versions differ: the connected exchanges are ready for [`descend`]. /// Carries both versions for the descent's role tiebreak. Diverged { - local: ClientConnected, - remote: ServerConnected, + local: ClientConnected, + remote: ServerConnected, our_version: Version, peer: Handshake, }, } -impl Handshaken +impl Handshaken where - T: Send + Sync, - C: Client, - S: Server, + C: Client, + S: Server, { /// The peer's causal-version greeting. pub(crate) fn peer(&self) -> &Handshake { @@ -275,14 +272,13 @@ where /// version. /// /// Stops there, handing the equal/divergent outcome to [`Handshaken`]. -pub(crate) async fn handshake( +pub(crate) async fn handshake( c: C, s: S, -) -> Result, Error> +) -> Result, Error> where - T: Send + Sync, - C: Client, - S: Server, + C: Client, + S: Server, { // The client emits its handshake. `connect` is statically `Continue` (its // `Done` carries `Infallible`), so this `let` is irrefutable. @@ -338,16 +334,15 @@ where /// Run the steady-state descent between two connected [`Peer`]s, choosing the /// initiator by the canonical-byte tiebreak on the two (necessarily distinct) /// versions, and returning the outputs in `(local, remote)` order. -pub(crate) async fn descend( +pub(crate) async fn descend( local: I, remote: R, local_version: Version, remote_version: Version, ) -> Result<(I::Output, R::Output), Error> where - T: Send + Sync, - I: Peer, - R: Peer, + I: Peer, + R: Peer, { // Their causal order is only partial (they may be concurrent), so to pick // an initiator we compare canonical bytes lexicographically: an arbitrary @@ -376,14 +371,13 @@ where /// on the peer's [`Handshake`] in between; this whole-session shortcut serves /// only the in-process protocol tests. #[cfg(test)] -pub async fn mirror<'a, C, S, T>( +pub async fn mirror<'a, C, S>( c: C, s: S, ) -> Result<(C::Output, S::Output), Error> where - T: Send + Sync + 'a, - C: Client + 'a, - S: Server + 'a, + C: Client + 'a, + S: Server + 'a, { // Box the future so that callers don't need to handle its big future type. Box::pin(async move { diff --git a/src/tree/mirror/alternating/backend/local.rs b/src/tree/mirror/alternating/backend/local.rs index 0095ce493..e8e58a4a5 100644 --- a/src/tree/mirror/alternating/backend/local.rs +++ b/src/tree/mirror/alternating/backend/local.rs @@ -167,14 +167,11 @@ pub struct Exchange { expected_parents: std::collections::BTreeSet>, } -impl Exchange> -where - T: Send + Sync, -{ +impl Exchange { /// Open a local exchange over `node`, ready for the connect phase: the /// zipper at the top, the handshake version captured from the root's /// ceiling. - pub fn start(node: tree::Root) -> Self { + pub fn start(node: tree::Root) -> Self { Self { versions: Start { our_version: node.ceiling.clone(), @@ -191,20 +188,16 @@ where impl protocol::Stage for Exchange where L: Levels + Send, - L::Message: Send, { type Height = L::Height; - type Output = tree::Root; + type Output = tree::Root; /// Absorbing peer content can diagnose a semantic violation /// ([`Exchange::absorb_providing`]); nothing else here fails. type Error = Violation; } -impl protocol::Connect for Exchange> -where - T: Send + Sync, -{ - type Next = Exchange>; +impl protocol::Connect for Exchange { + type Next = Exchange; async fn connect( self, @@ -229,11 +222,8 @@ where } } -impl protocol::CompleteConnect for Exchange> -where - T: Send + Sync, -{ - type Next = Exchange>; +impl protocol::CompleteConnect for Exchange { + type Next = Exchange; async fn complete_connect( self, @@ -266,11 +256,8 @@ where } } -impl protocol::Accept for Exchange> -where - T: Send + Sync, -{ - type Next = Exchange>; +impl protocol::Accept for Exchange { + type Next = Exchange; async fn accept( self, @@ -311,11 +298,8 @@ where } } -impl protocol::Initiator for Exchange> -where - T: Send + Sync, -{ - type Next = Exchange>; +impl protocol::Initiator for Exchange { + type Next = Exchange; async fn initiator( self, @@ -333,11 +317,8 @@ where } } -impl protocol::Responder for Exchange> -where - T: Send + Sync, -{ - type Next = Exchange>>; +impl protocol::Responder for Exchange { + type Next = Exchange>; async fn responder( mut self, @@ -386,10 +367,9 @@ where } } -impl protocol::OpenInitiator for Exchange +impl protocol::OpenInitiator for Exchange where - T: Send + Sync, - L: Levels + Send, + L: Levels + Send, { type Next = Exchange>>; @@ -397,60 +377,56 @@ where self, request: message::Opening, ) -> Result< - protocol::Step, Self::Next, Self::Output>, + protocol::Step, Self::Next, Self::Output>, Self::Error, > { self.reply(request) } } -impl protocol::Exchange for Exchange +impl protocol::Exchange for Exchange where - T: Send + Sync, - L: Levels>> + Send, + L: Levels>> + Send, S>: Height, S: Height, H: Height + Unknown, // Assumed at impl-validation time so we don't have to case-analyze `H` // here: at use sites `H` is concrete and one of the three blanket impls // discharges it. - Exchange, L>>>: protocol::AfterExchange, + Exchange, L>>>: protocol::AfterExchange, { type Next = Exchange, L>>>; async fn exchange( self, - request: message::Exchange>, - ) -> Result, Self::Next, Self::Output>, Self::Error> - { + request: message::Exchange>, + ) -> Result, Self::Next, Self::Output>, Self::Error> { self.reply(request) } } -impl protocol::CloseResponder for Exchange +impl protocol::CloseResponder for Exchange where - T: Send + Sync, - L: Levels> + Send, + L: Levels> + Send, { type Next = Exchange>; async fn close_responder( self, - request: message::Exchange, - ) -> Result, Self::Next, Self::Output>, Self::Error> { + request: message::Exchange, + ) -> Result, Self::Error> { self.close(request) } } -impl protocol::CompleteInitiator for Exchange +impl protocol::CompleteInitiator for Exchange where - T: Send + Sync, - L: Levels + Send, + L: Levels + Send, { async fn complete_initiator( mut self, - request: message::Closing, - ) -> Result, Infallible, Self::Output>, Violation> { + request: message::Closing, + ) -> Result, Violation> { self.absorb_providing(request.providing)?; let providing = self.answer_requested_leaves(request.requested); Ok(protocol::Step::Done { @@ -465,14 +441,13 @@ where } } -impl protocol::CompleteResponder for Exchange +impl protocol::CompleteResponder for Exchange where - T: Send + Sync, - L: Levels + Send, + L: Levels + Send, { async fn complete_responder( mut self, - request: message::Complete, + request: message::Complete, ) -> Result, Violation> { self.absorb_providing(request.providing)?; Ok(protocol::Step::Done { diff --git a/src/tree/mirror/alternating/backend/local/partition.rs b/src/tree/mirror/alternating/backend/local/partition.rs index 4b85680ba..6bd94de08 100644 --- a/src/tree/mirror/alternating/backend/local/partition.rs +++ b/src/tree/mirror/alternating/backend/local/partition.rs @@ -25,7 +25,7 @@ use super::{Connected, Exchange, message, protocol}; /// The output of [`Exchange::partition_uncertain`], one field per outgoing /// channel in the asymmetry matrix. -struct Partition +struct Partition where S: Height, H: Height, @@ -33,26 +33,26 @@ where /// Left-case subtrees (we have them, the counterparty does not). The caller /// will combine these with `answer_requested`'s output to form the final /// outgoing `providing`. - providing: Level>, + providing: Level>, /// Right-case prefixes (the counterparty has them, we do not): the outgoing /// `requested`. Built in strictly ascending order (see /// [`Exchange::partition_uncertain`]). requested: Vec>>, /// `Both`-case children whose hashes agreed, plus Left-case children we /// kept locally. Become the new level immediately above the bottom. - matched: Level>, + matched: Level>, /// `Both`-case grandchildren of children whose hashes disagreed. Become the /// new bottom of the zipper, and next round's outgoing `uncertain`. - exploded: Level, + exploded: Level, } /// The output of [`Exchange::partition_leaf_uncertain`]: the leaf-height /// [`Partition`], with the dispute cell gone (a leaf never recurses) and the /// matched-and-kept leaves named `kept`. -struct LeafPartition { +struct LeafPartition { /// Leaves only we hold that the counterparty has not deleted: joins the /// outgoing `providing`. - providing: Level, + providing: Level, /// Leaves only the counterparty holds: the outgoing `requested`. Built in /// strictly ascending order. requested: Vec>, @@ -61,13 +61,12 @@ struct LeafPartition { /// /// Becomes the zipper's new bottom, where the counterparty's answers to /// `requested` join it before `collapse` reassembles the union parents. - kept: Level, + kept: Level, } impl Exchange where L: Levels, - L::Message: Send + Sync, { /// Insert nodes the counterparty has just sent us (because we requested /// them last round, or because they unilaterally knew we lacked them) into @@ -85,10 +84,9 @@ where /// subtree: O(nodes received). Rejection leaves the zipper untouched. pub(super) fn absorb_providing( &mut self, - providing: message::Providing, + providing: message::Providing, ) -> Result<(), Violation> where - L::Message: Send + Sync, L: Levels, H: Height, { @@ -139,7 +137,7 @@ where fn answer_requested_surviving( &mut self, requested: Vec>, - mut provide: impl FnMut(Prefix, &tree::typed::Node), + mut provide: impl FnMut(Prefix, &tree::typed::Node), ) where L: Levels, H: Height + Unknown, @@ -181,10 +179,7 @@ where /// that any subtrees they have deleted disappear locally too. /// /// Returns the outgoing `providing` map, one height below the frontier. - pub(super) fn answer_requested( - &mut self, - requested: Vec>>, - ) -> Level + pub(super) fn answer_requested(&mut self, requested: Vec>>) -> Level where L: Levels>, S: Unknown, @@ -215,10 +210,7 @@ where /// constructed by Both-case matches in the previous round and therefore /// agree on every parent. The debug-assertions guard against a /// steady-state caller silently triggering either branch. - fn partition_uncertain( - &mut self, - uncertain: Vec<(Prefix>, Hash)>, - ) -> Partition + fn partition_uncertain(&mut self, uncertain: Vec<(Prefix>, Hash)>) -> Partition where L: Levels>>, S>: Height, @@ -370,10 +362,7 @@ where /// the counterparty's version is one they deleted, so it is dropped /// locally instead of provided — deletion honored on both sides in one /// arm. - pub(super) fn answer_requested_leaves( - &mut self, - requested: Vec>, - ) -> Level + pub(super) fn answer_requested_leaves(&mut self, requested: Vec>) -> Level where L: Levels, { @@ -394,10 +383,7 @@ where /// Each disputed parent leaves the frontier; its reconciled leaves land /// in the returned `kept` level, which the caller pushes down the zipper /// so `collapse` reassembles the union parent. - fn partition_leaf_uncertain( - &mut self, - uncertain: Vec<(Prefix, Hash)>, - ) -> LeafPartition + fn partition_leaf_uncertain(&mut self, uncertain: Vec<(Prefix, Hash)>) -> LeafPartition where L: Levels>, { @@ -503,13 +489,9 @@ where #[allow(clippy::type_complexity)] pub(super) fn close( mut self, - request: message::Exchange, + request: message::Exchange, ) -> Result< - protocol::Step< - message::Closing, - Exchange>, - tree::Root, - >, + protocol::Step>, tree::Root>, Violation, > where @@ -589,16 +571,12 @@ where mut self, request: Request, ) -> Result< - protocol::Step< - Response, - Exchange, L>>>, - tree::Root, - >, + protocol::Step, L>>>, tree::Root>, Violation, > where - Request: Into>>, - Response: From>, + Request: Into>>, + Response: From>, L: Levels>>, S>: Height, S: Height, diff --git a/src/tree/mirror/alternating/backend/remote.rs b/src/tree/mirror/alternating/backend/remote.rs index a54219cb2..956c7bbdd 100644 --- a/src/tree/mirror/alternating/backend/remote.rs +++ b/src/tree/mirror/alternating/backend/remote.rs @@ -1,7 +1,7 @@ //! Wire-bound counterpart to [`super::local`]. //! //! Where `local::Exchange` realizes the protocol trait family by traversing an -//! in-memory zipper, `remote::Exchange` realizes it as a proxy of +//! in-memory zipper, `remote::Exchange` realizes it as a proxy of //! the *counterparty*: each protocol method serializes its incoming request //! into the writer and deserializes the counterparty's response from the //! reader. The struct carries only a paired `(reader, writer)` plus a phantom @@ -50,13 +50,14 @@ use std::marker::PhantomData; use tokio::io::{AsyncRead, AsyncWrite}; +use crate::message::PayloadDeserializer; use crate::tree::wire; use crate::Error; use crate::tree::mirror::framing::{FrameRead, FrameWrite}; use crate::tree::typed::{ - Node, height::{Height, Root, S, UnderRoot, UnderUnderRoot, Z}, + node::DecodeNode, }; use super::super::{ @@ -64,8 +65,6 @@ use super::super::{ protocol::{self, Step}, }; -use serde::Serialize; -use serde::de::DeserializeOwned; /// The version state for an [`Exchange`] which has just been initialized but /// has not yet connected. pub struct Start; @@ -79,38 +78,53 @@ pub struct Connected; /// Holds the underlying reader/writer (each wrapped for exact-read framing) and /// a phantom tag pinning the height; the counterparty's actual zipper lives on /// the far side of the wire. -pub struct Exchange { +pub struct Exchange { reader: FrameRead, writer: FrameWrite, - #[allow(clippy::type_complexity)] - _phantom: PhantomData (T, V, H)>, + /// The peer's payload deserializer: every `providing` channel this + /// proxy decodes builds its leaf payloads through it. + deserializer: PayloadDeserializer, + _phantom: PhantomData (V, H)>, } -impl Exchange { +impl Exchange { /// Begin an [`Exchange`] on transport halves wrapped after the shared raw /// preamble has completed. - pub fn start(reader: FrameRead, writer: FrameWrite) -> Self { + /// + /// `deserializer` is the peer's payload deserializer, the typed + /// ingress for every leaf this session decodes. + pub fn start( + reader: FrameRead, + writer: FrameWrite, + deserializer: PayloadDeserializer, + ) -> Self { Self { reader, writer, + deserializer, _phantom: PhantomData, } } } -impl Exchange { +impl Exchange { /// Construct a [`Connected`]-state [`Exchange`] from already-framed /// reader/writer halves, threading them through from a predecessor stage. - fn connected(reader: FrameRead, writer: FrameWrite) -> Self { + fn connected( + reader: FrameRead, + writer: FrameWrite, + deserializer: PayloadDeserializer, + ) -> Self { Self { reader, writer, + deserializer, _phantom: PhantomData, } } } -impl protocol::Stage for Exchange { +impl protocol::Stage for Exchange { type Height = H; /// The reconciled tree lives on the local side; the proxy yields its /// framed reader/writer halves back to the caller, which stays the @@ -164,17 +178,49 @@ where wire::from_slice(&frame).map_err(Error::Io) } +/// [`recv_msg`] for the payload-bearing messages: the frame decodes +/// through [`message::DecodeWith`], its leaf payloads through the peer's +/// deserializer. +pub(super) async fn recv_msg_with( + reader: &mut FrameRead, + deserializer: PayloadDeserializer, +) -> Result +where + R: AsyncRead + Unpin + Send, + M: message::DecodeWith, +{ + let frame = reader + .frame() + .await + .map_err(|e| match e.kind() { + std::io::ErrorKind::UnexpectedEof => std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, + "peer closed before sending expected message", + ), + _ => e, + }) + .map_err(Error::Io)?; + let mut slice = frame.as_slice(); + let msg = M::read_wire_with(&mut slice, deserializer).map_err(Error::Io)?; + if !slice.is_empty() { + return Err(Error::Io(wire::invalid(format!( + "{} trailing bytes after the decoded message", + slice.len() + )))); + } + Ok(msg) +} + // One protocol-trait impl block per trait, each at the specific height it // pertains to. Together with the [`protocol::AfterExchange`] blanket impls, // they discharge every transition in the protocol's height schedule. -impl protocol::Accept for Exchange +impl protocol::Accept for Exchange where R: AsyncRead + Unpin + Send, W: AsyncWrite + Unpin + Send, - T: Serialize + DeserializeOwned + Send + Sync, { - type Next = Exchange; + type Next = Exchange; async fn accept( mut self, @@ -203,19 +249,18 @@ where Ok(protocol::Step::Continue { msg: peer, - next: Exchange::connected(self.reader, self.writer), + next: Exchange::connected(self.reader, self.writer, self.deserializer), }) } } -impl protocol::Initiator for Exchange +impl protocol::Initiator for Exchange where - T: DeserializeOwned + Send + Sync, R: AsyncRead + Unpin + Send, W: AsyncWrite + Unpin + Send, - Node: wire::Decode, + UnderRoot: DecodeNode, { - type Next = Exchange; + type Next = Exchange; async fn initiator(mut self) -> Result, Error> { // No write: the real initiator (on the far side of the wire) has @@ -225,19 +270,18 @@ where // is `Infallible`, so `Done` is uninhabitable here. Ok(Step::Continue { msg, - next: Exchange::connected(self.reader, self.writer), + next: Exchange::connected(self.reader, self.writer, self.deserializer), }) } } -impl protocol::Responder for Exchange +impl protocol::Responder for Exchange where - T: DeserializeOwned + Send + Sync, R: AsyncRead + Unpin + Send, W: AsyncWrite + Unpin + Send, - Node: wire::Decode, + UnderRoot: DecodeNode, { - type Next = Exchange; + type Next = Exchange; async fn responder( mut self, @@ -253,30 +297,30 @@ where let response: message::Opening = recv_msg(&mut self.reader).await?; Ok(Step::Continue { msg: response, - next: Exchange::connected(self.reader, self.writer), + next: Exchange::connected(self.reader, self.writer, self.deserializer), }) } } -impl protocol::OpenInitiator for Exchange +impl protocol::OpenInitiator for Exchange where - T: DeserializeOwned + Send + Sync, R: AsyncRead + Unpin + Send, W: AsyncWrite + Unpin + Send, - Node: wire::Decode, + UnderRoot: DecodeNode, { - type Next = Exchange; + type Next = Exchange; async fn open_initiator( mut self, request: message::Opening, - ) -> Result, Self::Next, Self::Output>, Error> { + ) -> Result, Self::Next, Self::Output>, Error> { send_msg(&mut self.writer, &request).await?; // We always await a response: even an empty `Opening` can prompt the // counterparty to send back a non-trivial `providing` (the "we have, // they lack" Left case when we are the empty side). - let response: message::Exchange = recv_msg(&mut self.reader).await?; + let response: message::Exchange = + recv_msg_with(&mut self.reader, self.deserializer).await?; if response.requested.is_empty() && response.uncertain.is_empty() { Ok(Step::Done { @@ -286,32 +330,31 @@ where } else { Ok(Step::Continue { msg: response, - next: Exchange::connected(self.reader, self.writer), + next: Exchange::connected(self.reader, self.writer, self.deserializer), }) } } } -impl protocol::Exchange for Exchange>> +impl protocol::Exchange for Exchange>> where - T: DeserializeOwned + Send + Sync, R: AsyncRead + Unpin + Send, W: AsyncWrite + Unpin + Send, H: Height, S: Height, S>: Height, - Node>: wire::Decode, + S: DecodeNode, // Assumed at impl-validation time so we don't have to case-analyze `H` // here: at use sites `H` is concrete and one of the three blanket impls // in `super::protocol` discharges it. - Exchange: protocol::AfterExchange, + Exchange: protocol::AfterExchange, { - type Next = Exchange; + type Next = Exchange; async fn exchange( mut self, - request: message::Exchange>, - ) -> Result, Self::Next, Self::Output>, Error> { + request: message::Exchange>, + ) -> Result, Self::Next, Self::Output>, Error> { // If the message we just sent will cause the other party to be done, // they won't ever respond, so don't await their response. let counterparty_finished = request.requested.is_empty() && request.uncertain.is_empty(); @@ -325,7 +368,8 @@ where }); } - let response: message::Exchange = recv_msg(&mut self.reader).await?; + let response: message::Exchange = + recv_msg_with(&mut self.reader, self.deserializer).await?; if response.requested.is_empty() && response.uncertain.is_empty() { Ok(Step::Done { @@ -335,24 +379,23 @@ where } else { Ok(Step::Continue { msg: response, - next: Exchange::connected(self.reader, self.writer), + next: Exchange::connected(self.reader, self.writer, self.deserializer), }) } } } -impl protocol::CloseResponder for Exchange> +impl protocol::CloseResponder for Exchange> where - T: DeserializeOwned + Send + Sync, R: AsyncRead + Unpin + Send, W: AsyncWrite + Unpin + Send, { - type Next = Exchange; + type Next = Exchange; async fn close_responder( mut self, - request: message::Exchange, - ) -> Result, Self::Next, Self::Output>, Error> { + request: message::Exchange, + ) -> Result, Error> { // If the message we just sent will cause the other party to be done, // they won't ever respond, so don't await their response. let counterparty_finished = request.requested.is_empty() && request.uncertain.is_empty(); @@ -366,7 +409,7 @@ where }); } - let response: message::Closing = recv_msg(&mut self.reader).await?; + let response: message::Closing = recv_msg_with(&mut self.reader, self.deserializer).await?; if response.requested.is_empty() { Ok(Step::Done { @@ -376,22 +419,21 @@ where } else { Ok(Step::Continue { msg: response, - next: Exchange::connected(self.reader, self.writer), + next: Exchange::connected(self.reader, self.writer, self.deserializer), }) } } } -impl protocol::CompleteInitiator for Exchange +impl protocol::CompleteInitiator for Exchange where - T: DeserializeOwned + Send + Sync, R: AsyncRead + Unpin + Send, W: AsyncWrite + Unpin + Send, { async fn complete_initiator( mut self, - request: message::Closing, - ) -> Result, Infallible, Self::Output>, Error> { + request: message::Closing, + ) -> Result, Error> { // If the message we just sent will cause the other party to be done, // they won't ever respond, so don't await their response. let counterparty_finished = request.requested.is_empty(); @@ -405,7 +447,8 @@ where }); } - let response: message::Complete = recv_msg(&mut self.reader).await?; + let response: message::Complete = + recv_msg_with(&mut self.reader, self.deserializer).await?; // `CompleteInitiator` is statically `Done`: the `Next` slot is // `Infallible`, so `Continue` is uninhabitable here. @@ -416,15 +459,14 @@ where } } -impl protocol::CompleteResponder for Exchange +impl protocol::CompleteResponder for Exchange where - T: Send + Sync, R: AsyncRead + Unpin + Send, W: AsyncWrite + Unpin + Send, { async fn complete_responder( mut self, - request: message::Complete, + request: message::Complete, ) -> Result, Error> { // Final write; the real responder absorbs this and is done. send_msg(&mut self.writer, &request).await?; diff --git a/src/tree/mirror/alternating/backend/remote/tests.rs b/src/tree/mirror/alternating/backend/remote/tests.rs index 1dfa344fb..b4320081c 100644 --- a/src/tree/mirror/alternating/backend/remote/tests.rs +++ b/src/tree/mirror/alternating/backend/remote/tests.rs @@ -11,11 +11,12 @@ //! `alternating/message/tests.rs`; the exact wire bytes in //! `alternating/wire_snapshot.rs`. +use crate::message::Message; use proptest::collection::vec; use proptest::prelude::*; use super::super::super::message; -use super::{FrameRead, recv_msg}; +use super::{FrameRead, recv_msg, recv_msg_with}; use crate::tree::arb::nth_party; use crate::tree::mirror::framing::LENGTH_HEADER_LEN; use crate::tree::typed::height::UnderRoot; @@ -38,6 +39,15 @@ fn recv(bytes: &[u8]) -> Result { }) } +/// [`recv`] for the payload-bearing messages, through the production +/// deserializer-parameterized ingress with a unit-payload deserializer. +fn recv_with(bytes: &[u8]) -> Result { + pollster::block_on(async { + let mut reader = FrameRead::new(bytes); + recv_msg_with::(&mut reader, Message::deserializer::<()>()).await + }) +} + /// The canonical encoding of a greeting whose version is nonempty, so a /// truncation leaves bytes to cut. fn handshake_bytes() -> Vec { @@ -177,15 +187,15 @@ proptest! { Ok(()) | Err(Error::Io(_)), )); prop_assert!(matches!( - recv::>(&framed).map(|_| ()), + recv_with::>(&framed).map(|_| ()), Ok(()) | Err(Error::Io(_)), )); prop_assert!(matches!( - recv::>(&framed).map(|_| ()), + recv_with::(&framed).map(|_| ()), Ok(()) | Err(Error::Io(_)), )); prop_assert!(matches!( - recv::>(&framed).map(|_| ()), + recv_with::(&framed).map(|_| ()), Ok(()) | Err(Error::Io(_)), )); } diff --git a/src/tree/mirror/alternating/message.rs b/src/tree/mirror/alternating/message.rs index 8b05e3fe0..34bb07737 100644 --- a/src/tree/mirror/alternating/message.rs +++ b/src/tree/mirror/alternating/message.rs @@ -12,7 +12,7 @@ //! no length prefix. //! - [`typed::Prefix`](crate::tree::typed::Prefix): exactly `32 − //! H::HEIGHT` raw bytes, no length prefix (the type pins the byte count). -//! - [`Version`] and [`Message`](crate::message::Message): one CBOR +//! - [`Version`] and [`Message`](crate::message::Message): one CBOR //! value each — a byte string wrapping the version's canonical encoding, //! and a byte string wrapping the message's cached CBOR payload — //! self-delimiting by CBOR's own length headers. @@ -21,7 +21,7 @@ //! frame whose entries are not strictly ascending order (which also //! rejects duplicates). //! -//! ## Typed [`Node`](crate::tree::typed::Node) +//! ## Typed [`Node`](crate::tree::typed::Node) //! //! Encoded in its in-memory layout. The typed node's wire impl is a thin //! delegate over the untyped node's `serialize_to`, which is the canonical @@ -32,7 +32,7 @@ //! prefix_len: u8 // path-compressed prefix byte count //! [u8; prefix_len] // head bytes, shallowest first //! body // dispatched on `children`: -//! Children::Leaf: version: Version, message: Message +//! Children::Leaf: version: Version, message: Message //! Children::Branch: count_minus_two: u8, [(radix: u8, NodeWire); count] //! ``` //! @@ -52,7 +52,7 @@ //! //! ## The three channels //! -//! - **`providing`**: `Vec<(Prefix<_>, Node)>` — the subtrees being +//! - **`providing`**: `Vec<(Prefix<_>, Node<_>)>` — the subtrees being //! provided, each paired with the prefix it lands at, in ascending prefix //! order. Each node carries its full structure on the wire (path-compression //! bytes, branch radices, child counts); the receiver inserts it directly at @@ -72,22 +72,57 @@ //! the wire: the protocol's height schedule names the type each side expects //! next. +use crate::message::PayloadDeserializer; use crate::tree::wire::{self, Decode, Encode}; +/// Wire decode for the payload-bearing protocol messages: parses each +/// `providing` channel's leaf payloads through the peer's deserializer +/// (see [`read_providing`]); the payload-free messages stay on the plain +/// [`Decode`]. +pub trait DecodeWith: Sized { + fn read_wire_with( + reader: &mut R, + deserializer: PayloadDeserializer, + ) -> std::io::Result; +} + use crate::Version; use crate::tree::typed::{ Hash, Node, Prefix, height::{Height, Root, S, UnderRoot, Z}, + node::DecodeNode, }; -use serde::de::DeserializeOwned; #[cfg(test)] mod tests; /// The `providing` channel's payload at height `H`: the subtrees being provided, /// each paired with the prefix it lands at, in ascending prefix order. The /// receiver inserts each node directly at its named prefix. -pub type Providing = Vec<(Prefix, Node)>; +pub type Providing = Vec<(Prefix, Node)>; + +/// Decode one `providing` channel: a `u32` count, then `(prefix, node)` +/// pairs, the nodes' leaf payloads parsed through the peer's +/// deserializer (the protocol's typed ingress; see [`DecodeNode`]). +fn read_providing( + reader: &mut R, + deserializer: PayloadDeserializer, +) -> std::io::Result> +where + H: DecodeNode, + R: std::io::Read, +{ + let count = u32::read_wire(reader)? as usize; + // Grow as elements arrive rather than trusting the declared count for + // the allocation (the same discipline as the framing reader). + let mut items = Vec::new(); + for _ in 0..count { + let prefix = Prefix::::read_wire(reader)?; + let node = H::read_node(reader, deserializer)?; + items.push((prefix, node)); + } + Ok(items) +} /// The opening message of every session, exchanged by the `connect`/`accept` /// steps. It carries the sender's causal [`Version`]. @@ -174,7 +209,7 @@ impl Decode for Opening { /// The steady-state message: carries all three channels (see the /// asymmetry-matrix table in the [`super::local`] module docs). #[derive(Clone)] -pub struct Exchange +pub struct Exchange where S: Height, H: Height, @@ -193,7 +228,7 @@ where /// On the wire each subtree travels as a whole `(prefix, node)` pair in /// ascending prefix order; the receiver inserts it directly at the named /// prefix. Strictly ascending by prefix; duplicates are rejected. - pub providing: Providing>, + pub providing: Providing>, /// Prefixes the counterparty listed in the previous round's `uncertain` /// that we lack entirely. We ask them to send the subtrees so we can insert /// them into our zipper. Strictly ascending; duplicates are rejected. @@ -207,7 +242,7 @@ where pub uncertain: Vec<(Prefix, Hash)>, } -impl Encode for Exchange +impl Encode for Exchange where S: Height, H: Height, @@ -219,20 +254,16 @@ where } } -// `Node>: Decode` reduces inductively to `Node: Decode` -// and bottoms at `Z`, so with `H` left generic the proof obligation -// doesn't terminate during inference. We thread `Node>: Decode` -// through as an explicit bound so the caller — who knows `H` concretely — -// discharges it. -impl Decode for Exchange +impl DecodeWith for Exchange where - T: DeserializeOwned, - S: Height, + S: DecodeNode, H: Height, - Node>: Decode, { - fn read_wire(reader: &mut R) -> std::io::Result { - let providing: Providing> = Decode::read_wire(reader)?; + fn read_wire_with( + reader: &mut R, + deserializer: PayloadDeserializer, + ) -> std::io::Result { + let providing: Providing> = read_providing(reader, deserializer)?; verify_pairs_canonical(&providing, "Exchange.providing")?; let requested: Vec>> = Decode::read_wire(reader)?; verify_keys_canonical(&requested, "Exchange.requested")?; @@ -246,7 +277,7 @@ where } } -impl From for Exchange { +impl From for Exchange { fn from(Opening { uncertain }: Opening) -> Self { Exchange { uncertain, @@ -255,7 +286,7 @@ impl From for Exchange { } } -impl Default for Exchange +impl Default for Exchange where S: Height, H: Height, @@ -286,12 +317,12 @@ where /// [`complete_initiator`](super::protocol::CompleteInitiator::complete_initiator) /// consume `Closing` directly, without a runtime check against an /// out-of-spec responder. -#[derive(Clone)] -pub struct Closing { +#[derive(Clone, Default)] +pub struct Closing { /// Leaves only the responder holds that the initiator has not deleted: /// answers to the initiator's final `requested`, plus leaves the /// initiator's `uncertain` listing proved it lacks. - pub providing: Providing, + pub providing: Providing, /// Leaves the initiator listed under disputed parents that the responder /// lacks entirely. /// @@ -301,19 +332,19 @@ pub struct Closing { pub requested: Vec>, } -impl Encode for Closing { +impl Encode for Closing { fn write_wire(&self, writer: &mut W) -> std::io::Result<()> { self.providing.write_wire(writer)?; self.requested.write_wire(writer) } } -impl Decode for Closing -where - T: DeserializeOwned, -{ - fn read_wire(reader: &mut R) -> std::io::Result { - let providing: Providing = Decode::read_wire(reader)?; +impl DecodeWith for Closing { + fn read_wire_with( + reader: &mut R, + deserializer: PayloadDeserializer, + ) -> std::io::Result { + let providing: Providing = read_providing(reader, deserializer)?; verify_pairs_canonical(&providing, "Closing.providing")?; let requested: Vec> = Decode::read_wire(reader)?; verify_keys_canonical(&requested, "Closing.requested")?; @@ -324,15 +355,6 @@ where } } -impl Default for Closing { - fn default() -> Self { - Self { - providing: Default::default(), - requested: Default::default(), - } - } -} - /// The initiator's terminal message: the final `providing` at leaf height, /// answering the responder's closing `requested`. /// @@ -343,36 +365,28 @@ impl Default for Closing { /// /// No `requested` (the responder never replies after this) and no `uncertain` /// (vacuous at leaf height, same reasoning as [`Closing`]). -#[derive(Clone)] -pub struct Complete { - pub providing: Providing, +#[derive(Clone, Default)] +pub struct Complete { + pub providing: Providing, } -impl Encode for Complete { +impl Encode for Complete { fn write_wire(&self, writer: &mut W) -> std::io::Result<()> { self.providing.write_wire(writer) } } -impl Decode for Complete -where - T: DeserializeOwned, -{ - fn read_wire(reader: &mut R) -> std::io::Result { - let providing: Providing = Decode::read_wire(reader)?; +impl DecodeWith for Complete { + fn read_wire_with( + reader: &mut R, + deserializer: PayloadDeserializer, + ) -> std::io::Result { + let providing: Providing = read_providing(reader, deserializer)?; verify_pairs_canonical(&providing, "Complete.providing")?; Ok(Self { providing }) } } -impl Default for Complete { - fn default() -> Self { - Self { - providing: Default::default(), - } - } -} - /// An out-of-order or duplicated wire channel: the canonical encoding admits /// exactly one byte sequence per value, so a peer that reorders or pads is /// rejected before its content is acted on. diff --git a/src/tree/mirror/alternating/message/tests.rs b/src/tree/mirror/alternating/message/tests.rs index fe214dba3..9e62c4571 100644 --- a/src/tree/mirror/alternating/message/tests.rs +++ b/src/tree/mirror/alternating/message/tests.rs @@ -18,7 +18,7 @@ use crate::Version; use crate::message::Message; use crate::tree::arb::{arb_root_node, arb_version, nth_party}; use crate::tree::typed::height::{Height, Root, S, Z}; -use crate::tree::typed::{Hash, Node, Prefix, hash::MERKLE_HASH_LEN}; +use crate::tree::typed::{Hash, Node, Prefix, hash::MERKLE_HASH_LEN, node::DecodeNode}; use crate::tree::wire; use super as message; @@ -39,7 +39,7 @@ fn arb_hash() -> BoxedStrategy { any::<[u8; MERKLE_HASH_LEN]>().prop_map(Hash).boxed() } -fn arb_leaf() -> BoxedStrategy> { +fn arb_leaf() -> BoxedStrategy> { arb_version() .prop_map(|version| Node::leaf(version, Message::new(()))) .boxed() @@ -47,9 +47,7 @@ fn arb_leaf() -> BoxedStrategy> { /// Sort and deduplicate `(prefix, node)` entries into the canonical ascending /// `Vec` the `providing` channel expects. -fn canonical_providing( - entries: Vec<(Prefix, Node<(), H>)>, -) -> Vec<(Prefix, Node<(), H>)> { +fn canonical_providing(entries: Vec<(Prefix, Node)>) -> Vec<(Prefix, Node)> { entries .into_iter() .collect::>() @@ -120,14 +118,14 @@ proptest! { ); let requested = canonical_keys(requested); let uncertain = canonical_pairs(uncertain); - let m: message::Exchange<(), message::UnderRoot> = message::Exchange { + let m: message::Exchange = message::Exchange { providing: providing.clone(), requested: requested.clone(), uncertain: uncertain.clone(), }; let bytes = wire::to_vec(&m).unwrap(); let decoded = - wire::from_slice::>(&bytes).unwrap(); + from_slice_with::>(&bytes).unwrap(); prop_assert_eq!(decoded.providing, providing); prop_assert_eq!(decoded.requested, requested); prop_assert_eq!(decoded.uncertain, uncertain); @@ -142,12 +140,12 @@ proptest! { ) { let providing = canonical_providing(providing_entries); let requested = canonical_keys(requested); - let m: message::Closing<()> = message::Closing { + let m: message::Closing = message::Closing { providing: providing.clone(), requested: requested.clone(), }; let bytes = wire::to_vec(&m).unwrap(); - let decoded = wire::from_slice::>(&bytes).unwrap(); + let decoded = from_slice_with::(&bytes).unwrap(); prop_assert_eq!(decoded.providing, providing); prop_assert_eq!(decoded.requested, requested); } @@ -159,9 +157,9 @@ proptest! { providing_entries in vec((arb_prefix::(), arb_leaf()), 0..=4), ) { let providing = canonical_providing(providing_entries); - let m: message::Complete<()> = message::Complete { providing: providing.clone() }; + let m: message::Complete = message::Complete { providing: providing.clone() }; let bytes = wire::to_vec(&m).unwrap(); - let decoded = wire::from_slice::>(&bytes).unwrap(); + let decoded = from_slice_with::(&bytes).unwrap(); prop_assert_eq!(decoded.providing, providing); } @@ -180,12 +178,40 @@ proptest! { let mut permuted = canonical.clone(); permuted.rotate_left(rotate % canonical.len()); prop_assume!(permuted != canonical); - let m = message::Complete::<()> { providing: permuted }; + let m = message::Complete { providing: permuted }; let bytes = wire::to_vec(&m).unwrap(); - prop_assert!(wire::from_slice::>(&bytes).is_err()); + prop_assert!(from_slice_with::(&bytes).is_err()); } } +/// Decode one payload-bearing message from an exact slice through the +/// unit-payload deserializer, rejecting trailing bytes. +fn from_slice_with(bytes: &[u8]) -> std::io::Result { + let mut input = bytes; + let m = M::read_wire_with(&mut input, Message::deserializer::<()>())?; + if !input.is_empty() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("{} trailing bytes after the decoded message", input.len()), + )); + } + Ok(m) +} + +/// Decode one erased node at height `H` from an exact slice, rejecting +/// trailing bytes: the test-side door to [`DecodeNode`], with unit payloads. +fn node_from_slice(bytes: &[u8]) -> std::io::Result> { + let mut input = bytes; + let node = H::read_node(&mut input, Message::deserializer::<()>())?; + if !input.is_empty() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("{} trailing bytes after the decoded node", input.len()), + )); + } + Ok(node) +} + /// A single version, ticked once on a fixed party — enough to place one leaf. fn one_version() -> Version { let p = nth_party(0); @@ -200,17 +226,17 @@ fn one_version() -> Version { fn providing_rejects_duplicate_prefix() { let prefix = prefix_from_bytes::(&[7u8; 32]); let leaf = Node::leaf(one_version(), Message::new(())); - let m = message::Complete::<()> { + let m = message::Complete { providing: vec![(prefix, leaf.clone()), (prefix, leaf)], }; let bytes = wire::to_vec(&m).unwrap(); - assert!(wire::from_slice::>(&bytes).is_err()); + assert!(from_slice_with::(&bytes).is_err()); } /// A `requested` frame whose prefixes descend is rejected. #[test] fn requested_rejects_descending_order() { - let m = message::Closing::<()> { + let m = message::Closing { providing: Vec::new(), requested: vec![ prefix_from_bytes::(&[2u8; 32]), @@ -218,7 +244,7 @@ fn requested_rejects_descending_order() { ], }; let bytes = wire::to_vec(&m).unwrap(); - assert!(wire::from_slice::>(&bytes).is_err()); + assert!(from_slice_with::(&bytes).is_err()); } /// An `uncertain` frame with a duplicate prefix is rejected. @@ -248,7 +274,7 @@ fn uncertain_rejects_duplicate_prefix() { /// past the leaf floor. #[test] fn node_prefix_exceeding_height_is_rejected() { - let error = wire::from_slice::>>(&[2]).unwrap_err(); + let error = node_from_slice::>(&[2]).unwrap_err(); assert_eq!(error.kind(), std::io::ErrorKind::InvalidData); } @@ -260,7 +286,7 @@ fn node_prefix_exceeding_height_is_rejected() { /// cannot all be placed. #[test] fn node_child_count_overflow_is_rejected() { - let error = wire::from_slice::>>(&[0, 255]).unwrap_err(); + let error = node_from_slice::>(&[0, 255]).unwrap_err(); assert_eq!(error.kind(), std::io::ErrorKind::InvalidData); } @@ -271,7 +297,7 @@ fn node_child_count_overflow_is_rejected() { /// (`InvalidData`) rather than let one branch have two encodings. #[test] fn node_descending_radices_are_rejected() { - let leaf = wire::to_vec(&Node::<(), Z>::leaf(one_version(), Message::new(()))).unwrap(); + let leaf = wire::to_vec(&Node::::leaf(one_version(), Message::new(()))).unwrap(); // prefix_len 0, count_minus_two 0 (two children), radix 5, its leaf, // then a second radix that does not ascend. let mut bytes = vec![0, 0, 5]; @@ -279,6 +305,6 @@ fn node_descending_radices_are_rejected() { bytes.push(5); bytes.extend_from_slice(&leaf); - let error = wire::from_slice::>>(&bytes).unwrap_err(); + let error = node_from_slice::>(&bytes).unwrap_err(); assert_eq!(error.kind(), std::io::ErrorKind::InvalidData); } diff --git a/src/tree/mirror/alternating/protocol.rs b/src/tree/mirror/alternating/protocol.rs index 985078cfb..6b4c04ee0 100644 --- a/src/tree/mirror/alternating/protocol.rs +++ b/src/tree/mirror/alternating/protocol.rs @@ -88,12 +88,9 @@ pub trait Stage: Send { /// Open the connect phase on the client side: emit our [`message::Handshake`] /// greeting. Always continues (the `Done` slot is [`Infallible`]): a side /// cannot know it has converged before hearing the peer's version. -pub trait Connect: Stage + Sized -where - T: Send + Sync, -{ +pub trait Connect: Stage + Sized { /// The state that absorbs the peer's version ([`CompleteConnect`]). - type Next: CompleteConnect + Stage; + type Next: CompleteConnect + Stage; fn connect( self, @@ -106,13 +103,10 @@ where /// descent; `Continue` hands over a state ready to play either role /// (`Next` is both [`Initiator`] and [`Responder`]; the byte tiebreak picks /// which, see [`descend`](super::descend)). -pub trait CompleteConnect: Stage + Sized -where - T: Send + Sync, -{ +pub trait CompleteConnect: Stage + Sized { /// The connected state, able to play either descent role. - type Next: Initiator - + Responder + type Next: Initiator + + Responder + Stage; fn complete_connect( @@ -126,13 +120,10 @@ where /// /// `Done` mirrors [`CompleteConnect`]'s convergence case; the two sides always /// agree on it (both compare the same pair of versions). -pub trait Accept: Stage + Sized -where - T: Send + Sync, -{ +pub trait Accept: Stage + Sized { /// The connected state, able to play either descent role. - type Next: Initiator - + Responder + type Next: Initiator + + Responder + Stage; fn accept( @@ -147,12 +138,9 @@ where /// /// The trait is implemented by the state type that the constructor produces; /// `Self::Next == Self` for any straightforward implementation. -pub trait Initiator: Stage + Sized -where - T: Send + Sync, -{ +pub trait Initiator: Stage + Sized { /// The state that consumes the responder's [`message::Opening`]. - type Next: OpenInitiator + Stage; + type Next: OpenInitiator + Stage; /// Begin the protocol as the initiator. /// @@ -170,12 +158,9 @@ where } /// Continue the protocol as the responder. -pub trait Responder: Stage + Sized -where - T: Send + Sync, -{ +pub trait Responder: Stage + Sized { /// The first steady-state [`Exchange`] from the responder's side. - type Next: Exchange + Stage; + type Next: Exchange + Stage; /// Begin the protocol as the responder, processing the initiator's /// [`message::Initiate`]. @@ -197,13 +182,9 @@ where /// Distinct from [`Exchange`] because the opening carries only `uncertain`, /// and the responder may list children of the initiator's absent root, a /// case the steady-state [`Exchange`] is allowed to debug-assert against. -pub trait OpenInitiator: Stage + Sized -where - T: Send + Sync, -{ +pub trait OpenInitiator: Stage + Sized { /// The first steady-state [`Exchange`] from the initiator's side. - type Next: Exchange - + Stage; + type Next: Exchange + Stage; /// Process the initiator's first round, applied to the responder's /// [`message::Opening`]. @@ -220,7 +201,7 @@ where request: message::Opening, ) -> impl Future< Output = Result< - Step, Self::Next, Self::Output>, + Step, Self::Next, Self::Output>, Self::Error, >, > + Send; @@ -232,9 +213,8 @@ where /// message's is `Self::Height − 1`); both are recovered from `Stage::Height` /// via `Pred` projections, so each implementing type's exchange height is /// determined by its `Stage::Height` alone. -pub trait Exchange: Stage + Sized +pub trait Exchange: Stage + Sized where - T: Send + Sync, Self::Height: Pred, ::Pred: Pred, S<::Pred>: Height, @@ -242,7 +222,7 @@ where { /// Whichever of [`Exchange`], [`CloseResponder`], or [`CompleteInitiator`] /// is appropriate at the outgoing message's height. See [`AfterExchange`]. - type Next: AfterExchange::Pred as Pred>::Pred> + type Next: AfterExchange<<::Pred as Pred>::Pred> + Stage< Output = Self::Output, Height = <::Pred as Pred>::Pred, @@ -259,11 +239,11 @@ where #[allow(clippy::type_complexity)] fn exchange( self, - request: message::Exchange::Pred>, + request: message::Exchange<::Pred>, ) -> impl Future< Output = Result< Step< - message::Exchange::Pred as Pred>::Pred>, + message::Exchange<<::Pred as Pred>::Pred>, Self::Next, Self::Output, >, @@ -281,12 +261,9 @@ where /// two leaves at one path are the same leaf — so the reply carries only /// `providing` and `requested`, which is exactly what [`message::Closing`] /// encodes. -pub trait CloseResponder: Stage> + Sized -where - T: Send + Sync, -{ +pub trait CloseResponder: Stage> + Sized { /// The terminal responder state. - type Next: CompleteResponder + Stage; + type Next: CompleteResponder + Stage; /// The responder's closing round, descending the zipper from `S` to /// `Z` and emitting [`message::Closing`]. @@ -297,18 +274,13 @@ where #[allow(clippy::type_complexity)] fn close_responder( self, - request: message::Exchange, - ) -> impl Future< - Output = Result, Self::Next, Self::Output>, Self::Error>, - > + Send; + request: message::Exchange, + ) -> impl Future, Self::Error>> + Send; } /// The initiator's terminal round; absorbs the responder's /// [`message::Closing`] and answers it with [`message::Complete`]. -pub trait CompleteInitiator: Stage + Sized -where - T: Send + Sync, -{ +pub trait CompleteInitiator: Stage + Sized { /// The initiator's final round. /// /// Absorbs the responder's last batch of `providing`, answers its final @@ -322,18 +294,14 @@ where #[allow(clippy::type_complexity)] fn complete_initiator( self, - request: message::Closing, - ) -> impl Future< - Output = Result, Infallible, Self::Output>, Self::Error>, - > + Send; + request: message::Closing, + ) -> impl Future, Self::Error>> + + Send; } /// The responder's terminal round; absorbs the initiator's /// [`message::Complete`]. -pub trait CompleteResponder: Stage + Sized -where - T: Send + Sync, -{ +pub trait CompleteResponder: Stage + Sized { /// The responder's final round. /// /// Absorbs the initiator's last batch of `providing` (from @@ -343,7 +311,7 @@ where #[allow(clippy::type_complexity)] fn complete_responder( self, - request: message::Complete, + request: message::Complete, ) -> impl Future, Self::Error>> + Send; } @@ -361,42 +329,25 @@ where /// Heights `Z` and `S` are handled via the blanket impls below, keyed off /// the appropriate terminal trait. `S>` needs its own blanket only /// because it does not unify with the `S>>` pattern. -pub trait AfterExchange: Sized +pub trait AfterExchange: Sized where - T: Send + Sync, H: Height, { } -impl AfterExchange for X -where - T: Send + Sync, - X: CompleteInitiator, -{ -} +impl AfterExchange for X where X: CompleteInitiator {} -impl AfterExchange> for X -where - T: Send + Sync, - X: CloseResponder, -{ -} +impl AfterExchange> for X where X: CloseResponder {} -impl AfterExchange>> for X -where - T: Send + Sync, - X: Exchange + Stage>>, -{ -} +impl AfterExchange>> for X where X: Exchange + Stage>> {} -impl AfterExchange>>> for X +impl AfterExchange>>> for X where - T: Send + Sync, H: Height, S: Height, S>: Height, S>>: Height, - X: Exchange + Stage>>>, + X: Exchange + Stage>>>, { } @@ -405,7 +356,7 @@ where /// position. /// /// The macro emits the chains tt-munched ahead of time: the -/// initiator side wraps `$init_terminal` in N `Exchange` +/// initiator side wraps `$init_terminal` in N `Exchange` /// layers (where N is the count of `_` tokens in `init: […]`), and the /// responder side does the same for `$resp_terminal`. /// @@ -422,12 +373,12 @@ macro_rules! define_peer { define_peer!(@step init: [$($init_count)*], resp: [$($resp_count)*], - init_chain: (CompleteInitiator), - resp_chain: (CloseResponder), + init_chain: ( CompleteInitiator), + resp_chain: ( CloseResponder), ); }; - // Wrap one `Exchange` around the init-chain accumulator + // Wrap one `Exchange` around the init-chain accumulator // until the init-side counter is exhausted. (@step init: [_ $($init_rest:tt)*], @@ -438,7 +389,7 @@ macro_rules! define_peer { define_peer!(@step init: [$($init_rest)*], resp: [$($resp_count)*], - init_chain: (Exchange), + init_chain: (Exchange), resp_chain: ($($resp_chain)*), ); }; @@ -454,7 +405,7 @@ macro_rules! define_peer { init: [], resp: [$($resp_rest)*], init_chain: ($($init_chain)*), - resp_chain: (Exchange), + resp_chain: (Exchange), ); }; @@ -475,53 +426,47 @@ macro_rules! define_peer { /// /// Both `local::Exchange` and `remote::Exchange` pick this up for /// free via the blanket impl below; downstream call sites take a - /// single `Peer` bound on each argument and the chain bounds + /// single `Peer` bound on each argument and the chain bounds /// propagate. - pub trait Peer: - Initiator> + Responder + pub trait Peer: + Initiator> + Responder where - T: Send + Sync, { } - impl Peer for X + impl Peer for X where - T: Send + Sync, - X: Initiator> + Responder + X: Initiator> + Responder { } /// A [`Peer`] entered through the server side of the connect phase: /// [`Accept`] first, then either descent role. The whole-session /// bound the wire-facing driver takes for the remote party. - pub trait Server: - Accept> + Responder> + pub trait Server: + Accept> + Responder> where - T: Send + Sync, { } - impl Server for X + impl Server for X where - T: Send + Sync, - X: Accept> + Responder> + X: Accept> + Responder> { } /// A [`Peer`] entered through the client side of the connect phase: /// [`Connect`] then [`CompleteConnect`], then either descent role. /// The whole-session bound the drivers take for the local party. - pub trait Client: - Connect> + Responder>> + pub trait Client: + Connect> + Responder>> where - T: Send + Sync, { } - impl Client for X + impl Client for X where - T: Send + Sync, - X: Connect> + Responder>> + X: Connect> + Responder>> { } diff --git a/src/tree/mirror/alternating/tests.rs b/src/tree/mirror/alternating/tests.rs index bc4fe2cd9..22e5f6f30 100644 --- a/src/tree/mirror/alternating/tests.rs +++ b/src/tree/mirror/alternating/tests.rs @@ -20,8 +20,6 @@ use crate::{Version, message::Message}; use super::{local, mirror, remote}; use crate::tree::mirror::handshake::{self, Intent}; -use serde::Serialize; -use serde::de::DeserializeOwned; // clippy's `missing_const_for_thread_local` misreads `thread_local!`'s // fallback-TLS lowering (illumos among the gate's targets) and denies // initializers that already sit in `const` blocks; the allow keeps @@ -78,14 +76,7 @@ const DUPLEX_BUF: usize = 8 * 1024; /// Drive the mirror protocol through the high-level [`super::mirror`] /// driver under the chosen [`Scenario`], and return the reconciled tree /// (which must be equal on both sides if the protocol converged). -fn mirror_via( - a: crate::tree::Root, - b: crate::tree::Root, - scenario: Scenario, -) -> crate::tree::Root -where - T: PartialEq + std::fmt::Debug + Serialize + DeserializeOwned + Send + Sync, -{ +fn mirror_via(a: crate::tree::Root, b: crate::tree::Root, scenario: Scenario) -> crate::tree::Root { block_on(async move { match scenario { Scenario::LocalLocal => { @@ -110,11 +101,19 @@ where let (b_r, b_w) = tokio::io::split(b_side); let local_a = local::Exchange::start(a); - let remote_b = remote::Exchange::start(FrameRead::new(a_r), FrameWrite::new(a_w)); + let remote_b = remote::Exchange::start( + FrameRead::new(a_r), + FrameWrite::new(a_w), + Message::deserializer::<()>(), + ); let client = mirror(local_a, remote_b); let local_b = local::Exchange::start(b); - let remote_a = remote::Exchange::start(FrameRead::new(b_r), FrameWrite::new(b_w)); + let remote_a = remote::Exchange::start( + FrameRead::new(b_r), + FrameWrite::new(b_w), + Message::deserializer::<()>(), + ); let server = mirror(local_b, remote_a); // Both sides poll on the same current-thread task; no @@ -233,7 +232,7 @@ proptest! { let make_actions = |party_index: usize, forgets: &[bool]| -> Vec<_> { let p = nth_party(party_index); let mut version = Version::new(); - let mut actions: Vec<(Path, Version, Action<()>)> = Vec::new(); + let mut actions: Vec<(Path, Version, Action)> = Vec::new(); let mut paths: Vec = Vec::new(); for _ in forgets { version.tick(&p); @@ -257,11 +256,11 @@ proptest! { // The wrapper version must be a causal upper bound on every action // we apply — `Tree::react` maintains the same invariant by `|=`-ing // each action's version into the tree's version vector. - let wrap = |actions: &[(Path, Version, Action<()>)]| crate::tree::Root { + let wrap = |actions: &[(Path, Version, Action)]| crate::tree::Root { ceiling: actions .iter() .fold(Version::default(), |acc, (_, v, _)| acc | v.clone()), - root: act(None, actions.to_vec(), |_| ()), + root: act(None, actions.to_vec(), &mut |_| ()), }; let tree_a = wrap(&actions_a); @@ -317,11 +316,19 @@ fn uncontained_supply_is_rejected() { let (b_r, b_w) = tokio::io::split(b_side); let local_receiver = local::Exchange::start(receiver); - let remote_b = remote::Exchange::start(FrameRead::new(a_r), FrameWrite::new(a_w)); + let remote_b = remote::Exchange::start( + FrameRead::new(a_r), + FrameWrite::new(a_w), + Message::deserializer::<()>(), + ); let receiver_side = mirror(local_receiver, remote_b); let local_poisoned = local::Exchange::start(poisoned); - let remote_a = remote::Exchange::start(FrameRead::new(b_r), FrameWrite::new(b_w)); + let remote_a = remote::Exchange::start( + FrameRead::new(b_r), + FrameWrite::new(b_w), + Message::deserializer::<()>(), + ); let poisoned_side = mirror(local_poisoned, remote_a); let (receiver_result, poisoned_result) = tokio::join!(receiver_side, poisoned_side); diff --git a/src/tree/mirror/alternating/wire_snapshot.rs b/src/tree/mirror/alternating/wire_snapshot.rs index 4af0e03c1..6289103bd 100644 --- a/src/tree/mirror/alternating/wire_snapshot.rs +++ b/src/tree/mirror/alternating/wire_snapshot.rs @@ -53,7 +53,7 @@ fn prefix_from_bytes(bytes: &[u8]) -> Prefix { wire::from_slice(bytes).expect("known-valid prefix bytes") } -fn leaf(party: &str, version: u64) -> Node<(), Z> { +fn leaf(party: &str, version: u64) -> Node { Node::leaf(ticked(party, version), Message::new(())) } @@ -118,7 +118,7 @@ fn prefix_z_full_32_bytes() { insta::assert_snapshot!(snap(&prefix_from_bytes::(&bytes))); } -// ---------- Node: leaf ---------- +// ---------- Node: leaf ---------- /// A bare leaf node: version then message payload. #[test] @@ -129,17 +129,17 @@ fn node_z_leaf() { /// A leaf at the empty `Version`: the degenerate-timestamp encoding. #[test] fn node_z_leaf_empty_version() { - let l: Node<(), Z> = Node::leaf(Version::default(), Message::new(())); + let l: Node = Node::leaf(Version::default(), Message::new(())); insta::assert_snapshot!(snap(&l)); } -// ---------- Node> ---------- +// ---------- Node> ---------- /// A path-compressed single child: the compressed prefix byte rides the /// node, not a materialized intermediate level. #[test] fn node_s_z_singleton_path_compressed_leaf() { - let n: Node<(), S> = Node::beneath(leaf("a", 1), 0xab); + let n: Node> = Node::beneath(leaf("a", 1), 0xab); insta::assert_snapshot!(snap(&n)); } @@ -147,29 +147,29 @@ fn node_s_z_singleton_path_compressed_leaf() { /// ascending-radix order. #[test] fn node_s_z_two_child_branch() { - let children: Children<(), Z> = [(0x00, leaf("a", 1)), (0xff, leaf("a", 2))] + let children: Children = [(0x00, leaf("a", 1)), (0xff, leaf("a", 2))] .into_iter() .collect(); - let n = Node::<(), S>::branch(children).unwrap(); + let n = Node::>::branch(children).unwrap(); insta::assert_snapshot!(snap(&n)); } /// The saturated 256-child branch: the maximum fan-out boundary. #[test] fn node_s_z_full_256_child_branch() { - let children: Children<(), Z> = (0u16..=255) + let children: Children = (0u16..=255) .map(|i| (i as u8, leaf("a", i as u64 + 1))) .collect(); - let n = Node::<(), S>::branch(children).unwrap(); + let n = Node::>::branch(children).unwrap(); insta::assert_snapshot!(snap(&n)); } -// ---------- Node ---------- +// ---------- Node ---------- /// The empty tree (`None` root): the smallest possible encoding. #[test] fn node_root_none() { - let n: Option> = None; + let n: Option> = None; insta::assert_snapshot!(snap(&n)); } @@ -181,7 +181,7 @@ fn node_root_single_leaf_full_compression() { seq_macro::seq!(I in 0..32 { let n = Node::beneath(n, I); }); - let n: Node<(), Root> = n; + let n: Node = n; insta::assert_snapshot!(snap(&n)); } @@ -206,8 +206,8 @@ fn node_root_two_leaves_branched_at_root() { }); n }; - let children: Children<(), _> = [(0x01, n0), (0x02, n1)].into_iter().collect(); - Node::<(), Root>::branch(children).unwrap() + let children: Children<_> = [(0x01, n0), (0x02, n1)].into_iter().collect(); + Node::::branch(children).unwrap() }; insta::assert_snapshot!(snap(&n)); } @@ -262,7 +262,7 @@ fn message_opening_one_entry() { /// An `Exchange` with all three sets empty: the in-band termination shape. #[test] fn message_exchange_empty() { - let m: message::Exchange<(), UnderRoot> = message::Exchange::default(); + let m: message::Exchange = message::Exchange::default(); insta::assert_snapshot!(snap(&m)); } @@ -270,12 +270,12 @@ fn message_exchange_empty() { /// `requested`, and `uncertain` — the full steady-state round shape. #[test] fn message_exchange_populated() { - let leaf_z: Node<(), Z> = leaf("a", 1); - let inner: Node<(), S> = Node::beneath(leaf_z, 0xab); - let other_children: Children<(), S> = + let leaf_z: Node = leaf("a", 1); + let inner: Node> = Node::beneath(leaf_z, 0xab); + let other_children: Children> = [(0x01, inner.clone()), (0x02, inner)].into_iter().collect(); - let s_s_z = Node::<(), S>>::branch(other_children).unwrap(); - let n_root: Node<(), Root> = { + let s_s_z = Node::>>::branch(other_children).unwrap(); + let n_root: Node = { let n = s_s_z; seq_macro::seq!(I in 0..30 { let n = Node::beneath(n, I); @@ -292,7 +292,7 @@ fn message_exchange_populated() { Hash([3u8; MERKLE_HASH_LEN]), )]; - let m: message::Exchange<(), UnderRoot> = message::Exchange { + let m: message::Exchange = message::Exchange { providing, requested, uncertain, @@ -303,7 +303,7 @@ fn message_exchange_populated() { /// An empty `Closing`: the responder's closing round with nothing left. #[test] fn message_closing_empty() { - let m: message::Closing<()> = message::Closing::default(); + let m: message::Closing = message::Closing::default(); insta::assert_snapshot!(snap(&m)); } @@ -312,7 +312,7 @@ fn message_closing_empty() { fn message_closing_populated() { let providing = vec![(prefix_from_bytes::(&[0u8; 32]), leaf("a", 1))]; let requested = vec![prefix_from_bytes::(&[0xffu8; 32])]; - let m: message::Closing<()> = message::Closing { + let m: message::Closing = message::Closing { providing, requested, }; @@ -322,7 +322,7 @@ fn message_closing_populated() { /// An empty `Complete`: the initiator's sign-off with nothing owed. #[test] fn message_complete_empty() { - let m: message::Complete<()> = message::Complete::default(); + let m: message::Complete = message::Complete::default(); insta::assert_snapshot!(snap(&m)); } @@ -330,6 +330,6 @@ fn message_complete_empty() { #[test] fn message_complete_populated() { let providing = vec![(prefix_from_bytes::(&[0u8; 32]), leaf("a", 1))]; - let m: message::Complete<()> = message::Complete { providing }; + let m: message::Complete = message::Complete { providing }; insta::assert_snapshot!(snap(&m)); } diff --git a/src/tree/mirror/streaming.rs b/src/tree/mirror/streaming.rs index e5e389346..88baf7129 100644 --- a/src/tree/mirror/streaming.rs +++ b/src/tree/mirror/streaming.rs @@ -16,6 +16,9 @@ //! binding. //! - [`window`]: how one byte budget becomes per-height channel capacities. //! - [`message`]: the wire vocabulary, the greeting included. +//! - [`erased`]: the height-erased seam both implementors run on — the +//! wire vocabulary's erased twin, its typed exits, and the dispatch +//! back into the height-typed backend surface. //! - [`convert`]: the leaf conversion boundary between backends. //! - [`driver`], [`channel`], [`tasks`]: plumbing — phase scheduling and //! error routing, named bounded edges, task completion. @@ -45,6 +48,7 @@ mod backend; mod channel; pub(crate) mod convert; mod driver; +mod erased; pub mod materialized; mod message; mod protocol; @@ -55,7 +59,7 @@ mod tasks; mod testing; pub(crate) mod window; -pub use backend::{Backend, Leaf, Local, Node, Root}; +pub use backend::{Backend, ErasedNode, Leaf, Local, Node, Root}; // The stream vocabulary the backend conformance suite decorates with; // crate-visible alongside the suite itself. #[cfg(test)] @@ -72,18 +76,17 @@ use crate::{Version, tree::typed::height::Z}; use driver::{mirror_connected, try_join_mapped}; use protocol::*; -type ClientConnected = <>::Next as CompleteConnect>::Next; -type ServerConnected = >::Next; +type ClientConnected = <>::Next as CompleteConnect>::Next; +type ServerConnected = >::Next; -pub(crate) struct Handshaken +pub(crate) struct Handshaken where - T: Send + Sync + 'static, - B: Backend: Leaf>, - C: Client, - S: Server, + B: Backend: Leaf>, + C: Client, + S: Server, { - client: ClientConnected, - server: ServerConnected, + client: ClientConnected, + server: ServerConnected, our_version: Version, /// Our advertised live message count: our half of the role election's /// primary key ([`message::initiates`]). @@ -91,12 +94,11 @@ where peer: message::Greeting, } -impl Handshaken +impl Handshaken where - T: Send + Sync + 'static, - B: Backend: Leaf>, - C: Client, - S: Server, + B: Backend: Leaf>, + C: Client, + S: Server, { pub(crate) fn peer(&self) -> &message::Greeting { let Handshaken { peer, .. } = self; @@ -140,29 +142,27 @@ where /// vocabulary crossing between them. Equal handshake versions resolve both /// connected states without opening the descent. #[cfg(test)] -pub(crate) async fn mirror( +pub(crate) async fn mirror( client: C, server: S, ) -> Result<(C::Output, S::Output), Error> where - T: Send + Sync + 'static, - B: Backend: Leaf>, - C: Client, - S: Server, + B: Backend: Leaf>, + C: Client, + S: Server, { handshake(client, server).await?.reconcile().await } /// Exchange versions and return both connected protocol states. -pub(crate) async fn handshake( +pub(crate) async fn handshake( client: C, server: S, -) -> Result, Error> +) -> Result, Error> where - T: Send + Sync + 'static, - B: Backend: Leaf>, - C: Client, - S: Server, + B: Backend: Leaf>, + C: Client, + S: Server, { let (our_handshake, client) = client.connect().await.map_err(Error::Client)?; let our_version = our_handshake.version.clone(); @@ -183,7 +183,7 @@ where } /// Elect the initiator from the exchanged greetings and reconcile or complete. -pub(crate) async fn descend( +pub(crate) async fn descend( local: L, remote: R, local_version: Version, @@ -192,10 +192,9 @@ pub(crate) async fn descend( remote_len: u64, ) -> Result<(L::Output, R::Output), Error> where - T: Send + Sync + 'static, - B: Backend: Leaf>, - L: Peer, - R: Peer, + B: Backend: Leaf>, + L: Peer, + R: Peer, { if local_version == remote_version { return try_join_mapped( diff --git a/src/tree/mirror/streaming/backend.rs b/src/tree/mirror/streaming/backend.rs index 71c69db75..f2fa2c393 100644 --- a/src/tree/mirror/streaming/backend.rs +++ b/src/tree/mirror/streaming/backend.rs @@ -43,16 +43,50 @@ pub use local::Local; pub(super) use local::with_schedule as with_local_schedule; /// A backend value is a cheap cloneable *handle* to its storage. -pub trait Backend: Clone + Send + Sync + 'static +pub trait Backend: Clone + Send + Sync + 'static where - Self::Node: Leaf, + Self::Node: Leaf, { - /// The type of nodes carrying messages of type `T`, indexed by height `H`. - type Node: Node + Clone + Send + 'static; + /// The type of nodes, indexed by height `H`; leaf payloads are + /// erased in storage and decode as `T` at the wire boundary. + type Node: Node + Clone + Send + 'static; + + /// One runtime representation shared by every height's + /// [`Node`](Self::Node). + /// + /// The height parameter on [`Node`](Self::Node) is a compile-time tag + /// over runtime data that already knows its place in the tree, so a + /// backend can forget the tag ([`erase`](Self::erase)) and restore it + /// ([`assume`](Self::assume)) without changing the value. The + /// session's internal plumbing — its channels, pumps, and walk workers + /// — carries this one type where a height-typed payload would + /// instantiate the whole machinery once per height. + /// + /// A backend whose nodes share one representation across heights (as + /// [`Local`]'s do) uses that representation directly; a backend with + /// genuinely distinct per-height representations supplies a sum of + /// them. + type Erased: ErasedNode + Clone + Send + 'static; /// The type of errors returned by this backend. type Error: Send + 'static; + /// Forget a node's height tag. + fn erase(node: Self::Node) -> Self::Erased; + + /// Re-tag an erased node at height `H`. + /// + /// `H` must be the height the node was erased at: + /// `assume::(erase::(node)) == node` is the whole contract, and + /// a cross-height re-tag is a programmer error whose behavior is + /// unspecified. The pairing discipline lives inside the session's + /// erased plumbing, witnessed by the prefixes that travel alongside + /// every node: an erased prefix's byte length is its height, and + /// re-tagging one debug-asserts that length. Peer input cannot reach + /// a mispairing — every wire node decodes through height-typed + /// readers before it is ever erased. + fn assume(erased: Self::Erased) -> Self::Node; + /// Bytes one node value with `children` child entries keeps resident /// beyond the replica's own storage, its version bounds (ceiling and /// floor together) encoding within `version_bound` bytes. @@ -116,7 +150,7 @@ where self, prefix: Prefix>, parent: Self::Node>, - ) -> impl NodeStream + ) -> impl NodeStream where H: Height, S: Height; @@ -135,7 +169,7 @@ where self, prefix: Prefix, node: Self::Node, - ) -> impl NodeStream { + ) -> impl NodeStream { H::explode( self, Box::pin(stream::once(async move { Ok((prefix, node)) })), @@ -163,16 +197,16 @@ where /// session can meet it. fn assemble<'a, H: Convert>( self, - leaves: BoxNodeStream<'a, Self, T, Z>, - ) -> impl NodeStream + 'a { + leaves: BoxNodeStream<'a, Self, Z>, + ) -> impl NodeStream + 'a { H::assemble(self, leaves) } } /// The inspection operations of a backend's individual node type. -pub trait Node { +pub trait Node { /// The backend to which this node belongs. - type Backend: Backend: Leaf, Node = Self>; + type Backend: Backend: Leaf, Node = Self>; /// The height of the node above the leaf level. type Height: Height; @@ -250,11 +284,30 @@ pub trait Node { fn version_bytes(&self) -> usize; } +/// The observations a session reads off an erased node: [`Node`]'s +/// height-independent surface. +/// +/// Every read here answers exactly as it would on the typed node the value +/// was erased from — none of [`Node`]'s observations ever depended on the +/// height tag, so forgetting it loses nothing. Height-*dependent* +/// operations (exploding to children, reading a leaf's message) stay on +/// the typed surface, reached by re-tagging ([`Backend::assume`]). +pub trait ErasedNode { + /// The node's version bounds as one causal span ([`Node::span`]). + fn span(&self) -> Span<'_>; + + /// The merkle hash of this node ([`Node::hash`]). + fn hash(&self) -> Hash; + + /// The number of live leaves under this node, exact ([`Node::len`]). + fn len(&self) -> usize; +} + /// What crosses between backends at the conversion boundary, and the one node /// shape every backend must represent faithfully. -pub trait Leaf: Node { +pub trait Leaf: Node { /// The message stored at this leaf node. - fn message(&self) -> &Message; + fn message(&self) -> &Message; /// Construct a leaf node from one decoded wire record, taking custody /// of its payload. @@ -286,34 +339,32 @@ pub trait Leaf: Node { /// (the reclaimable-garbage case). fn leaf( version: Version, - message: Message, - ) -> impl Future>::Error>> + Send + message: Message, + ) -> impl Future::Error>> + Send where Self: Sized; } /// Type synonym for a fallible [`Stream`] of prefix-keyed nodes represented by /// a given backend. -pub trait NodeStream: Leaf>, T: Send + Sync + 'static, H: Height>: +pub trait NodeStream: Leaf>, H: Height>: Stream, B::Node), B::Error>> + Send { } -impl: Leaf>, T: Send + Sync + 'static, H: Height> NodeStream - for N -where - N: Stream, B::Node), B::Error>> + Send, +impl: Leaf>, H: Height> NodeStream for N where + N: Stream, B::Node), B::Error>> + Send { } /// A [`NodeStream`] erased to one level of type depth. -pub(crate) type BoxNodeStream<'a, B, T, H> = Pin + 'a>>; +pub(crate) type BoxNodeStream<'a, B, H> = Pin + 'a>>; /// A backend's whole tree at rest: what a mirror session consumes and produces. /// /// This is the backend-generic form of [`tree::Root`](crate::tree::Root); the /// `Local` backend converts between the two with [`From`]. #[derive(Debug)] -pub struct Root: Leaf>, T: Send + Sync + 'static> { +pub struct Root: Leaf>> { /// The maximum version this tree has incorporated. pub ceiling: Version, /// The root node, or nothing when the tree is empty. @@ -322,7 +373,7 @@ pub struct Root: Leaf>, T: Send + Sync + 'static> { // Manual because the derive would demand `T: Clone`; nodes are cloneable // handles regardless of the message type they carry. -impl: Leaf>, T: Send + Sync + 'static> Clone for Root { +impl: Leaf>> Clone for Root { fn clone(&self) -> Self { Root { ceiling: self.ceiling.clone(), @@ -331,7 +382,7 @@ impl: Leaf>, T: Send + Sync + 'static> Clone for Root: Leaf>, T: Send + Sync + 'static> Root { +impl: Leaf>> Root { /// The tree's live message count: the root node's [`len`](Node::len) /// aggregate, or zero when empty. What the session greeting carries /// as the exact set size. diff --git a/src/tree/mirror/streaming/backend/local.rs b/src/tree/mirror/streaming/backend/local.rs index e0bec2975..862ff4323 100644 --- a/src/tree/mirror/streaming/backend/local.rs +++ b/src/tree/mirror/streaming/backend/local.rs @@ -13,7 +13,7 @@ use crate::{ tree::{ self, mirror::streaming::{ - Backend, Leaf, Node, Root, + Backend, ErasedNode, Leaf, Node, Root, backend::{BoxNodeStream, NodeStream}, convert::Convert, }, @@ -31,7 +31,7 @@ mod tests; #[cfg(test)] pub use adversarial::with_schedule; -impl Node for typed::Node { +impl Node for typed::Node { type Backend = Local; type Height = H; @@ -56,14 +56,30 @@ impl Node for typed::Node { } } -impl Leaf for typed::Node { - fn message(&self) -> &Message { +// The typed node is `repr(transparent)` over the untyped node, so the +// erased observations are the same field reads the typed ones are. +impl ErasedNode for typed::untyped::Node { + fn span(&self) -> Span<'_> { + self.span() + } + + fn hash(&self) -> typed::Hash { + self.hash() + } + + fn len(&self) -> usize { + self.len() + } +} + +impl Leaf for typed::Node { + fn message(&self) -> &Message { self.message() } // Custody is free: the handle owns the payload and the tree it will // join is resident regardless, so construction completes immediately. - async fn leaf(version: Version, message: Message) -> Result { + async fn leaf(version: Version, message: Message) -> Result { Ok(Self::leaf(version, message)) } } @@ -85,28 +101,34 @@ impl Local { /// height-typed veneer is `repr(transparent)` over that handle, so /// every height costs the same. pub(crate) fn node_bytes(_children: usize, _version_bound: usize) -> usize { - std::mem::size_of::>() + std::mem::size_of::>() } } /// The handle really is pointer-sized: the window's per-reference price /// rests on it. -const _: () = - assert!(std::mem::size_of::>() == std::mem::size_of::<*const ()>()); +const _: () = assert!(std::mem::size_of::>() == std::mem::size_of::<*const ()>()); -impl Backend for Local { - type Node = typed::Node; +impl Backend for Local { + type Node = typed::Node; + // One representation for every height already: the typed node is a + // phantom tag over this, so both conversions are field moves. + type Erased = typed::untyped::Node; type Error = Infallible; + fn erase(node: Self::Node) -> Self::Erased { + node.into_untyped() + } + + fn assume(erased: Self::Erased) -> Self::Node { + typed::Node::from_untyped(erased) + } + fn node_bytes(children: usize, version_bound: usize) -> usize { Local::node_bytes(children, version_bound) } - fn children( - self, - prefix: Prefix>, - parent: Self::Node>, - ) -> impl NodeStream + fn children(self, prefix: Prefix>, parent: Self::Node>) -> impl NodeStream where H: Height, S: Height, @@ -155,7 +177,7 @@ impl Backend for Local { self, prefix: Prefix, node: Self::Node, - ) -> impl NodeStream { + ) -> impl NodeStream { // The default level-by-level explosion pays an allocation per // *virtual* level — ruinous for path-compressed spines. In-memory // nodes walk their own leaves directly, skipping compressed spans. @@ -168,8 +190,8 @@ impl Backend for Local { fn assemble<'a, H: Convert>( self, - leaves: BoxNodeStream<'a, Self, T, Z>, - ) -> impl NodeStream + 'a { + leaves: BoxNodeStream<'a, Self, Z>, + ) -> impl NodeStream + 'a { // The bulk counterpart of `leaves`: buffer each maximal // same-prefix run and build its subtree in one pass, rather than // folding it up one virtual level at a time. The buffered run is @@ -179,7 +201,7 @@ impl Backend for Local { let assembled = try_stream! { let mut leaves = pin!(leaves); let mut current: Option> = None; - let mut run: Vec<(Prefix, typed::Node)> = Vec::new(); + let mut run: Vec<(Prefix, typed::Node)> = Vec::new(); while let Some(item) = leaves.next().await { let (prefix, leaf) = item?; let target = Prefix::::containing(&Path::from(prefix)); @@ -207,15 +229,15 @@ impl Backend for Local { // `tree::Root` is exactly the `Local` instance of the session's generic // `Root`: the same (ceiling, optional root node) pair, concretely typed. -impl From> for Root { - fn from(root: tree::Root) -> Self { +impl From for Root { + fn from(root: tree::Root) -> Self { let tree::Root { ceiling, root } = root; Root { ceiling, root } } } -impl From> for tree::Root { - fn from(root: Root) -> Self { +impl From> for tree::Root { + fn from(root: Root) -> Self { let Root { ceiling, root } = root; tree::Root { ceiling, root } } diff --git a/src/tree/mirror/streaming/backend/local/tests.rs b/src/tree/mirror/streaming/backend/local/tests.rs index bc6844ead..46f18e7fb 100644 --- a/src/tree/mirror/streaming/backend/local/tests.rs +++ b/src/tree/mirror/streaming/backend/local/tests.rs @@ -27,7 +27,7 @@ use crate::{ use super::Local; -type LeafRun = Vec<(Prefix, typed::Node<(), Z>)>; +type LeafRun = Vec<(Prefix, typed::Node)>; /// One distinct-version leaf per path, in the (ascending) order given. fn leaves_at(paths: impl IntoIterator) -> LeafRun { @@ -44,26 +44,26 @@ fn leaves_at(paths: impl IntoIterator) -> LeafRun { .collect() } -fn boxed(run: LeafRun) -> BoxNodeStream<'static, Local, (), Z> { +fn boxed(run: LeafRun) -> BoxNodeStream<'static, Local, Z> { Box::pin(stream::iter(run.into_iter().map(Ok::<_, Infallible>))) } /// Assemble through the level-by-level default, bypassing Local's override. -fn assemble_default(run: LeafRun) -> Vec<(Prefix, typed::Node<(), H>)> { +fn assemble_default(run: LeafRun) -> Vec<(Prefix, typed::Node)> { pollster::block_on(H::assemble(Local, boxed(run)).try_collect()) .unwrap_or_else(|error| match error {}) } /// Assemble through the backend seam, which Local overrides in bulk. -fn assemble_local(run: LeafRun) -> Vec<(Prefix, typed::Node<(), H>)> { +fn assemble_local(run: LeafRun) -> Vec<(Prefix, typed::Node)> { pollster::block_on(Local.assemble::(boxed(run)).try_collect()) .unwrap_or_else(|error| match error {}) } /// Explode through the level-by-level default, bypassing Local's override. -fn leaves_default(prefix: Prefix, node: typed::Node<(), H>) -> LeafRun { +fn leaves_default(prefix: Prefix, node: typed::Node) -> LeafRun { pollster::block_on( - H::explode( + H::explode::<_>( Local, Box::pin(stream::once(async move { Ok((prefix, node)) })), ) @@ -73,8 +73,8 @@ fn leaves_default(prefix: Prefix, node: typed::Node<(), H>) -> Le } /// Walk leaves through the backend seam, which Local overrides directly. -fn leaves_local(prefix: Prefix, node: typed::Node<(), H>) -> LeafRun { - pollster::block_on(Local.leaves(prefix, node).try_collect()) +fn leaves_local(prefix: Prefix, node: typed::Node) -> LeafRun { + pollster::block_on(::leaves(Local, prefix, node).try_collect()) .unwrap_or_else(|error| match error {}) } diff --git a/src/tree/mirror/streaming/convert.rs b/src/tree/mirror/streaming/convert.rs index da55a940e..5dc7eda6e 100644 --- a/src/tree/mirror/streaming/convert.rs +++ b/src/tree/mirror/streaming/convert.rs @@ -28,32 +28,28 @@ use crate::tree::{ pub trait Convert: Height { /// Disassemble a stream of `backend`'s nodes at this height into the /// prefix-ordered stream of every leaf beneath them. - fn explode(backend: B, stream: BoxNodeStream) -> BoxNodeStream + fn explode(backend: B, stream: BoxNodeStream) -> BoxNodeStream where - B: Backend: Leaf>, - T: Send + Sync + 'static; + B: Backend: Leaf>; /// Assemble a prefix-ordered leaf stream into the stream of `backend`'s /// nodes at this height. - fn assemble(backend: B, leaves: BoxNodeStream) -> BoxNodeStream + fn assemble(backend: B, leaves: BoxNodeStream) -> BoxNodeStream where - B: Backend: Leaf>, - T: Send + Sync + 'static; + B: Backend: Leaf>; } impl Convert for Z { - fn explode(_backend: B, stream: BoxNodeStream) -> BoxNodeStream + fn explode(_backend: B, stream: BoxNodeStream) -> BoxNodeStream where - B: Backend: Leaf>, - T: Send + Sync + 'static, + B: Backend: Leaf>, { stream } - fn assemble(_backend: B, leaves: BoxNodeStream) -> BoxNodeStream + fn assemble(_backend: B, leaves: BoxNodeStream) -> BoxNodeStream where - B: Backend: Leaf>, - T: Send + Sync + 'static, + B: Backend: Leaf>, { leaves } @@ -64,13 +60,12 @@ where H: Convert, S: Height, { - fn explode(backend: B, stream: BoxNodeStream>) -> BoxNodeStream + fn explode(backend: B, stream: BoxNodeStream>) -> BoxNodeStream where - B: Backend: Leaf>, - T: Send + Sync + 'static, + B: Backend: Leaf>, { let exploded = backend.clone(); - let below: BoxNodeStream = Box::pin(try_stream! { + let below: BoxNodeStream = Box::pin(try_stream! { for await item in stream { let (prefix, node) = item?; for await child in exploded.clone().children::(prefix, node) { @@ -81,10 +76,9 @@ where H::explode(backend, below) } - fn assemble(backend: B, leaves: BoxNodeStream) -> BoxNodeStream> + fn assemble(backend: B, leaves: BoxNodeStream) -> BoxNodeStream> where - B: Backend: Leaf>, - T: Send + Sync + 'static, + B: Backend: Leaf>, { let below = H::assemble(backend.clone(), leaves); let folded = fold_parents(backend, below); @@ -101,24 +95,19 @@ where /// Reassemble an ascending child stream into its parent level, one complete /// radix group at a time: a group flushes when the prefix changes or the /// input ends. -fn fold_parents( - backend: B, - children: impl NodeStream, -) -> impl NodeStream> +fn fold_parents(backend: B, children: impl NodeStream) -> impl NodeStream> where - B: Backend: Leaf>, - T: Send + Sync + 'static, + B: Backend: Leaf>, H: Height, S: Height, { /// Flush a completed group, if any, into its parent. - async fn flush( + async fn flush( backend: &B, finished: Option<(Prefix>, Vec<(u8, Option>)>)>, ) -> Result>, B::Node>)>, B::Error> where - B: Backend: Leaf>, - T: Send + Sync + 'static, + B: Backend: Leaf>, H: Height, S: Height, { diff --git a/src/tree/mirror/streaming/convert/tests.rs b/src/tree/mirror/streaming/convert/tests.rs index 5a6b9b7f3..3bf5e8cfb 100644 --- a/src/tree/mirror/streaming/convert/tests.rs +++ b/src/tree/mirror/streaming/convert/tests.rs @@ -21,7 +21,7 @@ use crate::{ /// A distinct leaf per call: content is irrelevant to grouping; the versions /// only need to differ so the resulting parents' hashes do. -fn leaf(version: &mut Version) -> typed::Node<(), Z> { +fn leaf(version: &mut Version) -> typed::Node { version.tick(&nth_party(0)); typed::Node::leaf(version.clone(), Message::new(())) } @@ -42,27 +42,23 @@ fn parent_prefix(parent: u8) -> Prefix> { /// Build the expected parent of a radix group directly through the backend, /// bypassing the fold under test. -fn parent_of( - prefix: Prefix>, - children: Vec<(u8, typed::Node<(), Z>)>, -) -> typed::Node<(), S> { - pollster::block_on( - Local.parent( - prefix, - children - .into_iter() - .map(|(radix, child)| (radix, Some(child))) - .collect(), - ), - ) +fn parent_of(prefix: Prefix>, children: Vec<(u8, typed::Node)>) -> typed::Node> { + pollster::block_on(::parent( + Local, + prefix, + children + .into_iter() + .map(|(radix, child)| (radix, Some(child))) + .collect(), + )) .unwrap_or_else(|e| match e {}) .expect("a non-empty all-real group always constructs its parent") } /// Drive the fold of an in-memory child stream to completion. -fn fold(children: Vec<(Prefix, typed::Node<(), Z>)>) -> Vec<(Prefix>, Hash)> { +fn fold(children: Vec<(Prefix, typed::Node)>) -> Vec<(Prefix>, Hash)> { pollster::block_on( - fold_parents::( + fold_parents::( Local, stream::iter(children.into_iter().map(Ok::<_, Infallible>)), ) @@ -86,7 +82,7 @@ proptest! { ) { let mut version = Version::new(); let mut input = Vec::new(); - let mut expected_groups: Vec<(u8, Vec<(u8, typed::Node<(), Z>)>)> = Vec::new(); + let mut expected_groups: Vec<(u8, Vec<(u8, typed::Node)>)> = Vec::new(); for (parent, radix) in keys { let node = leaf(&mut version); input.push((key(parent, radix), node.clone())); diff --git a/src/tree/mirror/streaming/driver.rs b/src/tree/mirror/streaming/driver.rs index 65902ec59..ba3d4e19f 100644 --- a/src/tree/mirror/streaming/driver.rs +++ b/src/tree/mirror/streaming/driver.rs @@ -149,15 +149,14 @@ macro_rules! mirror { } /// Drive the complete reconciliation schedule between two connected peers. -pub(super) async fn mirror_connected( +pub(super) async fn mirror_connected( i: I, r: R, ) -> Result<(I::Output, R::Output), Error> where - T: Send + Sync + 'static, - B: Backend: Leaf>, - I: Peer, - R: Peer, + B: Backend: Leaf>, + I: Peer, + R: Peer, { mirror! { i.initiator; @@ -177,13 +176,12 @@ where /// On error the stream parks rather than ending, because EOF means successful /// phase completion to its consumer. [`race_session`] observes the routed /// error and cancels the parked schedule. -fn divert( - messages: impl Responses, +fn divert( + messages: impl Responses, route: ErrorRoute, -) -> impl Requests +) -> impl Requests where - T: Send + Sync + 'static, - B: Backend: Leaf>, + B: Backend: Leaf>, H: Height, E: Send + 'static, D: Send + 'static, diff --git a/src/tree/mirror/streaming/erased.rs b/src/tree/mirror/streaming/erased.rs new file mode 100644 index 000000000..b59ff85d4 --- /dev/null +++ b/src/tree/mirror/streaming/erased.rs @@ -0,0 +1,335 @@ +//! The height-erased seam of the streaming session: the wire vocabulary's +//! erased twin, the typed exits, and the dispatch back into the typed +//! backend surface. +//! +//! The materialized walk's payloads and workers are height-uniform at +//! runtime: nodes erase to one representation per backend +//! ([`Backend::Erased`]), prefixes to their bytes +//! ([`ErasedPrefix`](crate::tree::typed::ErasedPrefix)), and nothing else +//! in a payload ever depended on the height. The walk therefore runs on +//! erased values — one instantiation of its channels, generators, and +//! loops per backend, instead of one per height — while the protocol +//! schedule around it stays fully typed. This module owns the seam +//! between the two: +//! +//! - [`Reply`] and [`Reaction`] are [`message::Reply`]'s and +//! [`message::Reaction`]'s erased twins, converted exactly at the +//! schedule boundary: [`erase_reply`] where a typed request stream +//! enters a walk worker, and [`reply_channel`]'s typed exit where a +//! worker's responses become the schedule's typed response stream. +//! Every conversion is a phantom-tag swap over values the program +//! already holds. +//! - [`ops`] carries erased node operations back into the height-typed +//! [`Backend`] surface, selecting the type-level height from the one +//! runtime witness every erased scope carries: its prefix's byte +//! length. +//! +//! # What the types stop proving, and what catches it instead +//! +//! Outside the walk, pairing a height-5 payload with a height-6 consumer +//! is a compile error, exactly as before: the schedule's typestates and +//! message streams remain height-typed, and this module's two boundary +//! conversions are minted at one height parameter apiece. Inside the +//! walk, height agreement is a runtime-witnessed property: every prefix +//! re-tag debug-asserts its byte length against the claimed height, the +//! [`ops`] dispatch derives its height from that same length (so the +//! coordinate and the witness cannot drift apart), and every channel +//! keeps its [`QueueRole`] height label for +//! the instrumented diagnostics. The behavioral pins — the alternating +//! oracle, the violation and capacity suites, the byte-pinned wire +//! snapshots — exercise exactly these pairings. + +use std::pin::Pin; +use std::task::{Context, Poll}; + +use futures::Stream; +#[cfg(not(test))] +use tokio_stream::wrappers::ReceiverStream; + +use crate::tree::{ + mirror::streaming::{ + Backend, Leaf, + channel::{QueueRole, Receiver, Sender, channel}, + message, + }, + typed::{ + Hash, + height::{Height, Z}, + }, +}; + +/// [`message::Reply`] with its height forgotten: what the walk's workers +/// produce and consume. +pub(crate) struct Reply { + pub replies: Vec>, +} + +/// [`message::Reaction`] with its height forgotten. +pub(crate) enum Reaction { + Supply(u8, E), + Match, + Query(Vec<(u8, Hash)>), +} + +/// Erase one typed reply where a schedule-typed request stream enters a +/// walk worker. +pub(crate) fn erase_reply(reply: message::Reply) -> Reply +where + B: Backend: Leaf>, + H: Height, +{ + Reply { + replies: reply + .replies + .into_iter() + .map(|reaction| match reaction { + message::Reaction::Supply(radix, node) => Reaction::Supply(radix, B::erase(node)), + message::Reaction::Match => Reaction::Match, + message::Reaction::Query(listing) => Reaction::Query(listing), + }) + .collect(), + } +} + +/// Re-tag one erased reply at the typed exit of [`reply_channel`]. +fn assume_reply(reply: Reply) -> message::Reply +where + B: Backend: Leaf>, + H: Height, +{ + message::Reply { + replies: reply + .replies + .into_iter() + .map(|reaction| match reaction { + Reaction::Supply(radix, node) => message::Reaction::Supply(radix, B::assume(node)), + Reaction::Match => message::Reaction::Match, + Reaction::Query(listing) => message::Reaction::Query(listing), + }) + .collect(), + } +} + +/// The typed exit of [`reply_channel`]: the erased receiver as a stream +/// of schedule-typed replies. `Err` is the session's error type, passed +/// through untouched. +pub(crate) struct ReplyResultStream +where + B: Backend: Leaf>, + H: Height, +{ + inner: ReceiverStreamOf, Err>>, + assume: fn(Result, Err>) -> Result, Err>, +} + +impl Stream for ReplyResultStream +where + B: Backend: Leaf>, + H: Height, + Err: Send, +{ + type Item = Result, Err>; + + fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let this = self.get_mut(); + Pin::new(&mut this.inner) + .poll_next(cx) + .map(|item| item.map(this.assume)) + } +} + +/// The channel receiver as a stream, uniform across the test and +/// production channel types. +#[cfg(test)] +type ReceiverStreamOf = Receiver; +/// The channel receiver as a stream, uniform across the test and +/// production channel types. +#[cfg(not(test))] +type ReceiverStreamOf = ReceiverStream; + +fn receiver_stream(receiver: Receiver) -> ReceiverStreamOf { + #[cfg(test)] + { + receiver + } + #[cfg(not(test))] + { + ReceiverStream::new(receiver) + } +} + +/// Mint the outgoing-response edge: an erased sender for the response +/// pump, and the typed response stream the schedule consumes. +/// +/// The one edge whose two halves speak different vocabularies — erased +/// in, typed out — and therefore the outgoing half of the walk's typed +/// boundary. Its height parameter fixes the exit's re-tag; the erased +/// sender needs none. +pub(crate) fn reply_channel( + role: QueueRole, + capacity: usize, +) -> ( + Sender, Err>>, + ReplyResultStream, +) +where + B: Backend: Leaf>, + H: Height, + Err: Send, +{ + let (sender, receiver) = channel(role, capacity); + ( + sender, + ReplyResultStream { + inner: receiver_stream(receiver), + assume: |item| item.map(assume_reply::), + }, + ) +} + +/// Erased node operations dispatched back into the height-typed +/// [`Backend`] surface. +/// +/// The backend's traversal operations are generic over type-level +/// heights; an erased caller selects the height at runtime from the one +/// witness every erased scope carries — its prefix's byte length — via a +/// 33-arm match whose arms each instantiate one thin typed call. Deriving +/// the height from the prefix (rather than threading a separate counter) +/// is what keeps the coordinate and the witness structurally inseparable. +pub(crate) mod ops { + use futures::StreamExt; + + use super::*; + use crate::tree::{ + mirror::streaming::{ + backend::BoxNodeStream, materialized::children_of as children_of_typed, + }, + typed::{ErasedPrefix, height::Pred}, + }; + + /// Select the type-level height matching a runtime *parent* height. + /// + /// `$H` is bound to the parent's height type within `$body`, and + /// `<$H as Pred>::Pred` names the children's. Each arm monomorphizes + /// the body once, so bodies must stay thin. + macro_rules! at_parent_height { + ($height:expr, $H:ident => $body:expr) => {{ + seq_macro::seq!(N in 1..=32 { + match $height { + 0 => unreachable!("a leaf-height node has no children"), + #(N => { type $H = crate::tree::typed::height::H~N; $body })* + _ => unreachable!("a tree height is 0..=32"), + } + }) + }}; + } + + /// Select the type-level height matching a runtime height, leaves + /// included: [`at_parent_height!`] minus the nonzero premise. + macro_rules! at_height { + ($height:expr, $H:ident => $body:expr) => {{ + seq_macro::seq!(N in 0..=32 { + match $height { + #(N => { type $H = crate::tree::typed::height::H~N; $body })* + _ => unreachable!("a tree height is 0..=32"), + } + }) + }}; + } + + /// Collect one erased node's children, addressed by radix + /// ([`children_of_typed`], erased). + /// + /// `prefix` is the node's own prefix; its length names the height the + /// node is re-tagged at, so a walk cannot explode a node at any level + /// other than the one its coordinate claims. + pub(crate) async fn children_of( + backend: &B, + prefix: ErasedPrefix, + node: B::Erased, + ) -> Result, B::Error> + where + B: Backend: Leaf>, + { + at_parent_height!(prefix.height(), H => { + let children = + children_of_typed::::Pred>( + backend, + prefix.assume::(), + B::assume::(node), + ) + .await?; + Ok(children + .into_iter() + .map(|(radix, child)| (radix, B::erase(child))) + .collect()) + }) + } + + /// Assemble one erased parent node at `prefix` from one radix-keyed + /// child group ([`Backend::parent`], erased). + /// + /// `prefix` is the parent's own prefix, carrying the same + /// length-is-height witness as [`children_of`]. + pub(crate) async fn parent( + backend: B, + prefix: ErasedPrefix, + children: Vec<(u8, Option)>, + ) -> Result, B::Error> + where + B: Backend: Leaf>, + { + at_parent_height!(prefix.height(), H => { + let children = children + .into_iter() + .map(|(radix, child)| (radix, child.map(B::assume::<::Pred>))) + .collect(); + Ok(backend + .parent::<::Pred>(prefix.assume::(), children) + .await? + .map(B::erase)) + }) + } + + /// Walk every leaf beneath an erased node, in ascending path order + /// ([`Backend::leaves`], erased). + /// + /// The yielded leaves stay typed: `Z` is a single height, so nothing + /// about a leaf ever needed erasing. + pub(crate) fn leaves( + backend: B, + prefix: ErasedPrefix, + node: B::Erased, + ) -> BoxNodeStream<'static, B, Z> + where + B: Backend: Leaf>, + { + at_height!(prefix.height(), H => { + Box::pin(backend.leaves::(prefix.assume::(), B::assume::(node))) + }) + } + + /// Assemble a strictly ascending leaf stream into erased nodes at + /// `height`, one per maximal same-prefix run, in run order + /// ([`Backend::assemble`], erased). + /// + /// The one dispatch keyed by an explicit height rather than a prefix: + /// its input is a whole leaf stream, and the target height is the + /// consuming scope's — whose prefix length the caller derives it from. + pub(crate) fn assemble( + backend: B, + height: usize, + leaves: BoxNodeStream<'static, B, Z>, + ) -> Pin> + Send>> + where + B: Backend: Leaf>, + { + at_height!(height, H => { + Box::pin( + backend + .assemble::(leaves) + .map(|item| item.map(|(prefix, node)| (prefix.erase(), B::erase(node)))), + ) + }) + } +} diff --git a/src/tree/mirror/streaming/materialized.rs b/src/tree/mirror/streaming/materialized.rs index 931491c37..a3f75a633 100644 --- a/src/tree/mirror/streaming/materialized.rs +++ b/src/tree/mirror/streaming/materialized.rs @@ -105,16 +105,17 @@ use std::sync::atomic::{AtomicU64, Ordering}; use crate::tree::{ mirror::contained, mirror::streaming::{ - Backend, Leaf, Node, Root, - materialized::{unknown::Unknown, work::Work}, - message::{Greeting, Reaction, Reply}, + Backend, ErasedNode, Leaf, Root, + erased::{self, Reaction, Reply}, + materialized::work::Work, + message::Greeting, protocol::{self, BoxResponses, Requests}, remote::DEFAULT_TARGET_MESSAGE_SIZE, stats::Recorder, window::WindowConfig, }, typed::{ - Hash, Prefix, + ErasedPrefix, Hash, Prefix, height::{self, Height, S, UnderRoot, UnderUnderRoot, Z}, }, }; @@ -252,38 +253,35 @@ impl SupplyLedger { /// queue between consecutive same-side stages, and the in-process twin of /// the wire's expected scopes. /// -/// `H` is the children's height; the scope sits at `S`, so -/// `Query<_, _, H>` pairs with [`Reply<_, _, H>`](Reply). +/// `E` is the backend's erased node representation +/// ([`Backend::Erased`]); the prefix names the queried scope, and its +/// byte length is the scope's height witness (see +/// [`erased`]). A query pairs with the reply at its +/// children's height, one level below the prefix. /// /// If we issued a request for a node, `ours` is empty and we expect the /// reply to consist entirely of supplied nodes. -pub struct Query: Leaf>, T: Send + Sync + 'static, H: Height> -where - S: Height, -{ +pub struct Query { /// The prefix at which the resolved node will sit. - pub prefix: Prefix>, + pub prefix: ErasedPrefix, /// Our children of the node (empty if we don't have it at all). - pub ours: Vec<(u8, B::Node)>, + pub ours: Vec<(u8, E)>, } /// One scope's resolution: its children in radix order, each resolved /// locally or pending on the stages beneath. -pub struct Resolution: Leaf>, T: Send + Sync + 'static, H: Height> -where - S: Height, -{ +pub struct Resolution { /// The prefix at which the resolved node will sit. - prefix: Prefix>, + pub(crate) prefix: ErasedPrefix, /// The possibly-resolved children of the node. - resolved: Vec<(u8, Resolve)>, + pub(crate) resolved: Vec<(u8, Resolve)>, } /// One child's slot in a [`Resolution`]. -pub enum Resolve: Leaf>, T: Send + Sync + 'static, H: Height> { +pub enum Resolve { /// Resolved at the current level: kept, absorbed, or pruned (`None` = gone; /// flows into `Backend::parent` as its deletion vocabulary). - Ready(Option>), + Ready(Option), /// Resolved elsewhere: filled by the level stream's next item. Pending, } @@ -301,10 +299,10 @@ pub enum Resolve: Leaf>, T: Send + Sync + 'static, H: H /// [`responder`](protocol::Responder::responder). The session's outgoing /// messages carry `backend`'s own node types, which are the ones its /// counterparty reads. -pub struct Handshaking: Leaf>, T: Send + Sync + 'static, V> { +pub struct Handshaking: Leaf>, V> { backend: B, versions: V, - root: Root, + root: Root, /// The session's window choice, resolved against the exchanged set /// sizes; see [`window`](super::window). window: WindowConfig, @@ -328,16 +326,19 @@ pub struct Start { /// Carries the root fan the greeting's listing was derived from, so the /// descent reuses it instead of asking the backend for the root's children a /// second time (the memory model's one-query-per-prefix rule). -pub struct Connecting: Leaf>, T: Send + Sync + 'static> { +pub struct Connecting: Leaf>> { our_version: Version, - fan: Vec<(u8, B::Node)>, + /// The root fan, already erased: everything downstream of the + /// greeting — the descent's workers included — speaks the erased + /// representation. + fan: Vec<(u8, B::Erased)>, } /// The version state of a stage that has exchanged greetings with its peer /// and can proceed with reconciliation. /// /// Like [`Connecting`], retains the greeting-time root fan for the descent. -pub struct Connected: Leaf>, T: Send + Sync + 'static> { +pub struct Connected: Leaf>> { our_version: Version, their_version: Version, /// The peer's live message count, from its greeting. @@ -349,12 +350,13 @@ pub struct Connected: Leaf>, T: Send + Sync + 'static> /// initiator merges its own fan against to ship its exclusive root /// children as the opening's early supplies. their_listing: Vec<(u8, Hash)>, - fan: Vec<(u8, B::Node)>, + /// The root fan, erased at greeting time ([`Connecting`]). + fan: Vec<(u8, B::Erased)>, } -/// A mirror stage inside the descent, consuming [`Reply`](Reply) +/// A mirror stage inside the descent, consuming [`Reply`](Reply) /// against a [`Query`] queue at the same height. -pub struct Descending: Leaf>, T: Send + Sync + 'static, H: Height> +pub struct Descending: Leaf>, H: Height> where S: Height, { @@ -364,13 +366,18 @@ where /// absorbed supply ([`SupplyLedger`]). ledger: SupplyLedger, /// The questions we asked, awaiting their replies in order. - queries: Receiver>, + /// + /// The payloads are erased ([`Backend::Erased`]); the typestate's + /// `H` is what pins this queue to the walk stage that consumes it at + /// the right height, and every payload's prefix carries the runtime + /// witness. + queries: Receiver>, /// One resolved scope per query, in query order, to the stage above. - returns: Sender>>>, + returns: Sender>, /// An elected initiator's opening hand-off: the early-supplied root /// radices' survivors, consumed by the first descending stage to answer /// the responder's empty queries about them (`None` below it). - early_survivors: Option>)>>>, + early_survivors: Option)>>>, /// An elected responder's opening hand-off (`None` below the first /// descending stage). /// @@ -378,12 +385,18 @@ where /// their own children, consumed by the first descending stage to /// resolve its own root-level requests. #[allow(clippy::type_complexity)] - early_supplies: Option)>)>>>, + early_supplies: Option)>>>, /// The reassembly work accumulated so far; the terminals drive it to /// completion. - work: Work, + work: Work, /// Resolves to this side's reconciled root once the top return arrives. - finish: BoxFuture<'static, Result, Error>>, + finish: BoxFuture<'static, Result, Error>>, + /// The stage's height, phantom. + /// + /// The payloads above are erased; this tag is what the schedule's + /// typestates keep proving about them (`PhantomData H>` for + /// the auto-trait shortcut; see [`typed::Node`](crate::tree::typed::Node)). + height: std::marker::PhantomData H>, } /// The initiator's terminal state: the pending leaf requests, and the @@ -392,7 +405,7 @@ where /// This is not a [`Descending`] stage: its returns are the requested leaves /// themselves (height `Z`), not an assembled scope one height up, because /// nothing exists below a leaf to assemble from. -pub struct Completing: Leaf>, T: Send + Sync + 'static> { +pub struct Completing: Leaf>> { /// The peer's declared greeting version: the containment bound every /// supplied leaf is checked against /// ([`Violation::UncontainedSupply`]). @@ -403,17 +416,17 @@ pub struct Completing: Leaf>, T: Send + Sync + 'static> /// Where each requested leaf will sit, one per request, in order. queries: Receiver>, /// The requested leaves' resolutions, in request order. - returns: Sender>>, + returns: Sender>, /// The accumulated work to drive the pipeline. - work: Work, + work: Work, /// The future result of the pipeline. - finish: BoxFuture<'static, Result, Error>>, + finish: BoxFuture<'static, Result, Error>>, } -impl: Leaf>, T: Send + Sync + 'static> Handshaking { +impl: Leaf>> Handshaking { /// Construct the session in its opening phase, at the default window /// and message-size target. - pub fn start(backend: B, root: Root) -> Self { + pub fn start(backend: B, root: Root) -> Self { Self { backend, versions: Start { @@ -451,11 +464,9 @@ impl: Leaf>, T: Send + Sync + 'static> Handshaking: Leaf>, T: Send + Sync + 'static, V: Send> protocol::Protocol - for Handshaking -{ +impl: Leaf>, V: Send> protocol::Protocol for Handshaking { type Height = height::Root; - type Output = Root; + type Output = Root; type Error = Error; } @@ -466,12 +477,14 @@ impl: Leaf>, T: Send + Sync + 'static, V: Send> protoco /// (see [`Greeting`] for the trade). The fan itself is retained through /// [`Connecting`]/[`Connected`] so the descent never re-asks the backend for /// the root's children. -pub(crate) async fn greeting_fan: Leaf>, T: Send + Sync + 'static>( +pub(crate) async fn greeting_fan: Leaf>>( backend: &B, root: Option>, -) -> Result)>, B::Error> { +) -> Result, B::Error> { match root { - Some(node) => children_of(backend, Prefix::new(), node).await, + Some(node) => { + erased::ops::children_of(backend, Prefix::new().erase(), B::erase(node)).await + } None => Ok(Vec::new()), } } @@ -484,18 +497,14 @@ pub(crate) async fn greeting_fan: Leaf>, T: Send + Sync /// proxy pairs the two positionally, so they must be byte-identical; /// routing both through this one function makes drift structurally /// impossible rather than a coincidence of two matching code bodies. -pub(crate) fn fan_listing, T: Send + Sync + 'static>( - fan: &[(u8, N)], -) -> Vec<(u8, Hash)> { +pub(crate) fn fan_listing(fan: &[(u8, E)]) -> Vec<(u8, Hash)> { fan.iter() .map(|(radix, node)| (*radix, node.hash())) .collect() } -impl: Leaf>, T: Send + Sync + 'static> protocol::Connect - for Handshaking -{ - type Next = Handshaking>; +impl: Leaf>> protocol::Connect for Handshaking { + type Next = Handshaking>; async fn connect(self) -> Result<(Greeting, Self::Next), Self::Error> { let Start { our_version } = self.versions; @@ -524,10 +533,8 @@ impl: Leaf>, T: Send + Sync + 'static> protocol::Connec } } -impl: Leaf>, T: Send + Sync + 'static> protocol::CompleteConnect - for Handshaking> -{ - type Next = Handshaking>; +impl: Leaf>> protocol::CompleteConnect for Handshaking> { + type Next = Handshaking>; async fn complete_connect(self, theirs: Greeting) -> Result { Ok(Handshaking { @@ -548,10 +555,8 @@ impl: Leaf>, T: Send + Sync + 'static> protocol::Comple } } -impl: Leaf>, T: Send + Sync + 'static> protocol::Accept - for Handshaking -{ - type Next = Handshaking>; +impl: Leaf>> protocol::Accept for Handshaking { + type Next = Handshaking>; async fn accept(self, request: Greeting) -> Result<(Greeting, Self::Next), Self::Error> { let Start { our_version } = self.versions; @@ -587,20 +592,16 @@ impl: Leaf>, T: Send + Sync + 'static> protocol::Accept } } -impl: Leaf>, T: Send + Sync + 'static> protocol::CompleteEqual - for Handshaking> -{ - async fn complete_equal(self) -> Result, Self::Error> { +impl: Leaf>> protocol::CompleteEqual for Handshaking> { + async fn complete_equal(self) -> Result, Self::Error> { Ok(self.root) } } -impl: Leaf> + Sync, T: Send + Sync + 'static> protocol::Initiator - for Handshaking> -{ - type Next = Descending; +impl: Leaf> + Sync> protocol::Initiator for Handshaking> { + type Next = Descending; - fn initiator(self) -> (BoxResponses, Self::Next) { + fn initiator(self) -> (BoxResponses, Self::Next) { let Connected { our_version, their_version, @@ -635,20 +636,19 @@ impl: Leaf> + Sync, T: Send + Sync + 'static> protocol: early_supplies: None, work, finish, + height: std::marker::PhantomData, }, ) } } -impl: Leaf> + Sync, T: Send + Sync + 'static> protocol::Responder - for Handshaking> -{ - type Next = Descending; +impl: Leaf> + Sync> protocol::Responder for Handshaking> { + type Next = Descending; fn responder( self, - requests: impl Requests, - ) -> (BoxResponses, Self::Next) { + requests: impl Requests, + ) -> (BoxResponses, Self::Next) { let Connected { our_version, their_version, @@ -688,37 +688,36 @@ impl: Leaf> + Sync, T: Send + Sync + 'static> protocol: early_supplies: Some(early), work, finish, + height: std::marker::PhantomData, }, ) } } -impl: Leaf>, T: Send + Sync + 'static, H: Height> protocol::Protocol - for Descending +impl: Leaf>, H: Height> protocol::Protocol for Descending where S: Height, { type Height = H; - type Output = Root; + type Output = Root; type Error = Error; } -impl protocol::Reply for Descending>> +impl protocol::Reply for Descending>> where - B: Backend: Leaf> + Sync, - T: Send + Sync + 'static, - H: Unknown, - S: Unknown, - S>: Unknown, + B: Backend: Leaf> + Sync, + H: Height, + S: Height, + S>: Height, S>>: Height, { - type Next = Descending; + type Next = Descending; fn reply( mut self, - requests: impl Requests>>, - ) -> (BoxResponses, Self::Error>, Self::Next) { - let (responses, queries, upper, lower) = self.work.internal_level( + requests: impl Requests>>, + ) -> (BoxResponses, Self::Error>, Self::Next) { + let (responses, queries, upper, lower) = self.work.internal_level::( self.their_version.clone(), self.ledger.clone(), self.early_survivors.take(), @@ -726,8 +725,8 @@ where requests, self.queries, ); - let returns = self.work.assemble(self.returns, upper); - let returns = self.work.assemble(returns, lower); + let returns = self.work.assemble(>>::HEIGHT, self.returns, upper); + let returns = self.work.assemble(>::HEIGHT, returns, lower); ( responses, @@ -740,22 +739,22 @@ where early_supplies: None, work: self.work, finish: self.finish, + height: std::marker::PhantomData, }, ) } } -impl protocol::Reply for Descending> +impl protocol::Reply for Descending> where - B: Backend: Leaf> + Sync, - T: Send + Sync + 'static, + B: Backend: Leaf> + Sync, { - type Next = Completing; + type Next = Completing; fn reply( mut self, - requests: impl Requests>, - ) -> (BoxResponses, Self::Next) { + requests: impl Requests>, + ) -> (BoxResponses, Self::Next) { debug_assert!( self.early_survivors.is_none() && self.early_supplies.is_none(), "the opening hand-off is consumed by the first descending stage" @@ -766,8 +765,8 @@ where requests, self.queries, ); - let returns = self.work.assemble(self.returns, upper); - let returns = self.work.assemble(returns, lower); + let returns = self.work.assemble(>::HEIGHT, self.returns, upper); + let returns = self.work.assemble(Z::HEIGHT, returns, lower); ( responses, @@ -783,17 +782,16 @@ where } } -impl protocol::CompleteResponder for Descending +impl protocol::CompleteResponder for Descending where - B: Backend: Leaf>, - T: Send + Sync + 'static, + B: Backend: Leaf>, { fn complete_responder( mut self, - requests: impl Requests, + requests: impl Requests, ) -> ( - BoxResponses, - impl Future, Self::Error>> + Send, + BoxResponses, + impl Future, Self::Error>> + Send, ) { let (responses, resolutions) = self.work @@ -803,28 +801,25 @@ where } } -impl: Leaf>, T: Send + Sync + 'static> protocol::Protocol - for Completing -{ +impl: Leaf>> protocol::Protocol for Completing { type Height = Z; - type Output = Root; + type Output = Root; type Error = Error; } -impl protocol::CompleteInitiator for Completing +impl protocol::CompleteInitiator for Completing where - B: Backend: Leaf>, - T: Send + Sync + 'static, + B: Backend: Leaf>, { async fn complete_initiator( self, - requests: impl Requests, - ) -> Result, Self::Error> { + requests: impl Requests, + ) -> Result, Self::Error> { let stats = self.work.stats(); - let mut absorb = pin!(absorb( + let mut absorb = pin!(absorb::( self.their_version, self.ledger, - requests, + requests.map(erased::erase_reply::), self.queries, self.returns, stats, @@ -855,17 +850,16 @@ where /// Each absorbed leaf is content this replica just learned, credited as /// [`messages_gained`](crate::SessionStats::messages_gained) exactly like /// the resolver's supply arm. -async fn absorb( +async fn absorb( their_version: Version, ledger: SupplyLedger, - requests: impl Requests, + requests: impl futures::Stream> + Send, mut queries: Receiver>, - returns: Sender>>, + returns: Sender>, stats: Recorder, ) -> Result<(), Error> where - B: Backend: Leaf>, - T: Send + Sync + 'static, + B: Backend: Leaf>, { let mut requests = pin!(requests); while let Some(prefix) = queries.recv().await { diff --git a/src/tree/mirror/streaming/materialized/common.rs b/src/tree/mirror/streaming/materialized/common.rs index 2f019607b..352504c88 100644 --- a/src/tree/mirror/streaming/materialized/common.rs +++ b/src/tree/mirror/streaming/materialized/common.rs @@ -15,14 +15,13 @@ use crate::tree::{ use super::channel::{QueueRole, Receiver, Sender, channel}; /// Collect one node's children, addressed by radix. -pub async fn children_of( +pub async fn children_of( backend: &B, prefix: Prefix>, node: B::Node>, ) -> Result)>, B::Error> where - B: Backend: Leaf>, - T: Send + Sync + 'static, + B: Backend: Leaf>, H: Height, S: Height, { diff --git a/src/tree/mirror/streaming/materialized/progress.rs b/src/tree/mirror/streaming/materialized/progress.rs index e6beeba24..1495998a7 100644 --- a/src/tree/mirror/streaming/materialized/progress.rs +++ b/src/tree/mirror/streaming/materialized/progress.rs @@ -4,14 +4,8 @@ use std::cell::{Cell, RefCell}; use std::collections::BTreeMap; use crate::tree::{ - mirror::streaming::{ - Backend, Leaf, - materialized::{Query, Resolution, Resolve}, - }, - typed::{ - Prefix, - height::{Height, S, Z}, - }, + mirror::streaming::materialized::{Query, Resolution, Resolve}, + typed::{ErasedPrefix, Prefix, height::Height}, }; /// The kind of one observable publication in a work graph. @@ -440,27 +434,15 @@ pub(super) fn new_work() -> usize { }) } -pub(super) fn wire(work: usize, scope: Prefix) { +pub(super) fn wire(work: usize, scope: ErasedPrefix) { record(work, scope, Kind::Wire); } -pub(super) fn initial_query(work: usize, query: &Query) -where - B: Backend: Leaf>, - T: Send + Sync + 'static, - H: Height, - S: Height, -{ +pub(super) fn initial_query(work: usize, query: &Query) { record(work, query.prefix, Kind::InitialQuery); } -pub(super) fn resolution(work: usize, resolution: &Resolution) -where - B: Backend: Leaf>, - T: Send + Sync + 'static, - H: Height, - S: Height, -{ +pub(super) fn resolution(work: usize, resolution: &Resolution) { record( work, resolution.prefix, @@ -480,13 +462,7 @@ impl Scoped for Prefix { } } -impl Scoped for Query -where - B: Backend: Leaf>, - T: Send + Sync + 'static, - H: Height, - S: Height, -{ +impl Scoped for Query { fn scope(&self) -> &[u8] { self.prefix.as_bytes() } @@ -496,17 +472,11 @@ pub(super) fn dependent(work: usize, item: &impl Scoped) { record_bytes(work, item.scope(), Kind::DependentWork); } -pub(super) fn ready(work: usize, scope: Prefix) { +pub(super) fn ready(work: usize, scope: ErasedPrefix) { record(work, scope, Kind::Ready); } -pub(super) fn parent_resolution(work: usize, resolution: &Resolution) -where - B: Backend: Leaf>, - T: Send + Sync + 'static, - H: Height, - S: Height, -{ +pub(super) fn parent_resolution(work: usize, resolution: &Resolution) { record( work, resolution.prefix, @@ -516,19 +486,14 @@ where ); } -fn pending(resolved: &[(u8, Resolve)]) -> usize -where - B: Backend: Leaf>, - T: Send + Sync + 'static, - H: Height, -{ +fn pending(resolved: &[(u8, Resolve)]) -> usize { resolved .iter() .filter(|(_, slot)| matches!(slot, Resolve::Pending)) .count() } -fn record(work: usize, scope: Prefix, kind: Kind) { +fn record(work: usize, scope: ErasedPrefix, kind: Kind) { record_bytes(work, scope.as_bytes(), kind); } diff --git a/src/tree/mirror/streaming/materialized/tests.rs b/src/tree/mirror/streaming/materialized/tests.rs index 21b7c3911..d989be73f 100644 --- a/src/tree/mirror/streaming/materialized/tests.rs +++ b/src/tree/mirror/streaming/materialized/tests.rs @@ -15,16 +15,14 @@ use super::{ Error, SupplyLedger, Violation, absorb, channel::{QueueKind, QueueRole, channel}, }; +use crate::tree::mirror::streaming::erased; use crate::tree::mirror::streaming::stats::Recorder; use crate::{ Version, message::Message, tree::{ arb::nth_party, - mirror::streaming::{ - Local, - message::{Reaction, Reply}, - }, + mirror::streaming::{Backend, Local}, typed::{ self, Path, Prefix, height::{Height, Z}, @@ -54,7 +52,7 @@ fn absorb_scripted( leaf_version: Version, ) -> ( Result<(), Error>, - Option>>, + Option>>, ) { // The request whose answer the script supplies: the leaf radix is the // path's last byte, zero here. @@ -64,17 +62,17 @@ fn absorb_scripted( pollster::block_on(queries.send(Prefix::containing(&path))).expect("the loop is live"); drop(queries); - let (returns, mut returns_rx) = channel::>>( + let (returns, mut returns_rx) = channel::::Erased>>( QueueRole::new(QueueKind::TerminalLeafResolutions, Z::HEIGHT), 1, ); let leaf = typed::Node::leaf(leaf_version, Message::new(())); - let requests = stream::iter(vec![Reply:: { - replies: vec![Reaction::Supply(0, leaf)], + let requests = stream::iter(vec![erased::Reply { + replies: vec![erased::Reaction::Supply(0, ::erase(leaf))], }]); - let result = pollster::block_on(absorb::( + let result = pollster::block_on(absorb::( declared, ledger, requests, @@ -82,7 +80,8 @@ fn absorb_scripted( returns, Recorder::default(), )); - let returned = pollster::block_on(async move { returns_rx.recv().await }); + let returned = pollster::block_on(async move { returns_rx.recv().await }) + .map(|leaf| leaf.map(::assume::)); (result, returned) } diff --git a/src/tree/mirror/streaming/materialized/transcript.rs b/src/tree/mirror/streaming/materialized/transcript.rs index 4deecfab4..67fd97180 100644 --- a/src/tree/mirror/streaming/materialized/transcript.rs +++ b/src/tree/mirror/streaming/materialized/transcript.rs @@ -21,13 +21,7 @@ use std::cell::RefCell; -use crate::tree::{ - mirror::streaming::{ - Backend, Leaf, - message::{Reaction, Reply}, - }, - typed::height::{Height, Z}, -}; +use crate::tree::mirror::streaming::erased::{Reaction, Reply}; /// One reaction of a captured reply, with every payload erased. #[derive(Clone, Debug, Eq, PartialEq)] @@ -91,13 +85,10 @@ pub fn with_transcript(f: impl FnOnce() -> R) -> (R, Transcript) { (result, Transcript(sent)) } -/// Record one outgoing reply, payload-erased. -pub(super) fn reply(work: usize, reply: &Reply) -where - B: Backend: Leaf>, - T: Send + Sync + 'static, - H: Height, -{ +/// Record one outgoing reply, payload-erased; `height` is the reply's +/// children height (which logical stream it rides), threaded from the +/// capturing pump's typed exit. +pub(super) fn reply(work: usize, height: usize, reply: &Reply) { let labels = reply .replies .iter() @@ -113,7 +104,7 @@ where if let Some(sent) = sent.borrow_mut().as_mut() { sent.push(Sent { work, - height: H::HEIGHT, + height, labels, }); } diff --git a/src/tree/mirror/streaming/materialized/unknown.rs b/src/tree/mirror/streaming/materialized/unknown.rs index d4de629d2..d90c419c0 100644 --- a/src/tree/mirror/streaming/materialized/unknown.rs +++ b/src/tree/mirror/streaming/materialized/unknown.rs @@ -14,24 +14,23 @@ //! single recursing node, so it stays constant-memory and reusable across the //! in-memory and persistent backends alike. //! -//! Every level returns a [`BoxFuture`]: an `impl Future` return would nest -//! each level's `async` type inside the next, ballooning the compiler's type -//! exponentially over the 32-level descent. Erasing to a trait object at each -//! step keeps the type flat, and its `Send`-ness asserted rather than proven -//! through the whole tower. +//! The walk runs on erased nodes, its level named by its prefix's byte +//! length: one instantiation per backend, where a height-typed recursion +//! would mint one per level. Each recursive call boxes its future +//! ([`BoxFuture`]) exactly as the typed tower did — the type stays flat — +//! and the depth is bounded by the prefix's remaining height, at most 32, +//! so the recursion is stack-safe by construction rather than by input +//! goodwill. -use futures::future::{self, BoxFuture, FutureExt}; +use futures::future::{BoxFuture, FutureExt}; use before::Dominance; use crate::{ Version, causally, tree::{ - mirror::streaming::{Backend, Leaf, Node, materialized::children_of, stats::Recorder}, - typed::{ - Prefix, - height::{Height, S, Z}, - }, + mirror::streaming::{Backend, ErasedNode, Leaf, erased::ops, stats::Recorder}, + typed::{ErasedPrefix, height::Z}, }, }; @@ -41,7 +40,7 @@ use crate::{ /// /// A concurrent ceiling is beyond the known-at range and is *not* known: /// it carries history the counterparty has never seen. -pub(super) fn known(node: &impl Node, version: &Version) -> bool { +pub(super) fn known(node: &impl ErasedNode, version: &Version) -> bool { causally::before(version).contains(node.span().hi()) } @@ -56,14 +55,14 @@ pub(super) fn known(node: &impl Node, version: &Ver /// [`Between`](Dominance::Between) means mixed, so the /// caller descends. /// -/// The backend hands out its own stored bounds ([`Node::span`]) and the -/// span answers in one fused walk that decodes `known` once, where +/// The backend hands out its own stored bounds ([`ErasedNode::span`]) and +/// the span answers in one fused walk that decodes `known` once, where /// placing the two bounds separately would decode it once per bound; /// the span's ordering is the backend's construction-time obligation, /// so no validating comparison is paid per classification. The cheap /// unknown-subtree exit survives the fusion: `floor <= known` refuted /// is the whole verdict, decided at the first refuting interval. -fn knowledge(node: &impl Node, known: &Version) -> Dominance { +fn knowledge(node: &impl ErasedNode, known: &Version) -> Dominance { node.span().dominance(known) } @@ -75,19 +74,53 @@ fn knowledge(node: &impl Node, known: &Version) -> /// [`messages_shed`](crate::SessionStats::messages_shed): one per dropped /// leaf, a whole subtree's exact leaf count when the cached version bounds /// prune it without descending. -pub(super) fn unknown<'a, B, T, H>( +pub(super) fn unknown<'a, B>( backend: &'a B, known: &'a Version, - prefix: Prefix, - node: B::Node, + prefix: ErasedPrefix, + node: B::Erased, stats: &'a Recorder, -) -> BoxFuture<'a, Result>, B::Error>> +) -> BoxFuture<'a, Result, B::Error>> where - B: Backend: Leaf> + Sync, - T: Send + Sync + 'static, - H: Unknown, + B: Backend: Leaf> + Sync, { - H::unknown(backend, known, prefix, node, stats) + async move { + if prefix.height() == 0 { + // A leaf is known iff its ceiling is causally at or before + // `known`; a concurrent ceiling is beyond the known-at range, + // so those survive. + let verdict = Some(node).filter(|node| !self::known(node, known)); + if verdict.is_none() { + stats.shed(1); + } + return Ok(verdict); + } + + match knowledge(&node, known) { + // Wholly unknown: the whole subtree travels. + Dominance::Before => return Ok(Some(node)), + // Wholly known: nothing under the node needs to travel. + Dominance::After => { + stats.shed(node.len() as u64); + return Ok(None); + } + Dominance::Between => {} + } + + // Mixed: descend. Explode just this node one level, prune its + // children, and reassemble the survivors from the pruned radix + // group — `None` entries are the children that pruned away. A group + // that prunes away entirely reassembles to `None`, reporting the + // whole node known one level up. + let children = ops::children_of(backend, prefix, node).await?; + let mut group = Vec::with_capacity(children.len()); + for (radix, child) in children { + let survivor = unknown(backend, known, prefix.push(radix), child, stats).await?; + group.push((radix, survivor)); + } + ops::parent(backend.clone(), prefix, group).await + } + .boxed() } /// The top of the recursion, exposed: prune one subtree and report both the @@ -96,23 +129,20 @@ where /// Reporting the children lets an answerer emit them as `Supply` reactions /// without re-querying the prefix it just explored (the one-query-per-prefix /// invariant; see [`super`]). -pub(super) async fn unknown_providing( +pub(super) async fn unknown_providing( backend: &B, known: &Version, - prefix: Prefix>, - node: B::Node>, + prefix: ErasedPrefix, + node: B::Erased, stats: &Recorder, -) -> Result<(Option>>, Vec<(u8, B::Node)>), B::Error> +) -> Result<(Option, Vec<(u8, B::Erased)>), B::Error> where - B: Backend: Leaf> + Sync, - T: Send + Sync + 'static, - H: Unknown, - S: Height, + B: Backend: Leaf> + Sync, { match knowledge(&node, known) { // Wholly unknown: the whole subtree travels. Dominance::Before => { - let children = children_of(backend, prefix, node.clone()).await?; + let children = ops::children_of(backend, prefix, node.clone()).await?; return Ok((Some(node), children)); } // Wholly known: nothing under the node needs to travel. @@ -125,103 +155,20 @@ where // Mixed: prune the children one by one; the surviving group is both the // provision list and the material `parent` rebuilds the survivor from. - let children = children_of(backend, prefix, node).await?; + let children = ops::children_of(backend, prefix, node).await?; let mut group = Vec::with_capacity(children.len()); let mut survivors = Vec::new(); for (radix, child) in children { - let survivor = H::unknown(backend, known, prefix.push(radix), child, stats).await?; + let survivor = unknown(backend, known, prefix.push(radix), child, stats).await?; if let Some(survivor) = &survivor { survivors.push((radix, survivor.clone())); } group.push((radix, survivor)); } - Ok((backend.clone().parent(prefix, group).await?, survivors)) -} - -/// The inductive step of the streaming filter, implemented per [`Height`]. -/// -/// Each level classifies a node by its memoized version bounds before -/// descending, reproducing the verdicts of -/// [`traverse::unknown::Unknown`](crate::tree::traverse::unknown::Unknown) -/// node for node. -pub trait Unknown: Height { - /// Prune one node at this height. See [`unknown`]. - fn unknown<'a, B, T>( - backend: &'a B, - known: &'a Version, - prefix: Prefix, - node: B::Node, - stats: &'a Recorder, - ) -> BoxFuture<'a, Result>, B::Error>> - where - B: Backend: Leaf> + Sync, - T: Send + Sync + 'static; -} - -impl Unknown for Z { - fn unknown<'a, B, T>( - _backend: &'a B, - known: &'a Version, - _prefix: Prefix, - node: B::Node, - stats: &'a Recorder, - ) -> BoxFuture<'a, Result>, B::Error>> - where - B: Backend: Leaf> + Sync, - T: Send + Sync + 'static, - { - // A leaf is known iff its ceiling is causally at or before `known`; - // a concurrent ceiling is beyond the known-at range, so those survive. - let verdict = Some(node).filter(|node| !self::known(node, known)); - if verdict.is_none() { - stats.shed(1); - } - future::ready(Ok(verdict)).boxed() - } -} - -impl Unknown for S -where - H: Unknown, - S: Height, -{ - fn unknown<'a, B, T>( - backend: &'a B, - known: &'a Version, - prefix: Prefix>, - node: B::Node>, - stats: &'a Recorder, - ) -> BoxFuture<'a, Result>>, B::Error>> - where - B: Backend: Leaf> + Sync, - T: Send + Sync + 'static, - { - Box::pin(async move { - match knowledge(&node, known) { - // Wholly unknown: the whole subtree travels. - Dominance::Before => return Ok(Some(node)), - // Wholly known: nothing under the node needs to travel. - Dominance::After => { - stats.shed(node.len() as u64); - return Ok(None); - } - Dominance::Between => {} - } - - // Mixed: descend. Explode just this node one level, prune its - // children, and reassemble the survivors from the pruned radix - // group — `None` entries are the children that pruned away. A group - // that prunes away entirely reassembles to `None`, reporting the - // whole node known one level up. - let children = children_of(backend, prefix, node).await?; - let mut group = Vec::with_capacity(children.len()); - for (radix, child) in children { - let survivor = H::unknown(backend, known, prefix.push(radix), child, stats).await?; - group.push((radix, survivor)); - } - backend.clone().parent(prefix, group).await - }) - } + Ok(( + ops::parent(backend.clone(), prefix, group).await?, + survivors, + )) } #[cfg(test)] diff --git a/src/tree/mirror/streaming/materialized/unknown/tests.rs b/src/tree/mirror/streaming/materialized/unknown/tests.rs index f60e0ac1a..d70c5aafa 100644 --- a/src/tree/mirror/streaming/materialized/unknown/tests.rs +++ b/src/tree/mirror/streaming/materialized/unknown/tests.rs @@ -11,7 +11,7 @@ use crate::{ message::Message, tree::{ arb::nth_party, - mirror::streaming::{Local, materialized::unknown::unknown}, + mirror::streaming::{Backend, Local, materialized::unknown::unknown}, traverse::{Action, act, unknown::Unknown}, typed::{self, Path, Prefix, height::Root}, }, @@ -26,8 +26,8 @@ use crate::{ /// Splitting leaves across two parties guarantees cross-party concurrency, so /// the "floor concurrent, keep whole subtree" fast path is exercised alongside /// the drop path. -fn tree_and_known(flags_a: &[bool], flags_b: &[bool]) -> (Option>, Version) { - let mut actions: Vec<(Path, Version, Action<()>)> = Vec::new(); +fn tree_and_known(flags_a: &[bool], flags_b: &[bool]) -> (Option, Version) { + let mut actions: Vec<(Path, Version, Action)> = Vec::new(); let mut known = Version::new(); for (party_index, flags) in [(0, flags_a), (1, flags_b)] { @@ -44,24 +44,22 @@ fn tree_and_known(flags_a: &[bool], flags_b: &[bool]) -> (Option>, - known: &Version, -) -> Option> { +fn stream_prune(root: Option, known: &Version) -> Option { root.and_then(|node| { - pollster::block_on(unknown::( + pollster::block_on(unknown::( &Local, known, - Prefix::new(), - node, + Prefix::new().erase(), + ::erase(node), &Recorder::default(), )) .unwrap_or_else(|e| match e {}) + .map(::assume::) }) } diff --git a/src/tree/mirror/streaming/materialized/work.rs b/src/tree/mirror/streaming/materialized/work.rs index 9fce3badb..638684153 100644 --- a/src/tree/mirror/streaming/materialized/work.rs +++ b/src/tree/mirror/streaming/materialized/work.rs @@ -5,11 +5,17 @@ //! reconstructs their resolved scopes upward. The terminal protocol state //! drives the accumulated tasks and its final result through one shared //! fail-fast completion primitive. +//! +//! The walks and pumps run on the erased vocabulary (see +//! [`erased`]): one instantiation +//! per backend. The typed surface is the thin boundary the protocol +//! schedule sees — [`Work::respond`]'s [`BoxResponses`] exit re-tags each +//! outgoing reply at its stage's height, and each walk's public method +//! erases its typed request stream on the way in. -use std::pin::pin; +use std::pin::Pin; -use futures::{Stream, future::BoxFuture}; -use tokio_stream::StreamExt; +use futures::{Stream, StreamExt, future::BoxFuture}; mod answer; mod assembly; @@ -21,9 +27,9 @@ mod resolver; use super::{progress, transcript}; use crate::tree::{ mirror::streaming::{ - Backend, Leaf, + Backend, Leaf, erased, materialized::{Error, channel::Sender}, - protocol::{BoxResponses, Responses}, + protocol::BoxResponses, stats::Recorder, tasks::{complete, park_after_published_error}, window::Window, @@ -34,10 +40,9 @@ use crate::tree::{ use self::queues::outgoing_responses; /// Backend and independently runnable tasks retained across protocol phases. -pub struct Work +pub struct Work where - B: Backend: Leaf>, - T: Send + Sync + 'static, + B: Backend: Leaf>, { backend: B, /// Per-edge capacity for the recursive query and resolution queues. @@ -50,10 +55,9 @@ where trace_id: usize, } -impl Work +impl Work where - B: Backend: Leaf>, - T: Send + Sync + 'static, + B: Backend: Leaf>, { /// Construct a new work context with the session's pipeline window and /// stats recorder. @@ -78,47 +82,31 @@ where self.stats.clone() } - /// Add a task which actively drives a response stream. - /// - /// One buffered response is sufficient: whenever the pump blocks, that - /// response is already available to advance the counterparty and release - /// the slot. Buffering a fan would retain whole protocol messages without - /// breaking any additional dependency. + /// Add a task which actively drives a response stream, and return the + /// stream's typed exit: the one point where a walk's erased replies + /// re-tag at their stage's height. fn respond( &mut self, - messages: impl Responses>, - ) -> BoxResponses> { - let (send, responses) = outgoing_responses(); - #[cfg(test)] - let work = self.trace_id; - self.tasks.push(Box::pin(async move { - let mut messages = pin!(messages); - while let Some(item) = messages.next().await { - // Capture the payload-erased wire transcript at the pump: - // per-stream pull order is exactly the wire order. - #[cfg(test)] - if let Ok(reply) = &item { - transcript::reply(work, reply); - } - let failed = item.is_err(); - if send.send(item).await.is_err() { - return Ok(()); - } - park_after_published_error(failed).await; - } - Ok::<(), Error>(()) - })); - responses + messages: impl Stream, Error>> + Send + 'static, + ) -> BoxResponses> { + let (send, responses) = outgoing_responses::(); + self.tasks.push(Box::pin(pump( + Box::pin(messages), + send, + #[cfg(test)] + (self.trace_id, H::HEIGHT), + ))); + Box::pin(responses) } /// Forward a stream of nodes into an upward return channel. - fn return_into( + fn return_into( &mut self, - returns: Sender>>, - stream: impl Stream>, Error>> + Send + 'static, + returns: Sender>, + stream: impl Stream, Error>> + Send + 'static, ) { self.tasks.push(Box::pin(async move { - let mut stream = pin!(stream); + let mut stream = std::pin::pin!(stream); while let Some(item) = stream.next().await { if returns.send(item?).await.is_err() { return Ok(()); @@ -137,5 +125,32 @@ where } } +/// Drive one walk's response stream into its outgoing edge. +/// +/// One buffered response is sufficient: whenever the pump blocks, that +/// response is already available to advance the counterparty and release +/// the slot. Buffering a fan would retain whole protocol messages without +/// breaking another dependency. +async fn pump( + mut messages: Pin, Error>> + Send>>, + send: Sender, Error>>, + #[cfg(test)] (work, height): (usize, usize), +) -> Result<(), Error> { + while let Some(item) = messages.next().await { + // Capture the payload-erased wire transcript at the pump: + // per-stream pull order is exactly the wire order. + #[cfg(test)] + if let Ok(reply) = &item { + transcript::reply(work, height, reply); + } + let failed = item.is_err(); + if send.send(item).await.is_err() { + return Ok(()); + } + park_after_published_error(failed).await; + } + Ok(()) +} + #[cfg(test)] mod tests; diff --git a/src/tree/mirror/streaming/materialized/work/answer.rs b/src/tree/mirror/streaming/materialized/work/answer.rs index e65a84020..241b24283 100644 --- a/src/tree/mirror/streaming/materialized/work/answer.rs +++ b/src/tree/mirror/streaming/materialized/work/answer.rs @@ -4,23 +4,23 @@ use crate::{ Version, tree::{ mirror::streaming::{ - Backend, Leaf, Node, + Backend, ErasedNode, Leaf, + erased::{Reaction, ops}, materialized::{ - Query, Resolve, Violation, children_of, - unknown::{Unknown, known, unknown}, + Query, Resolve, Violation, + unknown::{known, unknown}, }, - message::Reaction, stats::Recorder, }, - typed::{ - Hash, Prefix, - height::{Height, S, Z}, - }, + typed::{ErasedPrefix, Hash, Prefix, height::Z}, }, }; /// Answer one nonempty internal query by merge-joining both child listings. /// +/// `prefix` names the queried scope; `ours` are our children of it, one +/// level below. +/// /// This merge-join is the chokepoint where /// [`disputed_scopes`](crate::SessionStats::disputed_scopes) is counted: it /// runs exactly once per scope this side resolves, and the scope was a @@ -28,27 +28,24 @@ use crate::{ /// held the subtree) and some child failed to match. An all-match join is a /// confirmation, not a dispute, and a one-sided join is a request being /// served. -pub(super) async fn internal( +#[allow(clippy::type_complexity)] +pub(super) async fn internal( backend: &B, their_version: &Version, - prefix: Prefix>>, - ours: Vec<(u8, B::Node>)>, + prefix: ErasedPrefix, + ours: Vec<(u8, B::Erased)>, theirs: Vec<(u8, Hash)>, stats: &Recorder, ) -> Result< ( - Vec>>, - Vec>, - Vec<(u8, Resolve>)>, + Vec>, + Vec>, + Vec<(u8, Resolve)>, ), B::Error, > where - B: Backend: Leaf> + Sync, - T: Send + Sync + 'static, - H: Unknown, - S: Unknown, - S>: Height, + B: Backend: Leaf> + Sync, { let mut reactions = Vec::new(); let mut asked = Vec::new(); @@ -68,7 +65,7 @@ where EitherOrBoth::Both((radix, node), _) => { differed = true; let prefix = prefix.push(radix); - let ours = children_of(backend, prefix, node).await?; + let ours = ops::children_of(backend, prefix, node).await?; reactions.push(Reaction::Query( ours.iter() .map(|(radix, child)| (*radix, child.hash())) @@ -113,21 +110,14 @@ where /// were non-empty and some leaf sat on one side alone. Each exclusive local /// leaf the causal filter drops is one deletion honored /// ([`messages_shed`](crate::SessionStats::messages_shed)). -pub(super) fn leaf_parent( +#[allow(clippy::type_complexity)] +pub(super) fn leaf_parent( their_version: &Version, - prefix: Prefix>, - ours: Vec<(u8, B::Node)>, + prefix: ErasedPrefix, + ours: Vec<(u8, E)>, theirs: Vec<(u8, Hash)>, stats: &Recorder, -) -> ( - Vec>, - Vec>, - Vec<(u8, Resolve)>, -) -where - B: Backend: Leaf>, - T: Send + Sync + 'static, -{ +) -> (Vec>, Vec>, Vec<(u8, Resolve)>) { let mut reactions = Vec::new(); let mut asked = Vec::new(); let mut resolved = Vec::new(); @@ -156,7 +146,10 @@ where EitherOrBoth::Right((radix, _)) => { differed = true; reactions.push(Reaction::Query(Vec::new())); - asked.push(prefix.push(radix)); + // A leaf-parent scope's child prefix is a full 32-byte + // path: the length witness makes the leaf-height re-tag + // exact. + asked.push(prefix.push(radix).assume::()); resolved.push((radix, Resolve::Pending)); } } @@ -176,17 +169,13 @@ where /// listing here is a protocol violation), so no dispute is counted. A /// requested leaf the causal filter drops is one deletion honored /// ([`messages_shed`](crate::SessionStats::messages_shed)). -pub(super) fn leaf( +pub(super) fn leaf( their_version: &Version, radix: u8, - node: B::Node, + node: E, listing: Vec<(u8, Hash)>, stats: &Recorder, -) -> Result<(Vec>, Option>), Violation> -where - B: Backend: Leaf>, - T: Send + Sync + 'static, -{ +) -> Result<(Vec>, Option), Violation> { if !listing.is_empty() { return Err(Violation::UnexpectedQuery); } diff --git a/src/tree/mirror/streaming/materialized/work/assembly.rs b/src/tree/mirror/streaming/materialized/work/assembly.rs index 108eadc15..325e30927 100644 --- a/src/tree/mirror/streaming/materialized/work/assembly.rs +++ b/src/tree/mirror/streaming/materialized/work/assembly.rs @@ -10,32 +10,32 @@ use super::{Work, queues::assembly_level_returns}; use crate::tree::{ mirror::streaming::{ Backend, Leaf, + erased::ops, materialized::{Error, Resolution, Resolve, channel::Sender}, tasks::next_or_cancelled, }, - typed::height::{Height, S, Z}, + typed::height::Z, }; -impl Work +impl Work where - B: Backend: Leaf>, - T: Send + Sync + 'static, + B: Backend: Leaf>, { /// Assemble one level upward and return its lower-level sender. /// + /// `height` is the resolutions' children height, labeling the level + /// boundary's queue for the instrumented diagnostics. + /// /// A full fan lets every lower scope enqueue before the parent resolution /// containing its [`Resolve::Pending`] slots is published, without relying /// on blocked sender futures remaining independently runnable. - pub fn assemble( + pub fn assemble( &mut self, - returns: Sender>>>, - resolutions: impl Stream, Error>> + Send + 'static, - ) -> Sender>> - where - H: Height, - S: Height, - { - let (level, level_rx) = assembly_level_returns::(); + height: usize, + returns: Sender>, + resolutions: impl Stream, Error>> + Send + 'static, + ) -> Sender> { + let (level, level_rx) = assembly_level_returns::(height); self.return_into( returns, assemble(self.backend.clone(), resolutions, level_rx), @@ -46,8 +46,8 @@ where /// Assemble leaf resolutions upward with no level beneath them. pub fn assemble_leaves( &mut self, - returns: Sender>>>, - resolutions: impl Stream, Error>> + Send + 'static, + returns: Sender>, + resolutions: impl Stream, Error>> + Send + 'static, ) { self.return_into( returns, @@ -61,13 +61,13 @@ where /// Pairing is positional: resolutions arrive in query order and `level` /// carries one item per `Pending` in that same order. An empty resolution /// reaches [`Backend::parent`] with an empty group and resolves to `None`. -pub(super) fn assemble: Leaf>, T: Send + Sync + 'static, H: Height>( +pub(super) fn assemble( backend: B, - resolutions: impl Stream, Error>> + Send, - level: impl Stream>, Error>> + Send, -) -> impl Stream>>, Error>> + Send + resolutions: impl Stream, Error>> + Send, + level: impl Stream, Error>> + Send, +) -> impl Stream, Error>> + Send where - S: Height, + B: Backend: Leaf>, { try_stream! { let mut level = pin!(level.fuse()); @@ -85,7 +85,7 @@ where } })); } - yield backend.clone().parent(prefix, children).await?; + yield ops::parent(backend.clone(), prefix, children).await?; } } } diff --git a/src/tree/mirror/streaming/materialized/work/levels.rs b/src/tree/mirror/streaming/materialized/work/levels.rs index 3829b7a62..be880f922 100644 --- a/src/tree/mirror/streaming/materialized/work/levels.rs +++ b/src/tree/mirror/streaming/materialized/work/levels.rs @@ -1,13 +1,19 @@ //! Phase-specific materialized reconciliation walks. +//! +//! Every walk body runs on the erased vocabulary — one instantiation per +//! backend — behind a thin typed method that erases the stage's request +//! stream on the way in and re-tags its responses on the way out +//! ([`Work::respond`]). The generic methods carry the type-level height +//! the schedule proves; the walk bodies carry it as the runtime witness +//! every scope's prefix length *is*. use std::collections::BTreeMap; use std::pin::pin; use async_stream::try_stream; use before::Version; -use futures::future::BoxFuture; +use futures::{Stream, StreamExt, future::BoxFuture, stream::BoxStream}; use tokio::sync::oneshot; -use tokio_stream::StreamExt; use super::{Work, answer, assembly::assemble, queues::*, resolver::Resolver}; #[cfg(test)] @@ -15,15 +21,15 @@ use crate::tree::mirror::streaming::materialized::progress; use crate::tree::{ mirror::contained, mirror::streaming::{ - Backend, Leaf, Node, Root, + Backend, ErasedNode, Leaf, Root, + erased::{self, Reaction, Reply}, materialized::{ Error, OkReceiverStream, Query, Resolution, Resolve, SupplyLedger, Violation, channel::{Receiver, Sender}, - children_of, fan_listing, - unknown::{Unknown, unknown, unknown_providing}, + fan_listing, + unknown::{unknown, unknown_providing}, violation, }, - message::{self, Reaction, Reply}, protocol::{BoxResponses, Requests}, tasks::next_or_cancelled, }, @@ -33,10 +39,14 @@ use crate::tree::{ }, }; -impl Work +/// A request stream already erased and boxed at the walk boundary: what +/// every walk body consumes, so each body instantiates once per backend +/// rather than once per concrete schedule stream. +type Replies = BoxStream<'static, Reply>; + +impl Work where - B: Backend: Leaf>, - T: Send + Sync + 'static, + B: Backend: Leaf>, { /// Process the initiator level. /// @@ -54,24 +64,25 @@ where /// handed to the next level through the returned channel, so the root /// resolution answers the responder's now-vestigial empty queries from /// local state instead of re-walking the subtrees. + #[allow(clippy::type_complexity)] pub fn initiator_level( &mut self, their_version: Version, ceiling: Version, - fan: Vec<(u8, B::Node)>, + fan: Vec<(u8, B::Erased)>, their_listing: Vec<(u8, Hash)>, ) -> ( - BoxResponses>, - Receiver>, - Sender>>, - oneshot::Receiver>)>>, - BoxFuture<'static, Result, Error>>, + BoxResponses>, + Receiver>, + Sender>, + oneshot::Receiver)>>, + BoxFuture<'static, Result, Error>>, ) where B: Sync, { - let (queries, queries_rx) = initiator_root_query(); - let (returns, mut returns_rx) = initiator_root_return::(); + let (queries, queries_rx) = initiator_root_query::(); + let (returns, mut returns_rx) = initiator_root_return::(); let (early_tx, early_rx) = oneshot::channel(); let backend = self.backend(); let stats = self.stats.clone(); @@ -79,6 +90,7 @@ where let trace_id = self.trace_id; let responses = try_stream! { + let root_scope = Prefix::new().erase(); // The Left-arm-only merge over (fan, their listing): exclusive // root children, pruned, in radix order. Asking no question, it // adds no question-owner anywhere: every scope keeps exactly one. @@ -96,10 +108,10 @@ where let mut early = Vec::new(); for (radix, node) in exclusive { let survivor = - unknown(&backend, &their_version, Prefix::new().push(radix), node, &stats) + unknown(&backend, &their_version, root_scope.push(radix), node, &stats) .await?; if let Some(survivor) = &survivor { - supplies.push(message::Reaction::Supply(radix, survivor.clone())); + supplies.push(Reaction::Supply(radix, survivor.clone())); } early.push((radix, survivor)); } @@ -107,14 +119,14 @@ where // never waits: its first query cannot arrive earlier. let _ = early_tx.send(early); #[cfg(test)] - progress::wire(trace_id, Prefix::new()); + progress::wire(trace_id, root_scope); yield Reply { - replies: std::iter::once(message::Reaction::Query(fan_listing(&fan))) + replies: std::iter::once(Reaction::Query(fan_listing(&fan))) .chain(supplies) .collect(), }; let query = Query { - prefix: Prefix::new(), + prefix: root_scope, ours: fan, }; #[cfg(test)] @@ -126,11 +138,17 @@ where let finish = Box::pin(async move { let root = next_or_cancelled(returns_rx.recv()).await; - Ok(Root { ceiling, root }) + Ok(Root { + ceiling, + // The root return is this walk's one fixed-height re-tag: + // the initiator's single return channel carries exactly the + // reconciled root. + root: root.map(B::assume::), + }) }); ( - self.respond(responses), + self.respond::(responses), queries_rx, returns, early_rx, @@ -159,40 +177,43 @@ where their_version: Version, ledger: SupplyLedger, ceiling: Version, - fan: Vec<(u8, B::Node)>, - requests: impl Requests, + fan: Vec<(u8, B::Erased)>, + requests: impl Requests, ) -> ( - BoxResponses>, - Receiver>, - Sender>>, - oneshot::Receiver)>)>>, - BoxFuture<'static, Result, Error>>, + BoxResponses>, + Receiver>, + Sender>, + oneshot::Receiver)>>, + BoxFuture<'static, Result, Error>>, ) where B: Sync, { + let requests: Replies = + Box::pin(requests.map(erased::erase_reply::)); let backend = self.backend(); let stats = self.stats.clone(); let (asked, asked_rx) = - responder_child_queries(self.window.capacity(UnderUnderRoot::HEIGHT)); - let (resolution, resolution_rx) = responder_root_resolution(); + responder_child_queries::(self.window.capacity(UnderUnderRoot::HEIGHT)); + let (resolution, resolution_rx) = responder_root_resolution::(); let (early_tx, early_rx) = oneshot::channel(); let assembling = backend.clone(); #[cfg(test)] let trace_id = self.trace_id; let responses = try_stream! { - let mut requests = pin!(requests); + let mut requests = requests; + let root_scope = Prefix::new().erase(); let Some(Reply { replies }) = requests.next().await else { return violation(Violation::UnansweredQuery)?; }; let mut reactions = replies.into_iter(); - let Some(message::Reaction::Query(theirs)) = reactions.next() else { + let Some(Reaction::Query(theirs)) = reactions.next() else { return violation(Violation::UnexpectedQuery)?; }; let mut early = Vec::new(); for reaction in reactions { - let message::Reaction::Supply(radix, node) = reaction else { + let Reaction::Supply(radix, node) = reaction else { return violation(Violation::UnexpectedQuery)?; }; // Early supplies are absorbed here, ahead of the descent's @@ -205,7 +226,7 @@ where } ledger.absorb(node.len() as u64)?; let children = - children_of(&backend, Prefix::new().push(radix), node).await?; + erased::ops::children_of(&backend, root_scope.push(radix), node).await?; early.push((radix, children)); } // Filled before this reply yields, so the level consuming it @@ -213,34 +234,49 @@ where let _ = early_tx.send(early); let ours = fan; let (reactions, next_queries, resolved) = - answer::internal(&backend, &their_version, Prefix::new(), ours, theirs, &stats) + answer::internal(&backend, &their_version, root_scope, ours, theirs, &stats) .await?; yield_resolve_query!( - trace_id, Prefix::new(); + trace_id, root_scope; yield Reply { replies: reactions }; resolution => Resolution { - prefix: Prefix::new(), + prefix: root_scope, resolved, }; asked => next_queries; ); }; - let (returns, returns_rx) = responder_root_returns::(); + let (returns, returns_rx) = responder_root_returns::(); let assembled = assemble(assembling, resolution_rx, returns_rx); let finish = Box::pin(async move { let mut assembled = pin!(assembled); let root = next_or_cancelled(assembled.next()).await; Ok(Root { ceiling, - root: root?, + // The responder's root assembles at the top of the return + // chain: the same one fixed-height re-tag as the + // initiator's. + root: root?.map(B::assume::), }) }); - (self.respond(responses), asked_rx, returns, early_rx, finish) + ( + self.respond::(responses), + asked_rx, + returns, + early_rx, + finish, + ) } - /// Walk an internal level, where disputes recur into another internal level. + /// Walk an internal level, where disputes recur into another internal + /// level. + /// + /// `H` is the height the walk's dependent queries descend to; the + /// walk's own scopes sit two levels above it, exactly as the schedule's + /// [`Reply`](crate::tree::mirror::streaming::protocol::Reply) + /// transition demands. /// /// The two `early_*` channels are the opening exchange's hand-off into /// the one instance that resolves root scopes; every deeper instance @@ -262,38 +298,86 @@ where &mut self, their_version: Version, ledger: SupplyLedger, - early_survivors: Option>>>)>>>, - early_supplies: Option>>)>)>>>, - requests: impl Requests>>, - mut queries: Receiver>>>, + early_survivors: Option)>>>, + early_supplies: Option)>>>, + requests: impl Requests>>, + queries: Receiver>, + ) -> ( + BoxResponses, Error>, + Receiver>, + OkReceiverStream, Error>, + OkReceiverStream, Error>, + ) + where + B: Sync, + H: Height, + S: Height, + S>: Height, + { + let requests: Replies = + Box::pin(requests.map(erased::erase_reply::>>)); + let (responses, asked_rx, upper_rx, lower_rx) = self.internal_walk( + their_version, + ledger, + early_survivors, + early_supplies, + requests, + queries, + H::HEIGHT, + ); + ( + self.respond::>(responses), + asked_rx, + upper_rx, + lower_rx, + ) + } + + /// The internal walk's body, shared by every height: + /// [`internal_level`](Self::internal_level) with the descent height as + /// the runtime datum it already is everywhere below the types. + // The argument list is the stage's dataflow, one edge per argument; + // bundling edges into a struct would only rename the arity. + #[allow(clippy::too_many_arguments, clippy::type_complexity)] + fn internal_walk( + &mut self, + their_version: Version, + ledger: SupplyLedger, + early_survivors: Option)>>>, + early_supplies: Option)>>>, + requests: Replies, + mut queries: Receiver>, + asked_height: usize, ) -> ( - BoxResponses, Error>, - Receiver>, - OkReceiverStream>>, Error>, - OkReceiverStream>, Error>, + impl Stream, Error>> + Send + 'static + use, + Receiver>, + OkReceiverStream, Error>, + OkReceiverStream, Error>, ) where B: Sync, - H: Unknown, - S: Unknown, - S>: Unknown, - S>>: Height, { let backend = self.backend(); let stats = self.stats.clone(); - let (asked, asked_rx) = internal_child_queries(self.window.capacity(H::HEIGHT)); - let (upper, upper_rx) = - internal_parent_resolutions(self.window.capacity(>>::HEIGHT)); - let (lower, lower_rx) = internal_child_resolutions(self.window.capacity(>::HEIGHT)); + let (asked, asked_rx) = + internal_child_queries::(asked_height, self.window.capacity(asked_height)); + let (upper, upper_rx) = internal_parent_resolutions::( + asked_height + 2, + self.window.capacity(asked_height + 2), + ); + let (lower, lower_rx) = internal_child_resolutions::( + asked_height + 1, + self.window.capacity(asked_height + 1), + ); #[cfg(test)] let trace_id = self.trace_id; let responses = try_stream! { - let mut requests = pin!(requests); + let mut requests = requests; let mut early_survivors = early_survivors; - let mut survivors: Option>>>>> = None; + let mut survivors: Option>> = None; let mut early_supplies = early_supplies; - let mut supplied: Option>>)>>> = None; + let mut supplied: Option>> = None; while let Some(query) = queries.recv().await { let Some(Reply { replies }) = requests.next().await else { return violation(Violation::UnansweredQuery)?; @@ -342,7 +426,8 @@ where } } - let mut resolver = Resolver::new(query, &their_version, &ledger, stats.clone()); + let mut resolver = + Resolver::::new(query, &their_version, &ledger, stats.clone()); for reaction in replies { let Some((prefix, radix, node, listing)) = resolver.react(reaction)? else { continue; @@ -388,7 +473,7 @@ where continue; } - let children = children_of(&backend, child_prefix, node).await?; + let children = erased::ops::children_of(&backend, child_prefix, node).await?; let (reactions, next_queries, resolved) = answer::internal( &backend, &their_version, @@ -425,7 +510,7 @@ where } }; - (self.respond(responses), asked_rx, upper_rx, lower_rx) + (responses, asked_rx, upper_rx, lower_rx) } /// Walk leaf parents, where disputes compare version-addressed leaves. @@ -433,13 +518,36 @@ where &mut self, their_version: Version, ledger: SupplyLedger, - requests: impl Requests>, - mut queries: Receiver>>, + requests: impl Requests>, + queries: Receiver>, + ) -> ( + BoxResponses>, + Receiver>, + OkReceiverStream, Error>, + OkReceiverStream, Error>, + ) + where + B: Sync, + { + let requests: Replies = Box::pin(requests.map(erased::erase_reply::>)); + let (responses, asked_rx, upper_rx, lower_rx) = + self.leaf_parent_walk(their_version, ledger, requests, queries); + (self.respond::(responses), asked_rx, upper_rx, lower_rx) + } + + /// The leaf-parent walk's body ([`leaf_parent_level`](Self::leaf_parent_level)). + #[allow(clippy::type_complexity)] + fn leaf_parent_walk( + &mut self, + their_version: Version, + ledger: SupplyLedger, + requests: Replies, + mut queries: Receiver>, ) -> ( - BoxResponses>, + impl Stream, Error>> + Send + 'static + use, Receiver>, - OkReceiverStream>, Error>, - OkReceiverStream, Error>, + OkReceiverStream, Error>, + OkReceiverStream, Error>, ) where B: Sync, @@ -447,19 +555,20 @@ where let backend = self.backend(); let stats = self.stats.clone(); let (asked, asked_rx) = leaf_requests(self.window.capacity(Z::HEIGHT)); - let (upper, upper_rx) = leaf_parent_resolutions(self.window.capacity(>::HEIGHT)); - let (lower, lower_rx) = leaf_child_resolutions(self.window.capacity(Z::HEIGHT)); + let (upper, upper_rx) = leaf_parent_resolutions::(self.window.capacity(>::HEIGHT)); + let (lower, lower_rx) = leaf_child_resolutions::(self.window.capacity(Z::HEIGHT)); #[cfg(test)] let trace_id = self.trace_id; let responses = try_stream! { - let mut requests = pin!(requests); + let mut requests = requests; while let Some(query) = queries.recv().await { let Some(Reply { replies }) = requests.next().await else { return violation(Violation::UnansweredQuery)?; }; - let mut resolver = Resolver::new(query, &their_version, &ledger, stats.clone()); + let mut resolver = + Resolver::::new(query, &their_version, &ledger, stats.clone()); for reaction in replies { let Some((prefix, radix, node, listing)) = resolver.react(reaction)? else { continue; @@ -482,7 +591,7 @@ where continue; } - let leaves = children_of(&backend, child_prefix, node).await?; + let leaves = erased::ops::children_of(&backend, child_prefix, node).await?; let (replies, next_queries, resolved) = answer::leaf_parent(&their_version, child_prefix, leaves, listing, &stats); yield_resolve_query!( @@ -512,7 +621,7 @@ where } }; - (self.respond(responses), asked_rx, upper_rx, lower_rx) + (responses, asked_rx, upper_rx, lower_rx) } /// Walk leaves, where every query is a terminal request. @@ -520,25 +629,43 @@ where &mut self, their_version: Version, ledger: SupplyLedger, - requests: impl Requests, - mut queries: Receiver>, + requests: impl Requests, + queries: Receiver>, + ) -> ( + BoxResponses>, + OkReceiverStream, Error>, + ) { + let requests: Replies = Box::pin(requests.map(erased::erase_reply::)); + let (responses, upper_rx) = self.leaf_walk(their_version, ledger, requests, queries); + (self.respond::(responses), upper_rx) + } + + /// The leaf walk's body ([`leaf_level`](Self::leaf_level)). + #[allow(clippy::type_complexity)] + fn leaf_walk( + &mut self, + their_version: Version, + ledger: SupplyLedger, + requests: Replies, + mut queries: Receiver>, ) -> ( - BoxResponses>, - OkReceiverStream, Error>, + impl Stream, Error>> + Send + 'static + use, + OkReceiverStream, Error>, ) { - let (upper, upper_rx) = terminal_leaf_resolutions(); + let (upper, upper_rx) = terminal_leaf_resolutions::(); let stats = self.stats.clone(); #[cfg(test)] let trace_id = self.trace_id; let responses = try_stream! { - let mut requests = pin!(requests); + let mut requests = requests; while let Some(query) = queries.recv().await { let Some(Reply { replies }) = requests.next().await else { return violation(Violation::UnansweredQuery)?; }; - let mut resolver = Resolver::new(query, &their_version, &ledger, stats.clone()); + let mut resolver = + Resolver::::new(query, &their_version, &ledger, stats.clone()); for reaction in replies { let Some((prefix, radix, node, listing)) = resolver.react(reaction)? else { continue; @@ -567,6 +694,6 @@ where } }; - (self.respond(responses), upper_rx) + (responses, upper_rx) } } diff --git a/src/tree/mirror/streaming/materialized/work/queues.rs b/src/tree/mirror/streaming/materialized/work/queues.rs index c61343f29..67a99cfbc 100644 --- a/src/tree/mirror/streaming/materialized/work/queues.rs +++ b/src/tree/mirror/streaming/materialized/work/queues.rs @@ -1,8 +1,13 @@ -//! Typed channel constructors for the materialized walk. +//! Channel constructors for the materialized walk. //! //! Each function names one edge in the protocol dataflow. Keeping capacity //! choices here makes them reviewable alongside the exact item type and keeps -//! queue arithmetic out of the walk itself. +//! queue arithmetic out of the walk itself. Every edge carries the erased +//! payload vocabulary — one channel-machinery instantiation per backend — +//! and keeps its height as the runtime [`QueueRole`] label the walk +//! threads through (the instrumented diagnostics and capacity tests key +//! on it). The one typed exception is [`leaf_requests`], whose item is +//! already the single-height [`Prefix`]. //! //! Recursive query and resolution queues rely on two halves of the walk's //! progress invariant: publish a scope's resolution before sending the work @@ -16,19 +21,15 @@ //! for every remaining one-slot edge; only the inter-level return boundary //! needs a fan. -#[cfg(not(test))] -use tokio_stream::wrappers::ReceiverStream; - use crate::tree::{ mirror::streaming::{ Backend, Leaf, + erased::{self, Reply, ReplyResultStream}, materialized::{ Error, OkReceiverStream, Query, Resolution, channel::{QueueKind, QueueRole, Receiver, Sender, channel}, ok_channel, }, - message::Reply, - protocol::BoxResponses, window::FAN, }, typed::{ @@ -42,25 +43,24 @@ use crate::tree::{ /// A blocked producer has already made one reply available to the counterparty, /// and consuming that reply is sufficient to release the producer. More slots /// retain whole messages without breaking another dependency. -pub(super) fn outgoing_responses() -> ( - Sender, Error>>, - BoxResponses>, +pub(super) fn outgoing_responses() -> ( + Sender, Error>>, + ReplyResultStream>, ) where - B: Backend: Leaf>, - T: Send + Sync + 'static, + B: Backend: Leaf>, H: Height, { - let (sender, receiver) = channel(QueueRole::new(QueueKind::OutgoingResponses, H::HEIGHT), 1); - #[cfg(test)] - let responses = Box::pin(receiver); - #[cfg(not(test))] - let responses = Box::pin(ReceiverStream::new(receiver)); - (sender, responses) + erased::reply_channel::>( + QueueRole::new(QueueKind::OutgoingResponses, H::HEIGHT), + 1, + ) } /// Buffer lower-level completions until their enclosing resolution arrives. /// +/// `height` is the completions' own height, the level boundary's label. +/// /// Processing one incoming reply can launch a full fan of disputed child /// scopes. Their lower assemblers may finish immediately and send completed /// nodes here, but this queue's consumer first waits for the enclosing parent @@ -81,32 +81,25 @@ where /// stall a session (`underbuffered_mirror_stalls` in the capacity tests /// demonstrates it), which is why the session window deliberately never /// reaches this constructor. -pub(super) fn assembly_level_returns() -> ( - Sender>>, - OkReceiverStream>, Error>, +pub(super) fn assembly_level_returns( + height: usize, +) -> ( + Sender>, + OkReceiverStream, Error>, ) where - B: Backend: Leaf>, - T: Send + Sync + 'static, - H: Height, + B: Backend: Leaf>, { - ok_channel( - QueueRole::new(QueueKind::AssemblyLevelReturns, H::HEIGHT), - FAN, - ) + ok_channel(QueueRole::new(QueueKind::AssemblyLevelReturns, height), FAN) } /// Carry the initiator's single root query. /// /// The opening emits exactly one query for the root scope, so a second slot /// can never be occupied. -pub(super) fn initiator_root_query() -> ( - Sender>, - Receiver>, -) +pub(super) fn initiator_root_query() -> (Sender>, Receiver>) where - B: Backend: Leaf>, - T: Send + Sync + 'static, + B: Backend: Leaf>, { channel( QueueRole::new(QueueKind::InitiatorRootQuery, UnderRoot::HEIGHT), @@ -118,13 +111,9 @@ where /// /// Reconciliation produces exactly one root node and the terminal future /// consumes it directly. -pub(super) fn initiator_root_return() -> ( - Sender>>, - Receiver>>, -) +pub(super) fn initiator_root_return() -> (Sender>, Receiver>) where - B: Backend: Leaf>, - T: Send + Sync + 'static, + B: Backend: Leaf>, { channel( QueueRole::new(QueueKind::InitiatorRootReturn, Root::HEIGHT), @@ -139,15 +128,11 @@ where /// stage can hold a pipeline of disputed children in flight; each buffered /// [`Query`] may own a fan of node handles, which is priced by the window's /// node budget. -pub(super) fn responder_child_queries( +pub(super) fn responder_child_queries( capacity: usize, -) -> ( - Sender>, - Receiver>, -) +) -> (Sender>, Receiver>) where - B: Backend: Leaf>, - T: Send + Sync + 'static, + B: Backend: Leaf>, { channel( QueueRole::new(QueueKind::ResponderChildQueries, UnderUnderRoot::HEIGHT), @@ -159,13 +144,12 @@ where /// /// The responder processes exactly one opening request and therefore /// publishes exactly one resolution for the root scope. -pub(super) fn responder_root_resolution() -> ( - Sender>, - OkReceiverStream, Error>, +pub(super) fn responder_root_resolution() -> ( + Sender>, + OkReceiverStream, Error>, ) where - B: Backend: Leaf>, - T: Send + Sync + 'static, + B: Backend: Leaf>, { ok_channel( QueueRole::new(QueueKind::ResponderRootResolution, UnderRoot::HEIGHT), @@ -178,13 +162,12 @@ where /// The root resolution is visible before its child queries are sent, so its /// assembler can consume each return as it arrives. No later return is needed /// to unlock the consumer of the buffered one. -pub(super) fn responder_root_returns() -> ( - Sender>>, - OkReceiverStream>, Error>, +pub(super) fn responder_root_returns() -> ( + Sender>, + OkReceiverStream, Error>, ) where - B: Backend: Leaf>, - T: Send + Sync + 'static, + B: Backend: Leaf>, { ok_channel( QueueRole::new(QueueKind::ResponderRootReturns, UnderRoot::HEIGHT), @@ -194,71 +177,71 @@ where /// Buffer the child queries emitted by one internal walk, window-wide. /// +/// `height` is the children's height: the walk's dependent queries descend +/// to it, and it labels the edge. +/// /// The corresponding child resolution is published first, so one slot is the /// liveness floor. This queue is the in-flight question window itself: its /// occupancy is the number of disputed scopes awaiting wire replies at this /// height, so its capacity is what lets sibling scopes' round trips overlap. -pub(super) fn internal_child_queries( +pub(super) fn internal_child_queries( + height: usize, capacity: usize, -) -> (Sender>, Receiver>) +) -> (Sender>, Receiver>) where - B: Backend: Leaf>, - T: Send + Sync + 'static, - H: Height, - S: Height, + B: Backend: Leaf>, { channel( - QueueRole::new(QueueKind::InternalChildQueries, H::HEIGHT), + QueueRole::new(QueueKind::InternalChildQueries, height), capacity, ) } /// Buffer parent-scope resolutions produced by an internal walk, window-wide. /// +/// `height` is the parent resolutions' own height (two above the walk's +/// dependent queries). +/// /// Before each parent resolution is sent, all work capable of fulfilling its /// `Pending` slots has been launched, so one slot is the liveness floor. But a /// resolution is consumed only as its subtree completes, so a one-slot edge /// stalls the walk two scopes in; the window lets it run ahead. -pub(super) fn internal_parent_resolutions( +pub(super) fn internal_parent_resolutions( + height: usize, capacity: usize, ) -> ( - Sender>>>, - OkReceiverStream>>, Error>, + Sender>, + OkReceiverStream, Error>, ) where - B: Backend: Leaf>, - T: Send + Sync + 'static, - H: Height, - S: Height, - S>: Height, - S>>: Height, + B: Backend: Leaf>, { ok_channel( - QueueRole::new(QueueKind::InternalParentResolutions, >>::HEIGHT), + QueueRole::new(QueueKind::InternalParentResolutions, height), capacity, ) } /// Buffer child-scope resolutions produced by an internal walk, window-wide. /// +/// `height` is the child resolutions' own height (one above the walk's +/// dependent queries). +/// /// Each resolution is published before its corresponding child queries, so one /// slot is the liveness floor; the window lets the walk publish a pipeline of /// them while earlier subtrees are still reconciling. -pub(super) fn internal_child_resolutions( +pub(super) fn internal_child_resolutions( + height: usize, capacity: usize, ) -> ( - Sender>>, - OkReceiverStream>, Error>, + Sender>, + OkReceiverStream, Error>, ) where - B: Backend: Leaf>, - T: Send + Sync + 'static, - H: Height, - S: Height, - S>: Height, + B: Backend: Leaf>, { ok_channel( - QueueRole::new(QueueKind::InternalChildResolutions, >::HEIGHT), + QueueRole::new(QueueKind::InternalChildResolutions, height), capacity, ) } @@ -278,15 +261,14 @@ pub(super) fn leaf_requests(capacity: usize) -> (Sender>, Receiver

(
+pub(super) fn leaf_parent_resolutions(
     capacity: usize,
 ) -> (
-    Sender>>,
-    OkReceiverStream>, Error>,
+    Sender>,
+    OkReceiverStream, Error>,
 )
 where
-    B: Backend: Leaf>,
-    T: Send + Sync + 'static,
+    B: Backend: Leaf>,
 {
     ok_channel(
         QueueRole::new(QueueKind::LeafParentResolutions, >::HEIGHT),
@@ -300,15 +282,14 @@ where
 /// Each resolution is published before its leaf requests — the one-slot
 /// liveness floor; the window keeps the walk publishing while earlier leaf
 /// scopes await their supplies.
-pub(super) fn leaf_child_resolutions(
+pub(super) fn leaf_child_resolutions(
     capacity: usize,
 ) -> (
-    Sender>,
-    OkReceiverStream, Error>,
+    Sender>,
+    OkReceiverStream, Error>,
 )
 where
-    B: Backend: Leaf>,
-    T: Send + Sync + 'static,
+    B: Backend: Leaf>,
 {
     ok_channel(
         QueueRole::new(QueueKind::LeafChildResolutions, Z::HEIGHT),
@@ -327,13 +308,12 @@ where
 /// scopes the memory model already charges, so no knob applies. (Contrast
 /// [`assembly_level_returns`], where one fan is a correctness floor rather
 /// than an amortization.)
-pub(super) fn terminal_leaf_resolutions() -> (
-    Sender>,
-    OkReceiverStream, Error>,
+pub(super) fn terminal_leaf_resolutions() -> (
+    Sender>,
+    OkReceiverStream, Error>,
 )
 where
-    B: Backend: Leaf>,
-    T: Send + Sync + 'static,
+    B: Backend: Leaf>,
 {
     ok_channel(
         QueueRole::new(QueueKind::TerminalLeafResolutions, Z::HEIGHT),
diff --git a/src/tree/mirror/streaming/materialized/work/resolver.rs b/src/tree/mirror/streaming/materialized/work/resolver.rs
index 43cced73d..bd4a574e4 100644
--- a/src/tree/mirror/streaming/materialized/work/resolver.rs
+++ b/src/tree/mirror/streaming/materialized/work/resolver.rs
@@ -5,28 +5,25 @@ use crate::{
     tree::{
         mirror::contained,
         mirror::streaming::{
-            Backend, Leaf, Node,
+            Backend, ErasedNode, Leaf,
+            erased::Reaction,
             materialized::{Error, Query, Resolution, Resolve, SupplyLedger, Violation, violation},
-            message::Reaction,
             stats::Recorder,
         },
-        typed::{
-            Hash, Prefix,
-            height::{Height, S, Z},
-        },
+        typed::{ErasedPrefix, Hash, height::Z},
     },
 };
 
 /// One query's reaction loop: pairs the held children against the reply's
 /// reactions in order, accumulating the scope's [`Resolution`] and reporting
 /// each counterparty fault as its exact [`Violation`].
-pub struct Resolver<'v, B: Backend: Leaf>, T: Send + Sync + 'static, H: Height>
+pub struct Resolver<'v, B>
 where
-    S: Height,
+    B: Backend: Leaf>,
 {
-    prefix: Prefix>,
-    fan: Peekable)>>,
-    resolved: Vec<(u8, Resolve)>,
+    prefix: ErasedPrefix,
+    fan: Peekable>,
+    resolved: Vec<(u8, Resolve)>,
     /// The peer's declared greeting version: every supplied subtree's
     /// ceiling must be contained in it
     /// ([`Violation::UncontainedSupply`]).
@@ -41,15 +38,12 @@ where
     stats: Recorder,
 }
 
-impl<'v, B, T, H> Resolver<'v, B, T, H>
+impl<'v, B> Resolver<'v, B>
 where
-    B: Backend: Leaf>,
-    T: Send + Sync + 'static,
-    H: Height,
-    S: Height,
+    B: Backend: Leaf>,
 {
     pub fn new(
-        Query { prefix, ours }: Query,
+        Query { prefix, ours }: Query,
         their_version: &'v Version,
         ledger: &'v SupplyLedger,
         stats: Recorder,
@@ -64,10 +58,11 @@ where
         }
     }
 
+    #[allow(clippy::type_complexity)]
     pub fn react(
         &mut self,
-        reaction: Reaction,
-    ) -> Result>, u8, B::Node, Vec<(u8, Hash)>)>, Error> {
+        reaction: Reaction,
+    ) -> Result)>, Error> {
         match reaction {
             Reaction::Match => {
                 let Some((radix, node)) = self.fan.next() else {
@@ -113,7 +108,7 @@ where
         Ok(None)
     }
 
-    pub fn ready(&mut self, radix: u8, node: Option>) {
+    pub fn ready(&mut self, radix: u8, node: Option) {
         self.resolved.push((radix, Resolve::Ready(node)));
     }
 
@@ -121,7 +116,7 @@ where
         self.resolved.push((radix, Resolve::Pending));
     }
 
-    pub fn finish(mut self) -> Result, Error> {
+    pub fn finish(mut self) -> Result, Error> {
         if self.fan.next().is_some() {
             violation(Violation::UnfinishedReply)
         } else {
diff --git a/src/tree/mirror/streaming/materialized/work/tests.rs b/src/tree/mirror/streaming/materialized/work/tests.rs
index 9ab2cdefb..cd6539179 100644
--- a/src/tree/mirror/streaming/materialized/work/tests.rs
+++ b/src/tree/mirror/streaming/materialized/work/tests.rs
@@ -33,8 +33,17 @@ use crate::{
     },
 };
 
+/// The in-memory backend's erased node representation, which resolutions
+/// and level returns carry.
+type Erased = ::Erased;
+
+/// Erase one typed leaf the way the walk's payloads carry it.
+fn erased(node: typed::Node) -> Erased {
+    ::erase(node)
+}
+
 /// A distinct leaf per call: the versions differ so hashes do.
-fn leaf(version: &mut Version) -> typed::Node<(), Z> {
+fn leaf(version: &mut Version) -> typed::Node {
     version.tick(&nth_party(0));
     typed::Node::leaf(version.clone(), Message::new(()))
 }
@@ -56,18 +65,19 @@ fn parent_prefix(parent: u8) -> Prefix> {
 /// Build the expected parent of a radix group directly through the backend.
 fn parent_of(
     prefix: Prefix>,
-    children: Vec<(u8, Option>)>,
-) -> Option>> {
-    pollster::block_on(Local.parent(prefix, children)).unwrap_or_else(|e| match e {})
+    children: Vec<(u8, Option>)>,
+) -> Option>> {
+    pollster::block_on(::parent(Local, prefix, children))
+        .unwrap_or_else(|e| match e {})
 }
 
-type Item = Result, Error>;
-type Level = Result>, Error>;
+type Item = Result, Error>;
+type Level = Result, Error>;
 
 /// A work error cancels parked peers and retains its original error identity.
 #[test]
 fn work_failure_preempts_parked_tasks() {
-    let mut work: Work, ()> = Work::new(
+    let mut work: Work> = Work::new(
         Failing::after(Local, usize::MAX),
         Window::FLOOR,
         Recorder::default(),
@@ -96,9 +106,10 @@ fn work_failure_preempts_parked_tasks() {
 fn assembled(
     resolutions: Vec,
     level: Vec,
-) -> Vec>>, Error>> {
+) -> Vec, Error>> {
     pollster::block_on(
-        assemble(Local, stream::iter(resolutions), stream::iter(level)).collect::>(),
+        assemble::(Local, stream::iter(resolutions), stream::iter(level))
+            .collect::>(),
     )
 }
 
@@ -110,16 +121,16 @@ fn fills_pendings_in_order() {
     let (a, b, c) = (leaf(&mut version), leaf(&mut version), leaf(&mut version));
 
     let resolution = Resolution {
-        prefix: parent_prefix(3),
+        prefix: parent_prefix(3).erase(),
         resolved: vec![
             (1, Resolve::Pending),
-            (2, Resolve::Ready(Some(b.clone()))),
+            (2, Resolve::Ready(Some(erased(b.clone())))),
             (5, Resolve::Pending),
         ],
     };
     let output = assembled(
         vec![Ok(resolution)],
-        vec![Ok(Some(a.clone())), Ok(Some(c.clone()))],
+        vec![Ok(Some(erased(a.clone()))), Ok(Some(erased(c.clone())))],
     );
 
     let expected = parent_of(
@@ -143,9 +154,9 @@ fn ready_none_is_deletion() {
     let a = leaf(&mut version);
 
     let resolution = Resolution {
-        prefix: parent_prefix(3),
+        prefix: parent_prefix(3).erase(),
         resolved: vec![
-            (1, Resolve::Ready(Some(a.clone()))),
+            (1, Resolve::Ready(Some(erased(a.clone())))),
             (2, Resolve::Ready(None)),
         ],
     };
@@ -166,7 +177,7 @@ fn ready_none_is_deletion() {
 #[test]
 fn empty_resolution_assembles_to_none() {
     let resolution = Resolution {
-        prefix: parent_prefix(3),
+        prefix: parent_prefix(3).erase(),
         resolved: vec![],
     };
     let output = assembled(vec![Ok(resolution)], vec![]);
@@ -178,7 +189,7 @@ fn empty_resolution_assembles_to_none() {
 #[test]
 fn all_deleted_resolution_assembles_to_none() {
     let resolution = Resolution {
-        prefix: parent_prefix(3),
+        prefix: parent_prefix(3).erase(),
         resolved: vec![(1, Resolve::Ready(None)), (2, Resolve::Ready(None))],
     };
     let output = assembled(vec![Ok(resolution)], vec![]);
@@ -192,31 +203,34 @@ fn chains_two_instances() {
     let mut version = Version::new();
     let (a, b) = (leaf(&mut version), leaf(&mut version));
 
-    let lower: Vec, Error>> = vec![Ok(Resolution {
-        prefix: parent_prefix(3),
+    let lower: Vec = vec![Ok(Resolution {
+        prefix: parent_prefix(3).erase(),
         resolved: vec![
-            (1, Resolve::Ready(Some(a.clone()))),
-            (7, Resolve::Ready(Some(b.clone()))),
+            (1, Resolve::Ready(Some(erased(a.clone())))),
+            (7, Resolve::Ready(Some(erased(b.clone())))),
         ],
     })];
-    let upper: Vec>, Error>> = vec![Ok(Resolution {
-        prefix: parent_prefix(3).pop().0,
+    let upper: Vec = vec![Ok(Resolution {
+        prefix: parent_prefix(3).pop().0.erase(),
         resolved: vec![(3, Resolve::Pending)],
     })];
 
-    let chained = assemble(
+    let chained = assemble::(
         Local,
         stream::iter(upper),
-        assemble(Local, stream::iter(lower), stream::empty()),
+        assemble::(Local, stream::iter(lower), stream::empty()),
     );
     let output =
         pollster::block_on(chained.try_collect::>()).expect("no errors were fed in");
 
     let inner = parent_of(parent_prefix(3), vec![(1, Some(a)), (7, Some(b))])
         .expect("a non-empty all-real group constructs its parent");
-    let expected =
-        pollster::block_on(Local.parent(parent_prefix(3).pop().0, vec![(3, Some(inner))]))
-            .unwrap_or_else(|e| match e {});
+    let expected = pollster::block_on(::parent(
+        Local,
+        parent_prefix(3).pop().0,
+        vec![(3, Some(inner))],
+    ))
+    .unwrap_or_else(|e| match e {});
     assert_eq!(
         output
             .into_iter()
@@ -242,7 +256,7 @@ fn resolution_error_passes_through() {
 #[test]
 fn level_error_passes_through() {
     let resolution = Resolution {
-        prefix: parent_prefix(3),
+        prefix: parent_prefix(3).erase(),
         resolved: vec![(1, Resolve::Pending)],
     };
     let output = assembled(
diff --git a/src/tree/mirror/streaming/materialized/work/tests/violations.rs b/src/tree/mirror/streaming/materialized/work/tests/violations.rs
index f1c3359ea..fa409eb76 100644
--- a/src/tree/mirror/streaming/materialized/work/tests/violations.rs
+++ b/src/tree/mirror/streaming/materialized/work/tests/violations.rs
@@ -10,11 +10,10 @@ use crate::tree::mirror::streaming::stats::Recorder;
 use crate::{
     Version,
     tree::mirror::streaming::{
-        Local,
+        Backend, Local,
         materialized::{
             Error, Query, SupplyLedger, Violation, Work,
             channel::{Receiver, with_schedule},
-            unknown::Unknown,
             work::queues::internal_child_queries,
         },
         message::{Reaction, Reply},
@@ -26,6 +25,10 @@ use crate::{
         height::{Height, S, Z},
     },
 };
+
+/// The in-memory backend's erased node representation, which the walk's
+/// query queues carry.
+type Erased = ::Erased;
 /// One deliberately malformed counterparty script and the exact violation it
 /// must surface.
 #[derive(Clone, Copy, Debug)]
@@ -70,11 +73,11 @@ fn arb_injection() -> impl Strategy {
 
 /// Build a node at any traversal height from one path-compressed leaf.
 trait TestHeight: Height + Sized {
-    fn node(version: &mut Version) -> typed::Node<(), Self>;
+    fn node(version: &mut Version) -> typed::Node;
 }
 
 impl TestHeight for Z {
-    fn node(version: &mut Version) -> typed::Node<(), Self> {
+    fn node(version: &mut Version) -> typed::Node {
         leaf(version)
     }
 }
@@ -83,7 +86,7 @@ impl TestHeight for S
 where
     S: Height,
 {
-    fn node(version: &mut Version) -> typed::Node<(), Self> {
+    fn node(version: &mut Version) -> typed::Node {
         typed::Node::beneath(H::node(version), 0)
     }
 }
@@ -99,11 +102,7 @@ fn violation_script(
     injection: Injection,
     parent: u8,
     radixes: &BTreeSet,
-) -> (
-    Option>,
-    Vec>,
-    Version,
-)
+) -> (Option>, Vec>, Version)
 where
     H: TestHeight,
     S: Height,
@@ -120,8 +119,11 @@ where
     path[0] = parent;
     let prefix = Prefix::>::containing(&Path::from(path));
     let query = Query {
-        prefix,
-        ours: ours.clone(),
+        prefix: prefix.erase(),
+        ours: ours
+            .iter()
+            .map(|(radix, node)| (*radix, ::erase(node.clone())))
+            .collect(),
     };
 
     let matches = || {
@@ -168,7 +170,7 @@ where
             let radix = *radixes.first().expect("the strategy produces a child");
             (
                 Some(Query {
-                    prefix,
+                    prefix: prefix.erase(),
                     ours: Vec::new(),
                 }),
                 vec![Reply {
@@ -185,7 +187,7 @@ where
             let radix = *radixes.first().expect("the strategy produces a child");
             (
                 Some(Query {
-                    prefix,
+                    prefix: prefix.erase(),
                     ours: Vec::new(),
                 }),
                 vec![Reply {
@@ -197,13 +199,14 @@ where
     (query, replies, declared)
 }
 
-/// Put the script's optional outstanding query into the walk's pairing queue.
-fn query_receiver(query: Option>) -> Receiver>
+/// Put the script's optional outstanding query into the walk's pairing
+/// queue, labeled at the script's height.
+fn query_receiver(query: Option>) -> Receiver>
 where
     H: Height,
     S: Height,
 {
-    let (queries, queries_rx) = internal_child_queries::(1);
+    let (queries, queries_rx) = internal_child_queries::(H::HEIGHT, 1);
     if let Some(query) = query {
         pollster::block_on(queries.send(query)).expect("the walk is live");
     }
@@ -213,8 +216,8 @@ where
 
 /// Drive a walk's response pump until it surfaces the injected violation.
 fn reported_violation(
-    work: Work,
-    mut responses: BoxResponses>,
+    work: Work,
+    mut responses: BoxResponses>,
 ) -> Violation {
     let response = pollster::block_on(async move {
         let drive = work.execute(Box::pin(std::future::pending::<
@@ -243,7 +246,7 @@ trait InjectHeight: TestHeight {
 impl InjectHeight for Z {
     fn inject(injection: Injection, parent: u8, radixes: &BTreeSet) -> Violation {
         let (query, requests, declared) = violation_script::(injection, parent, radixes);
-        let queries = query_receiver(query);
+        let queries = query_receiver::(query);
         let mut work = Work::new(Local, Window::FLOOR, Recorder::default());
         let (responses, _resolutions) = work.leaf_level(
             declared,
@@ -258,7 +261,7 @@ impl InjectHeight for Z {
 impl InjectHeight for S {
     fn inject(injection: Injection, parent: u8, radixes: &BTreeSet) -> Violation {
         let (query, requests, declared) = violation_script::(injection, parent, radixes);
-        let queries = query_receiver(query);
+        let queries = query_receiver::(query);
         let mut work = Work::new(Local, Window::FLOOR, Recorder::default());
         let (responses, _asked, _upper, _lower) = work.leaf_parent_level(
             declared,
@@ -272,14 +275,14 @@ impl InjectHeight for S {
 
 impl InjectHeight for S>
 where
-    H: TestHeight + Unknown,
-    S: Unknown,
-    S>: TestHeight + Unknown,
+    H: TestHeight,
+    S: Height,
+    S>: TestHeight,
     S>>: Height,
 {
     fn inject(injection: Injection, parent: u8, radixes: &BTreeSet) -> Violation {
         let (query, requests, declared) = violation_script::(injection, parent, radixes);
-        let queries = query_receiver(query);
+        let queries = query_receiver::(query);
         let mut work = Work::new(Local, Window::FLOOR, Recorder::default());
         let (responses, _asked, _upper, _lower) = work.internal_level::(
             declared,
diff --git a/src/tree/mirror/streaming/message.rs b/src/tree/mirror/streaming/message.rs
index 4f6e70706..5f7528026 100644
--- a/src/tree/mirror/streaming/message.rs
+++ b/src/tree/mirror/streaming/message.rs
@@ -144,9 +144,9 @@ pub(crate) fn initiates(
 }
 
 /// The sole stream message.
-pub struct Reply: Leaf>, T: Send + Sync + 'static, H: Height> {
+pub struct Reply: Leaf>, H: Height> {
     /// The reactions to a single previous query.
-    pub replies: Vec>,
+    pub replies: Vec>,
 }
 
 /// Reactions are positionally keyed against the corresponding
@@ -155,7 +155,7 @@ pub struct Reply: Leaf>, T: Send + Sync + 'static, H: H
 /// The exception is [`Reaction::Supply`], which indicates its radix because
 /// it represents information that the counterparty could not have known to
 /// ask about.
-pub enum Reaction: Leaf>, T: Send + Sync + 'static, H: Height> {
+pub enum Reaction: Leaf>, H: Height> {
     /// Having inferred that the counterparty lacks this node through its
     /// absence in the counterparty's listing of hashes, we provide it, at
     /// this radix.
diff --git a/src/tree/mirror/streaming/protocol.rs b/src/tree/mirror/streaming/protocol.rs
index a1d67c7ba..88e8aa1b4 100644
--- a/src/tree/mirror/streaming/protocol.rs
+++ b/src/tree/mirror/streaming/protocol.rs
@@ -31,36 +31,30 @@ pub trait Protocol: Send {
 }
 
 /// Trait synonym: non-erroring message streams, the shape of incoming streams.
-pub trait Requests: Leaf>, T: Send + Sync + 'static, H: Height>:
-    Stream> + Send + 'static
+pub trait Requests: Leaf>, H: Height>:
+    Stream> + Send + 'static
 {
 }
-impl: Leaf>, T: Send + Sync + 'static, H: Height> Requests
-    for X
-where
-    X: Stream> + Send + 'static,
+impl: Leaf>, H: Height> Requests for X where
+    X: Stream> + Send + 'static
 {
 }
 
 /// Trait synonym: fallible message streams, the shape of outgoing streams.
-pub trait Responses: Leaf>, T: Send + Sync + 'static, H: Height, E>:
-    Stream, E>> + Send + 'static
+pub trait Responses: Leaf>, H: Height, E>:
+    Stream, E>> + Send + 'static
 {
 }
-impl: Leaf>, T: Send + Sync + 'static, H: Height, E>
-    Responses for X
-where
-    X: Stream, E>> + Send + 'static,
+impl: Leaf>, H: Height, E> Responses for X where
+    X: Stream, E>> + Send + 'static
 {
 }
 
 /// A boxed [`Responses`] stream.
-pub type BoxResponses = Pin>>;
+pub type BoxResponses = Pin>>;
 
-pub trait Connect: Leaf>, T: Send + Sync + 'static>:
-    Protocol + Sized
-{
-    type Next: CompleteConnect
+pub trait Connect: Leaf>>: Protocol + Sized {
+    type Next: CompleteConnect
         + Protocol;
 
     fn connect(
@@ -68,12 +62,10 @@ pub trait Connect: Leaf>, T: Send + Sync + 'static>:
     ) -> impl Future> + Send;
 }
 
-pub trait Accept: Leaf>, T: Send + Sync + 'static>:
-    Protocol + Sized
-{
-    type Next: CompleteEqual
-        + Initiator
-        + Responder
+pub trait Accept: Leaf>>: Protocol + Sized {
+    type Next: CompleteEqual
+        + Initiator
+        + Responder
         + Protocol;
 
     fn accept(
@@ -82,12 +74,10 @@ pub trait Accept: Leaf>, T: Send + Sync + 'static>:
     ) -> impl Future> + Send;
 }
 
-pub trait CompleteConnect: Leaf>, T: Send + Sync + 'static>:
-    Protocol + Sized
-{
-    type Next: CompleteEqual
-        + Initiator
-        + Responder
+pub trait CompleteConnect: Leaf>>: Protocol + Sized {
+    type Next: CompleteEqual
+        + Initiator
+        + Responder
         + Protocol;
 
     fn complete_connect(
@@ -102,9 +92,7 @@ pub trait CompleteConnect: Leaf>, T: Send + Sync + 'sta
 /// must still be converted into its normal output. A materialized state returns
 /// the root it already holds; a remote proxy returns its transport halves so
 /// the caller can continue with trailing session frames.
-pub trait CompleteEqual: Leaf>, T: Send + Sync + 'static>:
-    Protocol + Sized
-{
+pub trait CompleteEqual: Leaf>>: Protocol + Sized {
     fn complete_equal(self) -> impl Future> + Send;
 }
 
@@ -119,9 +107,7 @@ pub trait CompleteEqual: Leaf>, T: Send + Sync + 'stati
 /// wire that makes this stage free: the remote proxy replays the greeting's
 /// listing instead of spending a hop on a standalone opening frame, and only
 /// the in-process message below actually flows.
-pub trait Initiator: Leaf>, T: Send + Sync + 'static>:
-    Protocol + Sized
-{
+pub trait Initiator: Leaf>>: Protocol + Sized {
     type Next: Protocol;
 
     fn initiator(
@@ -129,14 +115,12 @@ pub trait Initiator: Leaf>, T: Send + Sync + 'static>:
     ) -> (
         // IMPORTANT: This must be boxed because otherwise `rustc` explodes on
         // an exponentially-sized type!
-        BoxResponses,
+        BoxResponses,
         Self::Next,
     );
 }
 
-pub trait Responder: Leaf>, T: Send + Sync + 'static>:
-    Protocol + Sized
-{
+pub trait Responder: Leaf>>: Protocol + Sized {
     // Like [`Initiator::Next`], this is left un-bounded by [`Reply`]: both
     // openings hand off to the descent, but only [`Peer`] spells the chain out.
     // Naming `Reply` here instead would make it a bound `Accept::Next` and
@@ -146,11 +130,11 @@ pub trait Responder: Leaf>, T: Send + Sync + 'static>:
 
     fn responder(
         self,
-        requests: impl Requests,
+        requests: impl Requests,
     ) -> (
         // IMPORTANT: This must be boxed because otherwise `rustc` explodes on
         // an exponentially-sized type!
-        BoxResponses,
+        BoxResponses,
         Self::Next,
     );
 }
@@ -176,9 +160,7 @@ where
     type Next = H;
 }
 
-pub trait Reply: Leaf>, T: Send + Sync + 'static>:
-    Protocol + Sized
-{
+pub trait Reply: Leaf>>: Protocol + Sized {
     type Next: Protocol<
             Height = ::Next,
             Output = Self::Output,
@@ -187,11 +169,11 @@ pub trait Reply: Leaf>, T: Send + Sync + 'static>:
 
     fn reply(
         self,
-        requests: impl Requests,
+        requests: impl Requests,
     ) -> (
         // IMPORTANT: This must be boxed because otherwise `rustc` explodes on
         // an exponentially-sized type!
-        BoxResponses::Output, Self::Error>,
+        BoxResponses::Output, Self::Error>,
         Self::Next,
     );
 }
@@ -201,25 +183,21 @@ pub trait Reply: Leaf>, T: Send + Sync + 'static>:
 ///
 /// Each requested leaf is answered pruned against the initiator's version,
 /// so a leaf the initiator deleted drops here instead of shipping.
-pub trait CompleteResponder: Leaf>, T: Send + Sync + 'static>:
-    Protocol + Sized
-{
+pub trait CompleteResponder: Leaf>>: Protocol + Sized {
     fn complete_responder(
         self,
-        requests: impl Requests,
+        requests: impl Requests,
     ) -> (
-        BoxResponses,
+        BoxResponses,
         impl Future> + Send,
     );
 }
 
 /// The initiator's terminal: absorb the responder's final leaf replies and
 /// resolve to the reconciled root.
-pub trait CompleteInitiator: Leaf>, T: Send + Sync + 'static>:
-    Protocol + Sized
-{
+pub trait CompleteInitiator: Leaf>>: Protocol + Sized {
     fn complete_initiator(
         self,
-        requests: impl Requests,
+        requests: impl Requests,
     ) -> impl Future> + Send;
 }
diff --git a/src/tree/mirror/streaming/protocol/peer.rs b/src/tree/mirror/streaming/protocol/peer.rs
index ed981f67d..3533d9328 100644
--- a/src/tree/mirror/streaming/protocol/peer.rs
+++ b/src/tree/mirror/streaming/protocol/peer.rs
@@ -18,8 +18,8 @@ macro_rules! define_peer {
         define_peer!(@step
             init: [$($init_count)*],
             resp: [$($resp_count)*],
-            init_chain: (Reply>),
-            resp_chain: (Reply>),
+            init_chain: (Reply>),
+            resp_chain: (Reply>),
         );
     };
 
@@ -32,7 +32,7 @@ macro_rules! define_peer {
         define_peer!(@step
             init: [$($init_rest)*],
             resp: [$($resp_count)*],
-            init_chain: (Reply),
+            init_chain: (Reply),
             resp_chain: ($($resp_chain)*),
         );
     };
@@ -47,7 +47,7 @@ macro_rules! define_peer {
             init: [],
             resp: [$($resp_rest)*],
             init_chain: ($($init_chain)*),
-            resp_chain: (Reply),
+            resp_chain: (Reply),
         );
     };
 
@@ -57,55 +57,49 @@ macro_rules! define_peer {
         init_chain: ($($init_chain:tt)*),
         resp_chain: ($($resp_chain:tt)*) $(,)?
     ) => {
-        pub trait Peer:
-            CompleteEqual
-            + Initiator
-            + Responder
+        pub trait Peer:
+            CompleteEqual
+            + Initiator
+            + Responder
         where
-            I: Backend: Leaf>,
-            T: Send + Sync + 'static,
-        {
+            I: Backend: Leaf>,
+                    {
         }
 
-        impl Peer for X
+        impl Peer for X
         where
-            I: Backend: Leaf>,
-            T: Send + Sync + 'static,
-            X: CompleteEqual
-                + Initiator
-                + Responder,
+            I: Backend: Leaf>,
+                        X: CompleteEqual
+                + Initiator
+                + Responder,
         {
         }
 
-        pub trait Server:
-            Accept + Responder>
+        pub trait Server:
+            Accept + Responder>
         where
-            I: Backend: Leaf>,
-            T: Send + Sync + 'static,
-        {
+            I: Backend: Leaf>,
+                    {
         }
 
-        impl Server for X
+        impl Server for X
         where
-            I: Backend: Leaf>,
-            T: Send + Sync + 'static,
-            X: Accept + Responder>,
+            I: Backend: Leaf>,
+                        X: Accept + Responder>,
         {
         }
 
-        pub trait Client:
-            Connect + Responder>>
+        pub trait Client:
+            Connect + Responder>>
         where
-            I: Backend: Leaf>,
-            T: Send + Sync + 'static,
-        {
+            I: Backend: Leaf>,
+                    {
         }
 
-        impl Client for X
+        impl Client for X
         where
-            I: Backend: Leaf>,
-            T: Send + Sync + 'static,
-            X: Connect + Responder>>,
+            I: Backend: Leaf>,
+                        X: Connect + Responder>>,
         {
         }
     };
diff --git a/src/tree/mirror/streaming/remote.rs b/src/tree/mirror/streaming/remote.rs
index 9d9b6b92d..aaf6470ce 100644
--- a/src/tree/mirror/streaming/remote.rs
+++ b/src/tree/mirror/streaming/remote.rs
@@ -33,7 +33,7 @@
 //! Supplied leaves ship in *runs*: one exact-length-delimited body carrying
 //! one or more leaf records, each itself exact-length-delimited — a CBOR
 //! byte string wrapping the [`Version`](crate::Version)'s canonical bytes,
-//! then the [`Message`](crate::message::Message)'s CBOR payload. The
+//! then the [`Message`](crate::message::Message)'s CBOR payload. The
 //! encoder chunks a supplied subtree's leaves into runs by a byte budget
 //! ([`RunBudget`]); once a run's whole body arrives, the frame codec
 //! validates its record framing and the incoming adapter decodes each
diff --git a/src/tree/mirror/streaming/remote/adapter.rs b/src/tree/mirror/streaming/remote/adapter.rs
index 911ba3c30..7b0ff35c7 100644
--- a/src/tree/mirror/streaming/remote/adapter.rs
+++ b/src/tree/mirror/streaming/remote/adapter.rs
@@ -4,13 +4,13 @@
 //! levels. In memory, one [`Reply`](super::super::message::Reply) contains
 //! backend node handles and omits its prefix because the receiver already knows
 //! which earlier question it answers. On the wire, a supplied node is flattened
-//! into runs of backend-neutral `(Version, Message)` leaf records which
+//! into runs of backend-neutral `(Version, Message)` leaf records which
 //! still carry neither prefix nor radix. This module is the lossless boundary
 //! between them:
 //!
 //! ```text
-//! Reply -- encode + explode --> Frame leaves
-//! Reply <-- decode + assemble -- Frame leaves
+//! Reply -- encode + explode --> Frame leaves
+//! Reply <-- decode + assemble -- Frame leaves
 //! ```
 //!
 //! # Recovering omitted scope
diff --git a/src/tree/mirror/streaming/remote/adapter/decode.rs b/src/tree/mirror/streaming/remote/adapter/decode.rs
index d08d55011..a7b82e413 100644
--- a/src/tree/mirror/streaming/remote/adapter/decode.rs
+++ b/src/tree/mirror/streaming/remote/adapter/decode.rs
@@ -1,3 +1,4 @@
+use crate::message::PayloadDeserializer;
 use std::pin::pin;
 use std::task::Poll;
 
@@ -10,15 +11,11 @@ use crate::tree::{
     mirror::streaming::{
         Backend, Leaf,
         backend::BoxNodeStream,
-        convert::Convert,
+        erased::{Reaction as ProtocolReaction, Reply, ops},
         materialized::SupplyLedger,
-        message::{Reaction as ProtocolReaction, Reply},
         window::FAN,
     },
-    typed::{
-        Hash, Path, Prefix,
-        height::{Height, S, UnderRoot, Z},
-    },
+    typed::{ErasedPrefix, Hash, Path, Prefix, height::Z},
 };
 
 use super::{
@@ -27,15 +24,9 @@ use super::{
     scope::Scope,
 };
 
-use serde::de::DeserializeOwned;
 /// One reconstructed reply and any questions it asks next.
-pub struct Decoded
-where
-    B: Backend: Leaf>,
-    T: Send + Sync + 'static,
-    H: Height,
-{
-    pub reply: Reply,
+pub struct Decoded {
+    pub reply: Reply,
     pub questions: Q,
 }
 
@@ -46,11 +37,7 @@ where
 /// validated the listing's canonical order, so synthesizing the one-query
 /// reply and its root scope is infallible. An empty listing replays an empty
 /// opening `Query` — the empty-tree initiator's "send everything".
-pub fn opening_reply(listing: Vec<(u8, Hash)>) -> (Reply, Scope)
-where
-    B: Backend: Leaf>,
-    T: Send + Sync + 'static,
-{
+pub fn opening_reply(listing: Vec<(u8, Hash)>) -> (Reply, Scope) {
     let scope = Scope::opening(&listing);
     (
         Reply {
@@ -61,14 +48,15 @@ where
 }
 
 /// Incrementally decode the initiator's opening-supply reply into whole
-/// height-`G` nodes with their root radices, in ascending radix order.
+/// nodes one level under `parent`, with their root radices, in ascending
+/// radix order.
 ///
 /// The wire shape is one supplies-only reply — empty when deletion pruning
-/// left nothing to ship — whose leaf records group into height-`G`
-/// subtrees by their version-derived paths under `parent`, followed by the
-/// stream end. Unlike [`decode_reply`], which materializes one whole reply
-/// before yielding it, this stream yields each assembled node as soon as
-/// its group completes: the consumer pairs supplies with the responder's
+/// left nothing to ship — whose leaf records group into subtrees one level
+/// under `parent` by their version-derived paths, followed by the stream
+/// end. Unlike [`decode_reply`], which materializes one whole reply before
+/// yielding it, this stream yields each assembled node as soon as its
+/// group completes: the consumer pairs supplies with the responder's
 /// root-level requests one radix at a time, so a later group's bulk never
 /// gates an earlier group's absorption.
 ///
@@ -77,19 +65,17 @@ where
 /// [`DecodeError::OversizedVersion`] session violation. `ledger` is the
 /// session's declared-`set_len` allowance, charged per record before the
 /// payload takes custody ([`DecodeError::OverdrawnSupply`]).
-pub fn early_supplies(
+pub fn early_supplies(
     backend: B,
     version_bytes: u64,
     ledger: SupplyLedger,
-    parent: Prefix>,
+    parent: ErasedPrefix,
     frames: F,
-) -> impl Stream), DecodeError>> + Send
+    deserializer: PayloadDeserializer,
+) -> impl Stream>> + Send
 where
-    B: Backend: Leaf>,
-    T: DeserializeOwned + Send + Sync + 'static,
-    G: Convert,
-    S: Height,
-    F: Stream> + Unpin + Send + 'static,
+    B: Backend: Leaf>,
+    F: Stream + Unpin + Send + 'static,
 {
     try_stream! {
         // The same reader/assembler split as `decode`, driven jointly so
@@ -98,14 +84,19 @@ where
         let leaves = ReceiverStream::new(rx);
         #[cfg(test)]
         let leaves = leaves.inspect(|_| fan_probe::on_recv());
-        let leaves: BoxNodeStream<'static, B, T, Z> = Box::pin(leaves);
-        let mut assembled = pin!(backend.clone().assemble::(leaves));
-        let mut read = pin!(read_early::(
+        let leaves: BoxNodeStream<'static, B, Z> = Box::pin(leaves);
+        let mut assembled = pin!(ops::assemble(
+            backend.clone(),
+            parent.height() - 1,
+            leaves
+        ));
+        let mut read = pin!(read_early::(
             version_bytes,
             &ledger,
             parent,
             frames,
-            tx
+            tx,
+            deserializer,
         ));
         let mut read_result: Option>> = None;
         loop {
@@ -142,21 +133,19 @@ where
 
 /// Read the opening-supply reply's frames — supplies only, one reply,
 /// nothing after it — streaming its leaves to assembly.
-async fn read_early(
+async fn read_early(
     version_bytes: u64,
     ledger: &SupplyLedger,
-    parent: Prefix>,
+    parent: ErasedPrefix,
     mut frames: F,
     leaves: mpsc::Sender, B::Node), B::Error>>,
+    deserializer: PayloadDeserializer,
 ) -> Result<(), DecodeError>
 where
-    B: Backend: Leaf>,
-    T: DeserializeOwned + Send + Sync + 'static,
-    G: Height,
-    S: Height,
-    F: Stream> + Unpin,
+    B: Backend: Leaf>,
+    F: Stream + Unpin,
 {
-    let mut supplies = SupplyRuns::::new(version_bytes);
+    let mut supplies = SupplyRuns::new(version_bytes);
     let mut any = false;
     loop {
         let Some(frame) = frames.next().await else {
@@ -165,7 +154,7 @@ where
         let flow = match frame {
             Frame::Reaction(WireReaction::Supply(records), flow) => {
                 any = true;
-                for record in records.records() {
+                for record in records.records(deserializer) {
                     let (version, message) = record.map_err(DecodeError::Record)?;
                     let (leaf_prefix, _) = supplies.observe::(parent, &version)?;
                     // The set-length half of the greeting's priced
@@ -176,7 +165,7 @@ where
                     ledger
                         .charge(1)
                         .map_err(|declared| DecodeError::OverdrawnSupply { declared })?;
-                    let leaf =  as Leaf>::leaf(version, message)
+                    let leaf =  as Leaf>::leaf(version, message)
                         .await
                         .map_err(DecodeError::Backend)?;
                     #[cfg(test)]
@@ -211,20 +200,17 @@ where
 }
 
 /// Decode one non-leaf reply and derive the lower questions it asks.
-pub async fn decode_reply(
+pub async fn decode_reply(
     backend: B,
     version_bytes: u64,
     ledger: SupplyLedger,
-    scope: Scope>,
+    scope: Scope,
     frames: &mut F,
-) -> Result, Vec>>, DecodeError>
+    deserializer: PayloadDeserializer,
+) -> Result>, DecodeError>
 where
-    B: Backend: Leaf>,
-    T: DeserializeOwned + Send + Sync + 'static,
-    H: Height,
-    S: Convert,
-    S>: Height,
-    F: Stream> + Unpin,
+    B: Backend: Leaf>,
+    F: Stream + Unpin,
 {
     decode(
         backend,
@@ -236,22 +222,23 @@ where
             let (_, prefix) = scope.next().ok_or(ScopeError::UnpositionedQuery)?;
             Ok(Scope::new(prefix, listing))
         },
+        deserializer,
     )
     .await
 }
 
 /// Decode one leaf-height reply, where only an empty request for the leaf is valid.
-pub async fn decode_leaf_reply(
+pub async fn decode_leaf_reply(
     backend: B,
     version_bytes: u64,
     ledger: SupplyLedger,
-    scope: Scope,
+    scope: Scope,
     frames: &mut F,
-) -> Result>>, DecodeError>
+    deserializer: PayloadDeserializer,
+) -> Result>, DecodeError>
 where
-    B: Backend: Leaf>,
-    T: DeserializeOwned + Send + Sync + 'static,
-    F: Stream> + Unpin,
+    B: Backend: Leaf>,
+    F: Stream + Unpin,
 {
     decode(
         backend,
@@ -266,26 +253,28 @@ where
             let (_, prefix) = scope.next().ok_or(ScopeError::UnpositionedQuery)?;
             Ok(Scope::leaf(prefix))
         },
+        deserializer,
     )
     .await
 }
 
-async fn decode(
+async fn decode(
     backend: B,
     version_bytes: u64,
     ledger: SupplyLedger,
-    scope: Scope,
+    scope: Scope,
     frames: &mut F,
     question: Q,
-) -> Result>, DecodeError>
+    deserializer: PayloadDeserializer,
+) -> Result>, DecodeError>
 where
-    B: Backend: Leaf>,
-    T: DeserializeOwned + Send + Sync + 'static,
-    H: Convert,
-    S: Height,
-    F: Stream> + Unpin,
-    Q: FnMut(&mut Scope, &[(u8, Hash)]) -> Result,
+    B: Backend: Leaf>,
+    F: Stream + Unpin,
+    Q: FnMut(&mut Scope, &[(u8, Hash)]) -> Result,
 {
+    // The reply's supplied runs group into nodes one level under the
+    // scope's parent: the scope's own children height.
+    let children_height = scope.parent().height() - 1;
     // One fan of buffered leaves, amortizing the reader/assembler waker
     // round trip over runs of consecutive leaves instead of paying it per
     // leaf. The capacity is load-bearing for liveness: the channel must
@@ -297,8 +286,16 @@ where
     // reader's hand per reply stream, at `node_bytes(0, version_bound)`
     // plus the slot itself (the window's supply-decode envelope).
     let (tx, rx) = mpsc::channel::, B::Node), B::Error>>(FAN);
-    let read = read_reply::(version_bytes, &ledger, scope, frames, question, tx);
-    let assemble = assemble_supplies::(backend, rx);
+    let read = read_reply::(
+        version_bytes,
+        &ledger,
+        scope,
+        frames,
+        question,
+        tx,
+        deserializer,
+    );
+    let assemble = assemble_supplies::(backend, children_height, rx);
     let (read, assembled) = futures::future::join(read, assemble).await;
     let Some(ReadReply {
         skeleton,
@@ -309,26 +306,24 @@ where
         assembled?;
         unreachable!("the assembler accepts leaves until it returns an error")
     };
-    let reply = reify(skeleton, assembled?);
+    let reply = reify::(skeleton, assembled?);
     Ok(Decoded { reply, questions })
 }
 
 /// Read and validate exactly one reply while streaming its leaves to assembly.
-async fn read_reply(
+async fn read_reply(
     version_bytes: u64,
     ledger: &SupplyLedger,
-    mut scope: Scope,
+    mut scope: Scope,
     frames: &mut F,
     mut question: Q,
     leaves: mpsc::Sender, B::Node), B::Error>>,
-) -> Result>, DecodeError>
+    deserializer: PayloadDeserializer,
+) -> Result>, DecodeError>
 where
-    B: Backend: Leaf>,
-    T: DeserializeOwned + Send + Sync + 'static,
-    H: Height,
-    S: Height,
-    F: Stream> + Unpin,
-    Q: FnMut(&mut Scope, &[(u8, Hash)]) -> Result,
+    B: Backend: Leaf>,
+    F: Stream + Unpin,
+    Q: FnMut(&mut Scope, &[(u8, Hash)]) -> Result,
 {
     let mut read = ReadReply::new(version_bytes);
     loop {
@@ -369,7 +364,7 @@ where
                 // Records leave the run one at a time and flow straight into
                 // assembly: the whole-run bound is its encoded bytes, never a
                 // decoded vector of leaves.
-                for record in records.records() {
+                for record in records.records(deserializer) {
                     let (version, message) = record.map_err(DecodeError::Record)?;
                     let (leaf_prefix, run) = read
                         .supplies
@@ -385,7 +380,7 @@ where
                     ledger
                         .charge(1)
                         .map_err(|declared| DecodeError::OverdrawnSupply { declared })?;
-                    let leaf =  as Leaf>::leaf(version, message)
+                    let leaf =  as Leaf>::leaf(version, message)
                         .await
                         .map_err(DecodeError::Backend)?;
                     #[cfg(test)]
@@ -404,21 +399,21 @@ where
     Ok(Some(read))
 }
 
-/// Fold the reply's one-slot leaf stream into complete height-`H` nodes.
-async fn assemble_supplies(
+/// Fold the reply's one-slot leaf stream into complete nodes at the
+/// scope's children height.
+async fn assemble_supplies(
     backend: B,
+    height: usize,
     leaves: mpsc::Receiver, B::Node), B::Error>>,
-) -> Result, B::Node)>, DecodeError>
+) -> Result, DecodeError>
 where
-    B: Backend: Leaf>,
-    T: Send + Sync + 'static,
-    H: Convert,
+    B: Backend: Leaf>,
 {
     let leaves = ReceiverStream::new(leaves);
     #[cfg(test)]
     let leaves = leaves.inspect(|_| fan_probe::on_recv());
-    let leaves: BoxNodeStream<'_, B, T, Z> = Box::pin(leaves);
-    let mut assembled = pin!(backend.assemble::(leaves));
+    let leaves: BoxNodeStream<'static, B, Z> = Box::pin(leaves);
+    let mut assembled = pin!(ops::assemble(backend, height, leaves));
     let mut nodes = Vec::new();
     while let Some(item) = assembled.next().await {
         nodes.push(item.map_err(DecodeError::Backend)?);
@@ -427,12 +422,7 @@ where
 }
 
 /// Replace supplied-prefix placeholders with the nodes assembled for them.
-fn reify(skeleton: Vec>, nodes: Vec<(Prefix, B::Node)>) -> Reply
-where
-    B: Backend: Leaf>,
-    T: Send + Sync + 'static,
-    H: Height,
-{
+fn reify(skeleton: Vec, nodes: Vec<(ErasedPrefix, E)>) -> Reply {
     let mut nodes = nodes.into_iter();
     let replies = skeleton
         .into_iter()
@@ -458,13 +448,13 @@ where
     Reply { replies }
 }
 
-struct ReadReply {
-    skeleton: Vec>,
+struct ReadReply {
+    skeleton: Vec,
     questions: Vec,
-    supplies: SupplyRuns,
+    supplies: SupplyRuns,
 }
 
-impl ReadReply {
+impl ReadReply {
     fn new(version_bytes: u64) -> Self {
         Self {
             skeleton: Vec::new(),
@@ -474,16 +464,16 @@ impl ReadReply {
     }
 }
 
-struct SupplyRuns {
+struct SupplyRuns {
     /// The peer's greeting-declared `max_version_bytes`, covering every
     /// version its tree materializes and so every version it may supply.
     version_bytes: u64,
     previous_leaf: Option>,
-    current: Option>,
+    current: Option,
     previous_radix: Option,
 }
 
-impl SupplyRuns {
+impl SupplyRuns {
     fn new(version_bytes: u64) -> Self {
         Self {
             version_bytes,
@@ -498,14 +488,15 @@ impl SupplyRuns {
     }
 
     /// Validate one supplied leaf and identify the start of a new run.
+    ///
+    /// The run boundary sits one level under `expected_parent`: the
+    /// supplied leaf's path must extend the parent prefix, and the byte
+    /// after it is the run's radix.
     fn observe(
         &mut self,
-        expected_parent: Prefix>,
+        expected_parent: ErasedPrefix,
         version: &crate::Version,
-    ) -> Result<(Prefix, Option<(u8, Prefix)>), DecodeError>
-    where
-        S: Height,
-    {
+    ) -> Result<(Prefix, Option<(u8, ErasedPrefix)>), DecodeError> {
         // The declared aggregate covers every version the peer's tree
         // materializes, so every version it supplies must encode within
         // it; one arriving over the declaration voids the premise the
@@ -520,14 +511,16 @@ impl SupplyRuns {
         }
         let path = Path::for_leaf(version);
         let leaf_prefix = Prefix::::containing(&path);
-        let node_prefix = Prefix::::containing(&path);
-        let (parent, radix) = node_prefix.pop();
-        if parent != expected_parent {
+        let path_bytes = <[u8; 32]>::from(path);
+        let parent_len = expected_parent.as_bytes().len();
+        if &path_bytes[..parent_len] != expected_parent.as_bytes() {
             return Err(DecodeError::LeafOutsideScope {
                 expected: expected_parent.as_bytes().to_vec(),
                 actual: path.into(),
             });
         }
+        let radix = path_bytes[parent_len];
+        let node_prefix = expected_parent.push(radix);
         if let Some(previous) = self
             .previous_leaf
             .filter(|previous| *previous >= leaf_prefix)
@@ -556,10 +549,10 @@ impl SupplyRuns {
     }
 }
 
-enum Skeleton {
+enum Skeleton {
     Match,
     Query(Vec<(u8, Hash)>),
-    Supply { radix: u8, prefix: Prefix },
+    Supply { radix: u8, prefix: ErasedPrefix },
 }
 
 /// Test-gated occupancy probe for the reader/assembler fan channels.
diff --git a/src/tree/mirror/streaming/remote/adapter/encode.rs b/src/tree/mirror/streaming/remote/adapter/encode.rs
index b624ec92d..3301c7a1a 100644
--- a/src/tree/mirror/streaming/remote/adapter/encode.rs
+++ b/src/tree/mirror/streaming/remote/adapter/encode.rs
@@ -6,13 +6,9 @@ use futures::{Stream, StreamExt};
 use crate::tree::{
     mirror::streaming::{
         Backend, Leaf, Node,
-        convert::Convert,
-        message::{Reaction as ProtocolReaction, Reply},
-    },
-    typed::{
-        Hash, Path, Prefix,
-        height::{Height, S, UnderRoot, Z},
+        erased::{Reaction as ProtocolReaction, Reply, ops},
     },
+    typed::{ErasedPrefix, Hash, Path, Prefix, height::Z},
 };
 
 use super::{
@@ -22,16 +18,16 @@ use super::{
 };
 
 /// A wire frame and the lower question it makes publishable once written.
-pub struct Encoded {
-    frame: Frame,
+pub struct Encoded {
+    frame: Frame,
     question: Option,
 }
 
-impl Encoded {
+impl Encoded {
     /// Write this frame and release its question only after a successful write.
     pub async fn write_with(self, write: W) -> Result, E>
     where
-        W: FnOnce(Frame) -> F,
+        W: FnOnce(Frame) -> F,
         F: Future>,
     {
         let Self { frame, question } = self;
@@ -40,14 +36,13 @@ impl Encoded {
     }
 
     #[cfg(test)]
-    pub fn into_parts(self) -> (Frame, Option) {
+    pub fn into_parts(self) -> (Frame, Option) {
         (self.frame, self.question)
     }
 }
 
 /// A fallible stream containing the wire frames of one protocol reply.
-pub type Frames =
-    Pin, EncodeError>> + Send>>;
+pub type Frames = Pin, EncodeError>> + Send>>;
 
 /// Validate the initiator's distinguished opening reply and split it into
 /// its question's listing and its early whole-subtree supplies.
@@ -60,13 +55,9 @@ pub type Frames =
 /// root children; they alone occupy wire frames, as the opening-supply
 /// reply on the initiator's first stream.
 #[allow(clippy::type_complexity)]
-pub fn opening_parts(
-    reply: Reply,
-) -> Result<(Vec<(u8, Hash)>, Vec>), OpeningError>
-where
-    B: Backend: Leaf>,
-    T: Send + Sync + 'static,
-{
+pub fn opening_parts(
+    reply: Reply,
+) -> Result<(Vec<(u8, Hash)>, Vec>), OpeningError> {
     let mut reactions = reply.replies.into_iter();
     let Some(first) = reactions.next() else {
         return Err(OpeningError::Empty);
@@ -86,18 +77,14 @@ where
 }
 
 /// Encode one non-leaf reply and derive the lower questions it asks.
-pub fn encode_reply(
+pub fn encode_reply(
     backend: B,
     budget: RunBudget,
-    scope: Scope>,
-    reply: Reply>,
-) -> Frames>
+    scope: Scope,
+    reply: Reply,
+) -> Frames
 where
-    B: Backend: Leaf>,
-    T: Send + Sync + 'static,
-    H: Height,
-    S: Convert,
-    S>: Height,
+    B: Backend: Leaf>,
 {
     render(
         backend,
@@ -121,15 +108,14 @@ where
 }
 
 /// Encode one leaf-height reply, where only an empty request for the leaf is valid.
-pub fn encode_leaf_reply(
+pub fn encode_leaf_reply(
     backend: B,
     budget: RunBudget,
-    scope: Scope,
-    reply: Reply,
-) -> Frames>
+    scope: Scope,
+    reply: Reply,
+) -> Frames
 where
-    B: Backend: Leaf>,
-    T: Send + Sync + 'static,
+    B: Backend: Leaf>,
 {
     render(
         backend,
@@ -155,20 +141,16 @@ where
     )
 }
 
-fn render(
+fn render(
     backend: B,
     budget: RunBudget,
-    mut scope: Scope,
-    reply: Reply,
+    mut scope: Scope,
+    reply: Reply,
     mut derive: D,
-) -> Frames
+) -> Frames
 where
-    B: Backend: Leaf>,
-    T: Send + Sync + 'static,
-    H: Convert,
-    S: Height,
-    Q: Send + 'static,
-    D: FnMut(&mut Scope, &ProtocolReaction) -> Result, ScopeError>
+    B: Backend: Leaf>,
+    D: FnMut(&mut Scope, &ProtocolReaction) -> Result, ScopeError>
         + Send
         + 'static,
 {
@@ -200,7 +182,7 @@ where
                 ProtocolReaction::Supply(radix, node) => {
                     debug_assert!(question.is_none());
                     let expected = scope.supplied(radix);
-                    let mut leaves = pin!(backend.clone().leaves(expected, node));
+                    let mut leaves = pin!(ops::leaves(backend.clone(), expected, node));
                     let mut previous = None;
                     // One run accumulates this reaction's leaves; it flushes
                     // when the next record would push its wire frame past
@@ -264,11 +246,11 @@ where
     })
 }
 
-fn validate_leaf(expected: Prefix, previous: Option>, current: Prefix) {
+fn validate_leaf(expected: ErasedPrefix, previous: Option>, current: Prefix) {
     let path = Path::from(current);
     assert_eq!(
-        Prefix::::containing(&path),
-        expected,
+        &<[u8; 32]>::from(path)[..expected.as_bytes().len()],
+        expected.as_bytes(),
         "a backend enumerates leaves beneath the requested node prefix",
     );
     if let Some(previous) = previous {
diff --git a/src/tree/mirror/streaming/remote/adapter/scope.rs b/src/tree/mirror/streaming/remote/adapter/scope.rs
index 99895e65d..3e94f1164 100644
--- a/src/tree/mirror/streaming/remote/adapter/scope.rs
+++ b/src/tree/mirror/streaming/remote/adapter/scope.rs
@@ -1,29 +1,23 @@
-use crate::tree::typed::{
-    Hash, Prefix,
-    height::{Height, S, UnderRoot, Z},
-};
+use crate::tree::typed::{ErasedPrefix, Hash, Prefix};
 
 /// The local knowledge needed to interpret one future prefix-free reply.
 ///
-/// `parent` names the scope whose height-`H` children the reply discusses;
-/// `children` preserves the positional radices from the `Query` which created
-/// it. Supplies remain self-keying and therefore do not advance `next`.
+/// `parent` names the scope whose children the reply discusses — its byte
+/// length is the scope's height witness, exactly one level above the
+/// children (see [`erased`](crate::tree::mirror::streaming::erased)) —
+/// and `children` preserves the positional radices from the `Query` which
+/// created it. Supplies remain self-keying and therefore do not advance
+/// `next`.
 #[derive(Debug, Clone, PartialEq, Eq)]
-pub struct Scope
-where
-    S: Height,
-{
-    parent: Prefix>,
+pub struct Scope {
+    parent: ErasedPrefix,
     children: Vec,
     next: usize,
 }
 
-impl Scope
-where
-    S: Height,
-{
+impl Scope {
     /// Record the question represented by `listing` at `parent`.
-    pub fn new(parent: Prefix>, listing: &[(u8, Hash)]) -> Self {
+    pub fn new(parent: ErasedPrefix, listing: &[(u8, Hash)]) -> Self {
         Self {
             parent,
             children: listing.iter().map(|(radix, _)| *radix).collect(),
@@ -32,7 +26,7 @@ where
     }
 
     /// The parent prefix against which keyed supplies are validated.
-    pub fn parent(&self) -> Prefix> {
+    pub fn parent(&self) -> ErasedPrefix {
         self.parent
     }
 
@@ -43,21 +37,27 @@ where
     }
 
     /// Resolve the next positional reaction to its child radix and prefix.
-    pub fn next(&mut self) -> Option<(u8, Prefix)> {
+    pub fn next(&mut self) -> Option<(u8, ErasedPrefix)> {
         let radix = *self.children.get(self.next)?;
         self.next += 1;
         Some((radix, self.parent.push(radix)))
     }
 
     /// Resolve a keyed supply to its claimed child prefix.
-    pub fn supplied(&self, radix: u8) -> Prefix {
+    pub fn supplied(&self, radix: u8) -> ErasedPrefix {
         self.parent.push(radix)
     }
-}
 
-impl Scope {
     /// Retain the one leaf position requested by a terminal empty query.
-    pub fn leaf(prefix: Prefix) -> Self {
+    ///
+    /// `prefix` is the requested leaf's full path, so the derived scope
+    /// sits one level above the leaves.
+    pub fn leaf(prefix: ErasedPrefix) -> Self {
+        debug_assert_eq!(
+            prefix.height(),
+            0,
+            "a terminal request names a full leaf path",
+        );
         let (parent, radix) = prefix.pop();
         Self {
             parent,
@@ -65,11 +65,9 @@ impl Scope {
             next: 0,
         }
     }
-}
 
-impl Scope {
     /// Record the initiator's opening question about the root's children.
     pub fn opening(listing: &[(u8, Hash)]) -> Self {
-        Self::new(Prefix::new(), listing)
+        Self::new(Prefix::new().erase(), listing)
     }
 }
diff --git a/src/tree/mirror/streaming/remote/adapter/tests.rs b/src/tree/mirror/streaming/remote/adapter/tests.rs
index f94c3296b..662fc575f 100644
--- a/src/tree/mirror/streaming/remote/adapter/tests.rs
+++ b/src/tree/mirror/streaming/remote/adapter/tests.rs
@@ -39,7 +39,7 @@ fn unbounded() -> SupplyLedger {
 }
 
 /// Build a supply run from borrowed leaf records, in the given order.
-fn leaf_run(records: &[(&Version, &Message)]) -> LeafRun {
+fn leaf_run(records: &[(&Version, &Message)]) -> LeafRun {
     let mut run = LeafRun::new();
     for (version, message) in records {
         run.push(version, message)
@@ -58,7 +58,7 @@ fn runtime() -> tokio::runtime::Runtime {
 struct LeafCase {
     value: u64,
     version: Version,
-    message: Message,
+    message: Message,
 }
 
 impl LeafCase {
diff --git a/src/tree/mirror/streaming/remote/adapter/tests/backend_errors.rs b/src/tree/mirror/streaming/remote/adapter/tests/backend_errors.rs
index 541a44a71..017244da9 100644
--- a/src/tree/mirror/streaming/remote/adapter/tests/backend_errors.rs
+++ b/src/tree/mirror/streaming/remote/adapter/tests/backend_errors.rs
@@ -1,13 +1,14 @@
 //! Source-error propagation across every backend operation reachable by the adapter.
 
+use crate::message::Message;
 use std::convert::Infallible;
 
 use futures::{StreamExt, stream};
 
 use crate::tree::{
     mirror::streaming::{
-        Failing, FailingNode, Failure, Local, Operation,
-        message::{Reaction, Reply},
+        Backend, Failing, FailingNode, Failure, Local, Operation,
+        erased::{Reaction, Reply},
     },
     typed::{
         self, Prefix,
@@ -26,11 +27,11 @@ use crate::tree::mirror::streaming::{
 
 /// Construct the same one-leaf subtree at any concrete reply height.
 trait BackendHeight: Convert {
-    fn node(leaf: &LeafCase) -> typed::Node;
+    fn node(leaf: &LeafCase) -> typed::Node;
 }
 
 impl BackendHeight for Z {
-    fn node(leaf: &LeafCase) -> typed::Node {
+    fn node(leaf: &LeafCase) -> typed::Node {
         typed::Node::leaf(leaf.version.clone(), leaf.message.clone())
     }
 }
@@ -40,7 +41,7 @@ where
     H: BackendHeight,
     S: Convert,
 {
-    fn node(leaf: &LeafCase) -> typed::Node {
+    fn node(leaf: &LeafCase) -> typed::Node {
         let path: [u8; 32] = leaf.path().into();
         typed::Node::beneath(H::node(leaf), path[31 - H::HEIGHT])
     }
@@ -68,7 +69,7 @@ where
 
         for fail_after in 0..Self::HEIGHT {
             let backend = Failing::after(Local, fail_after);
-            let supply = FailingNode::new(Self::node(leaf));
+            let supply =  as Backend>::erase(FailingNode::new(Self::node(leaf)));
             let replies = if supply_radix < u8::MAX {
                 vec![
                     Reaction::Supply(supply_radix, supply),
@@ -83,7 +84,7 @@ where
             let mut encoded = encode_reply(
                 backend.clone(),
                 RunBudget::default(),
-                Scope::new(parent, &listing),
+                Scope::new(parent.erase(), &listing),
                 Reply { replies },
             );
             let (yielded, error, ended) = runtime.block_on(async {
@@ -129,12 +130,13 @@ where
                 sentinel.clone(),
             ]);
             let error = runtime
-                .block_on(decode_reply::, u64, H, _>(
+                .block_on(decode_reply::, _>(
                     backend.clone(),
                     u64::MAX,
                     unbounded(),
-                    Scope::new(parent, &[]),
+                    Scope::new(parent.erase(), &[]),
                     &mut frames,
+                    Message::deserializer::(),
                 ))
                 .err()
                 .expect("the injected decoding failure was not reached");
diff --git a/src/tree/mirror/streaming/remote/adapter/tests/fan_occupancy.rs b/src/tree/mirror/streaming/remote/adapter/tests/fan_occupancy.rs
index f740d6c04..2fb909aaa 100644
--- a/src/tree/mirror/streaming/remote/adapter/tests/fan_occupancy.rs
+++ b/src/tree/mirror/streaming/remote/adapter/tests/fan_occupancy.rs
@@ -27,10 +27,7 @@ use crate::{
             remote::codec::{Flow, Frame, LeafRun, Reaction as WireReaction},
             window::FAN,
         },
-        typed::{
-            Path, Prefix,
-            height::{UnderRoot, UnderUnderRoot},
-        },
+        typed::{Path, Prefix},
     },
 };
 
@@ -42,8 +39,8 @@ const PER_FRAME: usize = 16;
 
 /// `count` unique `u64` leaves, in ascending path order (the wire order
 /// the decoder validates).
-fn leaves(count: u64) -> Vec<(Version, Message)> {
-    let mut leaves: Vec<(Version, Message)> = (0..count)
+fn leaves(count: u64) -> Vec<(Version, Message)> {
+    let mut leaves: Vec<(Version, Message)> = (0..count)
         .map(|index| {
             let version = Version::try_from(index + 1).expect("small linear versions are valid");
             (version, Message::new(index))
@@ -54,8 +51,8 @@ fn leaves(count: u64) -> Vec<(Version, Message)> {
 }
 
 /// Chunk leaves into supply frames of [`PER_FRAME`] records each.
-fn frames(leaves: &[(Version, Message)]) -> Vec> {
-    let chunks: Vec<&[(Version, Message)]> = leaves.chunks(PER_FRAME).collect();
+fn frames(leaves: &[(Version, Message)]) -> Vec {
+    let chunks: Vec<&[(Version, Message)]> = leaves.chunks(PER_FRAME).collect();
     let count = chunks.len();
     chunks
         .into_iter()
@@ -78,16 +75,17 @@ fn frames(leaves: &[(Version, Message)]) -> Vec> {
 
 /// Decode one pure-supply reply from `input` over the instant in-memory
 /// backend, reporting the probe's peak resident record count.
-fn peak_occupancy(mut input: impl Stream> + Unpin) -> usize {
+fn peak_occupancy(mut input: impl Stream + Unpin) -> usize {
     let runtime = super::runtime();
     fan_probe::reset();
     runtime.block_on(async {
-        decode_reply::(
+        decode_reply::(
             Local,
             u64::MAX,
             unbounded(),
-            Scope::::opening(&[]),
+            Scope::opening(&[]),
             &mut input,
+            Message::deserializer::(),
         )
         .await
         .expect("ascending in-scope leaves assemble");
@@ -134,12 +132,13 @@ fn eager_early_supplies_ride_the_same_ceiling() {
     let runtime = super::runtime();
     fan_probe::reset();
     runtime.block_on(async {
-        let assembled: Vec<_> = early_supplies::(
+        let assembled: Vec<_> = early_supplies::(
             Local,
             u64::MAX,
             unbounded(),
-            Prefix::new(),
+            Prefix::new().erase(),
             stream::iter(frames(&leaves)),
+            Message::deserializer::(),
         )
         .try_collect()
         .await
diff --git a/src/tree/mirror/streaming/remote/adapter/tests/malformed.rs b/src/tree/mirror/streaming/remote/adapter/tests/malformed.rs
index cc213e333..2337f049a 100644
--- a/src/tree/mirror/streaming/remote/adapter/tests/malformed.rs
+++ b/src/tree/mirror/streaming/remote/adapter/tests/malformed.rs
@@ -11,7 +11,7 @@ use crate::{
         mirror::streaming::{Backend, Local},
         typed::{
             Path, Prefix,
-            height::{S, UnderRoot, UnderUnderRoot, Z},
+            height::{S, UnderRoot, Z},
         },
     },
 };
@@ -23,7 +23,7 @@ use super::{
     },
     LeafCase, hash, leaf_run, runtime, unbounded,
 };
-use crate::tree::mirror::streaming::message::{Reaction, Reply};
+use crate::tree::mirror::streaming::erased::{Reaction, Reply};
 use crate::tree::mirror::streaming::remote::codec::{
     DecodeLeafError, End, Flow, Frame, LeafRun, Reaction as WireReaction, RunBudget,
 };
@@ -33,7 +33,7 @@ use crate::tree::mirror::streaming::remote::codec::{
 fn bare_end_cannot_follow_reactions() {
     let path = Path::for_leaf(&Version::new());
     let parent = Prefix::>::containing(&path);
-    let frames: Vec> = vec![
+    let frames: Vec = vec![
         Frame::Reaction(WireReaction::Match, Flow::Continue),
         Frame::End(End::Reply),
     ];
@@ -44,8 +44,9 @@ fn bare_end_cannot_follow_reactions() {
             Local,
             u64::MAX,
             unbounded(),
-            Scope::new(parent, &[(0, hash(0))]),
+            Scope::new(parent.erase(), &[(0, hash(0))]),
             &mut frames,
+            Message::deserializer::(),
         )
         .await
         .err()
@@ -59,15 +60,16 @@ fn bare_end_cannot_follow_reactions() {
 fn stream_exhaustion_before_a_boundary_is_truncation() {
     let path = Path::for_leaf(&Version::new());
     let parent = Prefix::>::containing(&path);
-    let mut frames = stream::iter([Frame::<()>::Reaction(WireReaction::Match, Flow::Continue)]);
+    let mut frames = stream::iter([Frame::Reaction(WireReaction::Match, Flow::Continue)]);
 
     let error = runtime().block_on(async {
         decode_leaf_reply(
             Local,
             u64::MAX,
             unbounded(),
-            Scope::new(parent, &[(0, hash(0))]),
+            Scope::new(parent.erase(), &[(0, hash(0))]),
             &mut frames,
+            Message::deserializer::(),
         )
         .await
         .err()
@@ -88,19 +90,20 @@ fn an_unpositioned_match_is_rejected_in_both_directions() {
     let parent = Prefix::>>::containing(&path);
     // One listed child admits one positional reaction; the second Match
     // must fail at its own frame with the reply still unterminated.
-    let frames: Vec> = vec![
+    let frames: Vec = vec![
         Frame::Reaction(WireReaction::Match, Flow::Continue),
         Frame::Reaction(WireReaction::Match, Flow::Continue),
     ];
 
     let decode_error = runtime().block_on(async {
         let mut frames = stream::iter(frames);
-        decode_reply::(
+        decode_reply::(
             Local,
             u64::MAX,
             unbounded(),
-            Scope::new(parent, &[(1, hash(1))]),
+            Scope::new(parent.erase(), &[(1, hash(1))]),
             &mut frames,
+            Message::deserializer::(),
         )
         .await
         .err()
@@ -111,14 +114,14 @@ fn an_unpositioned_match_is_rejected_in_both_directions() {
         DecodeError::Scope(ScopeError::UnpositionedMatch)
     ));
 
-    let reply = Reply::> {
+    let reply = Reply::<::Erased> {
         replies: vec![Reaction::Match, Reaction::Match],
     };
     let encode_error = runtime().block_on(async {
         encode_reply(
             Local,
             RunBudget::default(),
-            Scope::new(parent, &[(1, hash(1))]),
+            Scope::new(parent.erase(), &[(1, hash(1))]),
             reply,
         )
         .try_collect::>()
@@ -138,19 +141,20 @@ fn an_unpositioned_query_is_rejected_in_both_directions() {
     let path = Path::for_leaf(&Version::new());
     let parent = Prefix::>>::containing(&path);
     let listing = vec![(1, hash(1))];
-    let frames: Vec> = vec![Frame::Reaction(
+    let frames: Vec = vec![Frame::Reaction(
         WireReaction::Query(listing.clone()),
         Flow::End,
     )];
 
     let decode_error = runtime().block_on(async {
         let mut frames = stream::iter(frames);
-        decode_reply::(
+        decode_reply::(
             Local,
             u64::MAX,
             unbounded(),
-            Scope::new(parent, &[]),
+            Scope::new(parent.erase(), &[]),
             &mut frames,
+            Message::deserializer::(),
         )
         .await
         .err()
@@ -161,15 +165,20 @@ fn an_unpositioned_query_is_rejected_in_both_directions() {
         DecodeError::Scope(ScopeError::UnpositionedQuery)
     ));
 
-    let reply = Reply::> {
+    let reply = Reply::<::Erased> {
         replies: vec![Reaction::Query(listing)],
     };
     let encode_error = runtime().block_on(async {
-        encode_reply(Local, RunBudget::default(), Scope::new(parent, &[]), reply)
-            .try_collect::>()
-            .await
-            .err()
-            .expect("an unpositioned query cannot be put on the wire")
+        encode_reply(
+            Local,
+            RunBudget::default(),
+            Scope::new(parent.erase(), &[]),
+            reply,
+        )
+        .try_collect::>()
+        .await
+        .err()
+        .expect("an unpositioned query cannot be put on the wire")
     });
     assert!(matches!(
         encode_error,
@@ -204,17 +213,17 @@ fn leaf_query_matrix_is_exhaustive() {
             } else {
                 None
             };
-            let expected_frame =
+            let expected_frame: Frame =
                 Frame::Reaction(WireReaction::Query(query_listing.clone()), Flow::End);
 
-            let reply = Reply:: {
+            let reply = Reply::<::Erased> {
                 replies: vec![Reaction::Query(query_listing.clone())],
             };
             let encoded = runtime().block_on(async {
                 encode_leaf_reply(
                     Local,
                     RunBudget::default(),
-                    Scope::new(parent, &scope_listing),
+                    Scope::new(parent.erase(), &scope_listing),
                     reply,
                 )
                 .map_ok(|encoded| encoded.into_parts())
@@ -232,7 +241,7 @@ fn leaf_query_matrix_is_exhaustive() {
                         panic!("a leaf query encodes as exactly one frame")
                     };
                     assert_eq!(frame, &expected_frame);
-                    assert_eq!(question, &Some(Scope::leaf(parent.push(radix))));
+                    assert_eq!(question, &Some(Scope::leaf(parent.push(radix).erase())));
                 }
             }
             checked += 1;
@@ -243,8 +252,9 @@ fn leaf_query_matrix_is_exhaustive() {
                     Local,
                     u64::MAX,
                     unbounded(),
-                    Scope::new(parent, &scope_listing),
+                    Scope::new(parent.erase(), &scope_listing),
                     &mut frames,
+                    Message::deserializer::(),
                 )
                 .await
             });
@@ -255,7 +265,10 @@ fn leaf_query_matrix_is_exhaustive() {
                 }
                 None => {
                     let decoded = decoded.expect("this matrix cell must decode");
-                    assert_eq!(decoded.questions, vec![Scope::leaf(parent.push(radix))]);
+                    assert_eq!(
+                        decoded.questions,
+                        vec![Scope::leaf(parent.push(radix).erase())]
+                    );
                     let [Reaction::Query(listing)] = decoded.reply.replies.as_slice() else {
                         panic!("the decoded reaction must remain a query")
                     };
@@ -273,15 +286,16 @@ fn leaf_query_matrix_is_exhaustive() {
 fn stream_end_is_not_a_protocol_reply() {
     let path = Path::for_leaf(&Version::new());
     let parent = Prefix::>::containing(&path);
-    let mut frames = stream::iter([Frame::<()>::End(End::Stream)]);
+    let mut frames = stream::iter([Frame::End(End::Stream)]);
 
     let error = runtime()
         .block_on(decode_leaf_reply(
             Local,
             u64::MAX,
             unbounded(),
-            Scope::new(parent, &[]),
+            Scope::new(parent.erase(), &[]),
             &mut frames,
+            Message::deserializer::(),
         ))
         .err()
         .expect("stream control must be consumed below the adapter");
@@ -303,8 +317,8 @@ fn decode_scope_error(error: DecodeError) -> ScopeError {
     }
 }
 
-fn under_root_pair() -> [(Version, Message, Path); 2] {
-    let mut by_radix: BTreeMap, Path)>> = BTreeMap::new();
+fn under_root_pair() -> [(Version, Message, Path); 2] {
+    let mut by_radix: BTreeMap> = BTreeMap::new();
     for value in 0..u64::MAX {
         let leaf = LeafCase::new(value, value as u8 % 4);
         let path = leaf.path();
@@ -333,16 +347,17 @@ fn a_multi_leaf_run_is_one_supplied_subtree() {
             Flow::End,
         ),
     ];
-    let scope = Scope::::opening(&[]);
+    let scope = Scope::opening(&[]);
 
     let reencoded = runtime().block_on(async {
         let mut input = stream::iter(frames.clone());
-        let decoded = decode_reply::(
+        let decoded = decode_reply::(
             Local,
             u64::MAX,
             unbounded(),
             scope.clone(),
             &mut input,
+            Message::deserializer::(),
         )
         .await
         .expect("ascending in-scope leaves assemble");
@@ -351,11 +366,14 @@ fn a_multi_leaf_run_is_one_supplied_subtree() {
             panic!("one leaf run must become one supplied node")
         };
         let supplied_prefix = Prefix::::containing(&leaves[0].2);
-        let rebuilt = Local
-            .leaves(supplied_prefix, node.clone())
-            .try_collect::>()
-            .await
-            .expect("the local backend is infallible");
+        let rebuilt = ::leaves(
+            Local,
+            supplied_prefix,
+            ::assume::(node.clone()),
+        )
+        .try_collect::>()
+        .await
+        .expect("the local backend is infallible");
         assert_eq!(rebuilt.len(), 2);
 
         encode_reply(Local, RunBudget::default(), scope, decoded.reply)
@@ -395,12 +413,13 @@ fn leaf_order_is_enforced_within_one_run() {
 
     let error = runtime().block_on(async {
         let mut input = stream::iter(frames);
-        decode_reply::(
+        decode_reply::(
             Local,
             u64::MAX,
             unbounded(),
             Scope::opening(&[]),
             &mut input,
+            Message::deserializer::(),
         )
         .await
         .err()
@@ -440,8 +459,9 @@ fn leaf_scope_is_enforced_within_one_run() {
             Local,
             u64::MAX,
             unbounded(),
-            Scope::new(parent, &[]),
+            Scope::new(parent.erase(), &[]),
             &mut input,
+            Message::deserializer::(),
         )
         .await
         .err()
@@ -465,19 +485,20 @@ const ZERO_LENGTH_RECORD_RUN: [u8; 4] = [0, 0, 0, 0];
 /// version decoder's `UnexpectedEof`.
 #[test]
 fn a_zero_length_record_fails_as_a_version_decode_error() {
-    let run = LeafRun::::from_encoded(ZERO_LENGTH_RECORD_RUN.to_vec())
+    let run = LeafRun::from_encoded(ZERO_LENGTH_RECORD_RUN.to_vec())
         .expect("a zero-length record header chains structurally");
     assert_eq!(run.record_count(), 1);
     let frames = vec![Frame::Reaction(WireReaction::Supply(run), Flow::End)];
 
     let error = runtime().block_on(async {
         let mut input = stream::iter(frames);
-        decode_reply::(
+        decode_reply::(
             Local,
             u64::MAX,
             unbounded(),
             Scope::opening(&[]),
             &mut input,
+            Message::deserializer::(),
         )
         .await
         .err()
@@ -513,8 +534,9 @@ fn a_version_over_the_declared_bound_is_rejected() {
             Local,
             declared,
             unbounded(),
-            Scope::new(parent, &[]),
+            Scope::new(parent.erase(), &[]),
             &mut input,
+            Message::deserializer::(),
         )
         .await
         .expect("a version exactly at the declared bound is admitted");
@@ -526,8 +548,9 @@ fn a_version_over_the_declared_bound_is_rejected() {
             Local,
             declared - 1,
             unbounded(),
-            Scope::new(parent, &[]),
+            Scope::new(parent.erase(), &[]),
             &mut input,
+            Message::deserializer::(),
         )
         .await
         .err()
@@ -557,7 +580,7 @@ fn ascending_leaves(count: u64) -> Vec {
 
 /// One whole-root reply supplying every leaf in `cases`, as a single
 /// ascending run.
-fn whole_root_supply_reply(cases: &[LeafCase]) -> Vec> {
+fn whole_root_supply_reply(cases: &[LeafCase]) -> Vec {
     let records: Vec<_> = cases
         .iter()
         .map(|case| (&case.version, &case.message))
@@ -601,12 +624,13 @@ fn a_reply_past_the_declared_set_len_fails_at_its_first_over_record() {
         let (live, _) = census::read();
         let decoded = runtime().block_on(async {
             let mut input = stream::iter(frames);
-            decode_reply::(
+            decode_reply::(
                 Local,
                 u64::MAX,
                 SupplyLedger::new(declared),
                 Scope::opening(&[]),
                 &mut input,
+                Message::deserializer::(),
             )
             .await
         });
@@ -676,12 +700,13 @@ fn a_supply_run_cannot_resume_after_another_reaction() {
 
     let error = runtime().block_on(async {
         let mut input = stream::iter(frames);
-        decode_reply::(
+        decode_reply::(
             Local,
             u64::MAX,
             unbounded(),
             Scope::opening(&[(1, hash(1))]),
             &mut input,
+            Message::deserializer::(),
         )
         .await
         .err()
diff --git a/src/tree/mirror/streaming/remote/adapter/tests/opening.rs b/src/tree/mirror/streaming/remote/adapter/tests/opening.rs
index db221aed8..f020a5615 100644
--- a/src/tree/mirror/streaming/remote/adapter/tests/opening.rs
+++ b/src/tree/mirror/streaming/remote/adapter/tests/opening.rs
@@ -15,8 +15,8 @@ use futures::{TryStreamExt, stream};
 use crate::message::Message;
 use crate::tree::{
     mirror::streaming::{
-        Local,
-        message::{Reaction, Reply},
+        Backend, Local,
+        erased::{Reaction, Reply},
         remote::codec::{End, Flow, Frame, Reaction as WireReaction},
     },
     typed::{
@@ -25,17 +25,22 @@ use crate::tree::{
     },
 };
 
+/// One erased opening node over the unit payload.
+fn erased(node: typed::Node) -> ::Erased {
+    ::erase(node)
+}
+
 use super::{
     super::{DecodeError, OpeningError, Scope, early_supplies, opening_parts, opening_reply},
     LeafCase, hash, leaf_run, runtime, unbounded,
 };
 
 trait OpeningNode: Height {
-    fn node() -> typed::Node<(), Self>;
+    fn node() -> typed::Node;
 }
 
 impl OpeningNode for Z {
-    fn node() -> typed::Node<(), Self> {
+    fn node() -> typed::Node {
         typed::Node::leaf(Version::new(), Message::new(()))
     }
 }
@@ -44,7 +49,7 @@ impl OpeningNode for S
 where
     S: Height,
 {
-    fn node() -> typed::Node<(), Self> {
+    fn node() -> typed::Node {
         typed::Node::beneath(H::node(), 0)
     }
 }
@@ -54,7 +59,7 @@ where
 #[test]
 fn opening_listing_agrees_with_greeting_replay() {
     let listing = vec![(3, hash(1)), (9, hash(2))];
-    let reply = Reply:: {
+    let reply = Reply::<::Erased> {
         replies: vec![Reaction::Query(listing.clone())],
     };
 
@@ -63,7 +68,7 @@ fn opening_listing_agrees_with_greeting_replay() {
     assert!(supplies.is_empty(), "no supplies trailed the question");
     let scope = Scope::opening(&split);
 
-    let (replayed, replayed_scope) = opening_reply::(listing.clone());
+    let (replayed, replayed_scope) = opening_reply::<::Erased>(listing.clone());
     assert_eq!(replayed_scope, scope);
     let [Reaction::Query(replayed)] = replayed.replies.as_slice() else {
         panic!("the replayed opening must remain one query")
@@ -75,11 +80,11 @@ fn opening_listing_agrees_with_greeting_replay() {
 #[test]
 fn opening_supplies_split_off_the_question() {
     let listing = vec![(3, hash(1))];
-    let reply = Reply:: {
+    let reply = Reply::<::Erased> {
         replies: vec![
             Reaction::Query(listing.clone()),
-            Reaction::Supply(5, UnderRoot::node()),
-            Reaction::Supply(9, UnderRoot::node()),
+            Reaction::Supply(5, erased(UnderRoot::node())),
+            Reaction::Supply(9, erased(UnderRoot::node())),
         ],
     };
     let (split, supplies) = opening_parts(reply).expect("canonical opening with supplies");
@@ -99,7 +104,7 @@ fn opening_supplies_split_off_the_question() {
 /// scope holding no positional children.
 #[test]
 fn empty_listing_replays_the_empty_opening() {
-    let (replayed, mut scope) = opening_reply::(Vec::new());
+    let (replayed, mut scope) = opening_reply::<::Erased>(Vec::new());
     let [Reaction::Query(listing)] = replayed.replies.as_slice() else {
         panic!("the replayed opening must be one query")
     };
@@ -128,7 +133,7 @@ fn opening_supplies_decode_by_radix_group() {
     }
     assert!(groups.len() >= 2, "the fixture must span two root children");
 
-    let mut frames: Vec> = groups
+    let mut frames: Vec = groups
         .iter()
         .map(|group| {
             let records: Vec<_> = group
@@ -146,12 +151,13 @@ fn opening_supplies_decode_by_radix_group() {
 
     let decoded: Vec<(u8, _)> = runtime()
         .block_on(
-            early_supplies::(
+            early_supplies::(
                 Local,
                 u64::MAX,
                 unbounded(),
-                Prefix::new(),
+                Prefix::new().erase(),
                 stream::iter(frames),
+                Message::deserializer::(),
             )
             .try_collect(),
         )
@@ -186,19 +192,20 @@ fn opening_supplies_past_the_declared_set_len_are_rejected() {
         .iter()
         .map(|case| (&case.version, &case.message))
         .collect();
-    let frames: Vec> = vec![Frame::Reaction(
+    let frames: Vec = vec![Frame::Reaction(
         WireReaction::Supply(leaf_run(&records)),
         Flow::End,
     )];
 
     let error = runtime()
         .block_on(async {
-            early_supplies::(
+            early_supplies::(
                 Local,
                 u64::MAX,
                 SupplyLedger::new(1),
-                Prefix::new(),
+                Prefix::new().erase(),
                 stream::iter(frames),
+                Message::deserializer::(),
             )
             .try_collect::>()
             .await
@@ -217,15 +224,16 @@ fn opening_supplies_past_the_declared_set_len_are_rejected() {
 /// decodes to no supplies at all.
 #[test]
 fn empty_opening_supply_reply_decodes_to_nothing() {
-    let frames: Vec> = vec![Frame::End(End::Reply)];
+    let frames: Vec = vec![Frame::End(End::Reply)];
     let decoded: Vec<(u8, _)> = runtime()
         .block_on(
-            early_supplies::(
+            early_supplies::(
                 Local,
                 u64::MAX,
                 unbounded(),
-                Prefix::new(),
+                Prefix::new().erase(),
                 stream::iter(frames),
+                Message::deserializer::(),
             )
             .try_collect(),
         )
@@ -237,15 +245,16 @@ fn empty_opening_supply_reply_decodes_to_nothing() {
 /// end are rejected, not absorbed into a phantom second reply.
 #[test]
 fn second_opening_supply_reply_is_rejected() {
-    let frames: Vec> = vec![Frame::End(End::Reply), Frame::End(End::Reply)];
+    let frames: Vec = vec![Frame::End(End::Reply), Frame::End(End::Reply)];
     let error = runtime()
         .block_on(async {
-            early_supplies::(
+            early_supplies::(
                 Local,
                 u64::MAX,
                 unbounded(),
-                Prefix::new(),
+                Prefix::new().erase(),
                 stream::iter(frames),
+                Message::deserializer::(),
             )
             .try_collect::>()
             .await
@@ -258,15 +267,16 @@ fn second_opening_supply_reply_is_rejected() {
 /// an in-process one is rejected as unpositioned.
 #[test]
 fn positional_reaction_in_opening_supplies_is_rejected() {
-    let frames: Vec> = vec![Frame::Reaction(WireReaction::Match, Flow::End)];
+    let frames: Vec = vec![Frame::Reaction(WireReaction::Match, Flow::End)];
     let error = runtime()
         .block_on(async {
-            early_supplies::(
+            early_supplies::(
                 Local,
                 u64::MAX,
                 unbounded(),
-                Prefix::new(),
+                Prefix::new().erase(),
                 stream::iter(frames),
+                Message::deserializer::(),
             )
             .try_collect::>()
             .await
@@ -282,29 +292,29 @@ fn positional_reaction_in_opening_supplies_is_rejected() {
 /// form or its exact typed rejection.
 #[test]
 fn opening_rejections_are_exhaustive() {
-    let empty = Reply:: {
+    let empty = Reply::<::Erased> {
         replies: Vec::new(),
     };
     assert_eq!(opening_parts(empty).err(), Some(OpeningError::Empty));
 
     for count in 1..=3 {
-        let reply = Reply:: {
+        let reply = Reply::<::Erased> {
             replies: (0..count).map(|_| Reaction::Match).collect(),
         };
         assert_eq!(opening_parts(reply).err(), Some(OpeningError::NotQuery));
     }
 
-    let supplied = Reply:: {
-        replies: vec![Reaction::Supply(0, UnderRoot::node())],
+    let supplied = Reply::<::Erased> {
+        replies: vec![Reaction::Supply(0, erased(UnderRoot::node()))],
     };
     assert_eq!(opening_parts(supplied).err(), Some(OpeningError::NotQuery));
 
     // A non-supply reaction anywhere behind the question is rejected at
     // its whole-reply position.
-    let trailing = Reply:: {
+    let trailing = Reply::<::Erased> {
         replies: vec![
             Reaction::Query(vec![(3, hash(1))]),
-            Reaction::Supply(5, UnderRoot::node()),
+            Reaction::Supply(5, erased(UnderRoot::node())),
             Reaction::Match,
         ],
     };
diff --git a/src/tree/mirror/streaming/remote/adapter/tests/parking.rs b/src/tree/mirror/streaming/remote/adapter/tests/parking.rs
index f79f58290..ec94abcd4 100644
--- a/src/tree/mirror/streaming/remote/adapter/tests/parking.rs
+++ b/src/tree/mirror/streaming/remote/adapter/tests/parking.rs
@@ -28,15 +28,12 @@ use crate::{
     tree::{
         Action, Tree,
         mirror::streaming::{
-            Local,
-            message::{Reaction, Reply},
+            Backend, Local,
+            erased::{Reaction, Reply},
             remote::codec::{self, RunBudget, Speaker, Stream},
             window::FAN,
         },
-        typed::{
-            self, Hash,
-            height::{UnderRoot, UnderUnderRoot},
-        },
+        typed::{self, Hash, height::UnderRoot},
     },
 };
 
@@ -70,13 +67,13 @@ fn parked_supply_reply_holds_handles_not_subtrees() {
     // `replies.len()` pointers plus the skeleton — the subtree's bytes live
     // in backend custody behind the handle.
     assert_eq!(
-        mem::size_of::>(),
+        mem::size_of::>(),
         mem::size_of::(),
         "a parked supply must be a shared handle, not an owned subtree",
     );
 
     let party = before::Party::seed();
-    let mut tree = Tree::new();
+    let mut tree = Tree::<()>::new();
     tree.act(&party, (0..LEAVES).map(|v| Action::Insert(Message::new(v))));
     let root = tree
         .root
@@ -86,8 +83,7 @@ fn parked_supply_reply_holds_handles_not_subtrees() {
 
     // The real root fan: version-addressed leaves scatter across first
     // bytes, so the fan is wide and its children are multi-leaf.
-    let children: Vec<(u8, typed::Node)> =
-        root.into_children().into_iter().collect();
+    let children: Vec<(u8, typed::Node)> = root.into_children().into_iter().collect();
     let expected: Vec<(u8, Hash, usize)> = children
         .iter()
         .map(|(radix, node)| (*radix, node.hash(), node.len()))
@@ -106,14 +102,14 @@ fn parked_supply_reply_holds_handles_not_subtrees() {
         "the fan partitions every committed leaf",
     );
 
-    let reply = Reply:: {
+    let reply = Reply {
         replies: children
             .into_iter()
-            .map(|(radix, node)| Reaction::Supply(radix, node))
+            .map(|(radix, node)| Reaction::Supply(radix, ::erase(node)))
             .collect(),
     };
     let runtime = runtime();
-    let scope = Scope::::opening(&[]);
+    let scope = Scope::opening(&[]);
     let frames = runtime.block_on(async {
         encode_reply(Local, RunBudget::default(), scope.clone(), reply)
             .map_ok(|encoded| encoded.into_parts().0)
@@ -124,12 +120,13 @@ fn parked_supply_reply_holds_handles_not_subtrees() {
 
     let mut frames = stream::iter(frames);
     let decoded = runtime
-        .block_on(decode_reply::(
+        .block_on(decode_reply::(
             Local,
             u64::MAX,
             unbounded(),
             scope,
             &mut frames,
+            Message::deserializer::(),
         ))
         .expect("a canonical supplied fan decodes");
 
@@ -166,12 +163,12 @@ fn maximally_disputed_reply_parks_bounded_skeleton() {
     let listing: Vec<(u8, Hash)> = (0..FAN)
         .map(|radix| (radix as u8, hash(radix as u8)))
         .collect();
-    let reply = Reply:: {
+    let reply = Reply::<::Erased> {
         replies: (0..FAN).map(|_| Reaction::Query(listing.clone())).collect(),
     };
 
     let runtime = runtime();
-    let scope = Scope::::opening(&listing);
+    let scope = Scope::opening(&listing);
     let frames = runtime.block_on(async {
         encode_reply(Local, RunBudget::default(), scope.clone(), reply)
             .map_ok(|encoded| encoded.into_parts().0)
@@ -211,12 +208,13 @@ fn maximally_disputed_reply_parks_bounded_skeleton() {
 
     let mut frames = stream::iter(frames);
     let decoded = runtime
-        .block_on(decode_reply::(
+        .block_on(decode_reply::(
             Local,
             u64::MAX,
             unbounded(),
-            Scope::::opening(&listing),
+            Scope::opening(&listing),
             &mut frames,
+            Message::deserializer::(),
         ))
         .expect("a canonical maximally disputed reply decodes");
 
diff --git a/src/tree/mirror/streaming/remote/adapter/tests/properties.rs b/src/tree/mirror/streaming/remote/adapter/tests/properties.rs
index 11d793353..c542cabcb 100644
--- a/src/tree/mirror/streaming/remote/adapter/tests/properties.rs
+++ b/src/tree/mirror/streaming/remote/adapter/tests/properties.rs
@@ -1,5 +1,6 @@
 //! Laws which hold uniformly across the adapter's type-level height ladder.
 
+use crate::message::Message;
 use std::convert::Infallible;
 
 use futures::{StreamExt, TryStreamExt, stream};
@@ -9,7 +10,7 @@ use crate::tree::{
     mirror::streaming::{
         Backend, Local,
         convert::Convert,
-        message::{Reaction, Reply},
+        erased::{Reaction, Reply},
     },
     typed::{
         self, Hash, Prefix,
@@ -17,6 +18,10 @@ use crate::tree::{
     },
 };
 
+/// The in-memory backend's erased node representations, per payload.
+type ErasedUnit = ::Erased;
+type ErasedU64 = ::Erased;
+
 use super::{
     super::{DecodeError, Scope, decode_leaf_reply, decode_reply, encode_leaf_reply, encode_reply},
     LeafCase, hash, leaf_run, runtime, unbounded,
@@ -54,7 +59,7 @@ impl PositionalCase {
 
 /// Exercise an adapter law at one concrete type-level reply height.
 trait AdapterHeight: Convert {
-    fn node(leaf: &LeafCase) -> typed::Node;
+    fn node(leaf: &LeafCase) -> typed::Node;
 
     fn supplied_leaf_is_lossless(
         leaf: &LeafCase,
@@ -91,7 +96,7 @@ trait AdapterHeight: Convert {
 }
 
 impl AdapterHeight for Z {
-    fn node(leaf: &LeafCase) -> typed::Node {
+    fn node(leaf: &LeafCase) -> typed::Node {
         typed::Node::leaf(leaf.version.clone(), leaf.message.clone())
     }
 
@@ -100,7 +105,7 @@ impl AdapterHeight for Z {
         runtime: &tokio::runtime::Runtime,
     ) -> TestCaseResult {
         let (parent, radix) = Prefix::::containing(&leaf.path()).pop();
-        let scope = Scope::new(parent, &[]);
+        let scope = Scope::new(parent.erase(), &[]);
         let frame = supplied_frame(leaf, Flow::End);
         let mut frames = stream::iter([frame.clone()]);
         let decoded = runtime
@@ -110,6 +115,7 @@ impl AdapterHeight for Z {
                 unbounded(),
                 scope.clone(),
                 &mut frames,
+                Message::deserializer::(),
             ))
             .expect("an in-scope leaf decodes");
 
@@ -132,8 +138,8 @@ impl AdapterHeight for Z {
         runtime: &tokio::runtime::Runtime,
     ) -> TestCaseResult {
         let parent = Prefix::>::containing(&leaf.path());
-        let scope = Scope::new(parent, &listing(radixes));
-        let reply = Reply:: {
+        let scope = Scope::new(parent.erase(), &listing(radixes));
+        let reply = Reply:: {
             replies: radixes.iter().map(|_| Reaction::Match).collect(),
         };
         let encoded = runtime.block_on(async {
@@ -159,6 +165,7 @@ impl AdapterHeight for Z {
                 unbounded(),
                 scope,
                 &mut frames,
+                Message::deserializer::(),
             ))
             .expect("canonical matches decode");
         prop_assert!(decoded.questions.is_empty(), "height 0");
@@ -173,10 +180,10 @@ impl AdapterHeight for Z {
         runtime: &tokio::runtime::Runtime,
     ) -> TestCaseResult {
         let parent = Prefix::>::containing(&leaf.path());
-        let scope = Scope::new(parent, &case.listing());
+        let scope = Scope::new(parent.erase(), &case.listing());
         let mut leaf_case = case.clone();
         leaf_case.nested.clear();
-        let reply = Reply:: {
+        let reply = Reply:: {
             replies: (0..leaf_case.radixes.len())
                 .map(|position| {
                     if leaf_case.is_query(position) {
@@ -193,7 +200,7 @@ impl AdapterHeight for Z {
             .iter()
             .enumerate()
             .filter(|(position, _)| leaf_case.is_query(*position))
-            .map(|(_, &radix)| Scope::leaf(parent.push(radix)))
+            .map(|(_, &radix)| Scope::leaf(parent.push(radix).erase()))
             .collect::>();
         let expected_publications = if leaf_case.radixes.is_empty() {
             vec![None]
@@ -205,7 +212,7 @@ impl AdapterHeight for Z {
                 .map(|(position, &radix)| {
                     leaf_case
                         .is_query(position)
-                        .then(|| Scope::leaf(parent.push(radix)))
+                        .then(|| Scope::leaf(parent.push(radix).erase()))
                 })
                 .collect::>()
         };
@@ -236,6 +243,7 @@ impl AdapterHeight for Z {
                 unbounded(),
                 scope,
                 &mut frames,
+                Message::deserializer::(),
             ))
             .expect("canonical leaf reactions decode");
         prop_assert_eq!(&decoded.questions, &expected_questions, "height 0");
@@ -249,11 +257,12 @@ impl AdapterHeight for Z {
     ) -> TestCaseResult {
         let (parent, supply_radix) = Prefix::::containing(&leaf.path()).pop();
         let (case, supply_at) = case.with_supply(supply_radix);
-        let scope = Scope::new(parent, &case.listing());
+        let scope = Scope::new(parent.erase(), &case.listing());
         let reply = mixed_reply(&case, &[], supply_at, supply_radix, Self::node(leaf));
         let expected_frames = expected_mixed_frames(&case, &[], supply_at, leaf);
-        let expected_publications =
-            mixed_publications(&case, supply_at, |radix| Scope::leaf(parent.push(radix)));
+        let expected_publications = mixed_publications(&case, supply_at, |radix| {
+            Scope::leaf(parent.push(radix).erase())
+        });
         let expected_questions = expected_publications
             .iter()
             .filter_map(Clone::clone)
@@ -286,10 +295,11 @@ impl AdapterHeight for Z {
                 unbounded(),
                 scope,
                 &mut frames,
+                Message::deserializer::(),
             ))
             .expect("canonical mixed leaf reactions decode");
         prop_assert_eq!(&decoded.questions, &expected_questions, "height 0");
-        assert_mixed_reply(
+        assert_mixed_reply::(
             &decoded.reply,
             &case,
             &[],
@@ -313,8 +323,9 @@ impl AdapterHeight for Z {
                 Local,
                 u64::MAX,
                 unbounded(),
-                Scope::new(parent, &[]),
+                Scope::new(parent.erase(), &[]),
                 &mut frames,
+                Message::deserializer::(),
             ))
             .err()
             .expect("duplicate leaves are not strictly ascending");
@@ -333,8 +344,9 @@ impl AdapterHeight for Z {
                 Local,
                 u64::MAX,
                 unbounded(),
-                Scope::new(foreign, &[]),
+                Scope::new(foreign.erase(), &[]),
                 &mut frames,
+                Message::deserializer::(),
             ))
             .err()
             .expect("a leaf outside the retained scope must fail");
@@ -348,7 +360,7 @@ where
     S: Convert,
     S>: Height,
 {
-    fn node(leaf: &LeafCase) -> typed::Node {
+    fn node(leaf: &LeafCase) -> typed::Node {
         let path: [u8; 32] = leaf.path().into();
         typed::Node::beneath(H::node(leaf), path[31 - H::HEIGHT])
     }
@@ -358,16 +370,17 @@ where
         runtime: &tokio::runtime::Runtime,
     ) -> TestCaseResult {
         let (parent, radix) = Prefix::>::containing(&leaf.path()).pop();
-        let scope = Scope::new(parent, &[]);
+        let scope = Scope::new(parent.erase(), &[]);
         let frame = supplied_frame(leaf, Flow::End);
         let mut frames = stream::iter([frame.clone()]);
         let decoded = runtime
-            .block_on(decode_reply::(
+            .block_on(decode_reply::(
                 Local,
                 u64::MAX,
                 unbounded(),
                 scope.clone(),
                 &mut frames,
+                Message::deserializer::(),
             ))
             .expect("an in-scope leaf decodes");
 
@@ -390,8 +403,8 @@ where
         runtime: &tokio::runtime::Runtime,
     ) -> TestCaseResult {
         let parent = Prefix::>>::containing(&leaf.path());
-        let scope = Scope::new(parent, &listing(radixes));
-        let reply = Reply:: {
+        let scope = Scope::new(parent.erase(), &listing(radixes));
+        let reply = Reply:: {
             replies: radixes.iter().map(|_| Reaction::Match).collect(),
         };
         let encoded = runtime.block_on(async {
@@ -411,12 +424,13 @@ where
                 .chain([sentinel.clone()]),
         );
         let decoded = runtime
-            .block_on(decode_reply::(
+            .block_on(decode_reply::(
                 Local,
                 u64::MAX,
                 unbounded(),
                 scope,
                 &mut frames,
+                Message::deserializer::(),
             ))
             .expect("canonical matches decode");
         prop_assert!(decoded.questions.is_empty(), "height {}", Self::HEIGHT);
@@ -436,8 +450,8 @@ where
         runtime: &tokio::runtime::Runtime,
     ) -> TestCaseResult {
         let parent = Prefix::>>::containing(&leaf.path());
-        let scope = Scope::new(parent, &case.listing());
-        let reply = Reply:: {
+        let scope = Scope::new(parent.erase(), &case.listing());
+        let reply = Reply:: {
             replies: (0..case.radixes.len())
                 .map(|position| {
                     if case.is_query(position) {
@@ -453,8 +467,8 @@ where
             .iter()
             .enumerate()
             .filter(|(position, _)| case.is_query(*position))
-            .map(|(_, &radix)| Scope::new(parent.push(radix), &case.nested))
-            .collect::>>();
+            .map(|(_, &radix)| Scope::new(parent.push(radix).erase(), &case.nested))
+            .collect::>();
         let expected_frames = expected_positional_frames(case);
         let expected_publications = if case.radixes.is_empty() {
             vec![None]
@@ -464,7 +478,7 @@ where
                 .enumerate()
                 .map(|(position, &radix)| {
                     case.is_query(position)
-                        .then(|| Scope::new(parent.push(radix), &case.nested))
+                        .then(|| Scope::new(parent.push(radix).erase(), &case.nested))
                 })
                 .collect::>()
         };
@@ -494,12 +508,13 @@ where
 
         let mut frames = stream::iter(actual_frames);
         let decoded = runtime
-            .block_on(decode_reply::(
+            .block_on(decode_reply::(
                 Local,
                 u64::MAX,
                 unbounded(),
                 scope,
                 &mut frames,
+                Message::deserializer::(),
             ))
             .expect("canonical positional reactions decode");
         prop_assert_eq!(
@@ -518,7 +533,7 @@ where
     ) -> TestCaseResult {
         let (parent, supply_radix) = Prefix::::containing(&leaf.path()).pop();
         let (case, supply_at) = case.with_supply(supply_radix);
-        let scope = Scope::new(parent, &case.listing());
+        let scope = Scope::new(parent.erase(), &case.listing());
         let reply = mixed_reply(
             &case,
             &case.nested,
@@ -528,7 +543,7 @@ where
         );
         let expected_frames = expected_mixed_frames(&case, &case.nested, supply_at, leaf);
         let expected_publications = mixed_publications(&case, supply_at, |radix| {
-            Scope::new(parent.push(radix), &case.nested)
+            Scope::new(parent.push(radix).erase(), &case.nested)
         });
         let expected_questions = expected_publications
             .iter()
@@ -561,12 +576,13 @@ where
         let sentinel = Frame::End(End::Reply);
         let mut frames = stream::iter(actual_frames.into_iter().chain([sentinel.clone()]));
         let decoded = runtime
-            .block_on(decode_reply::(
+            .block_on(decode_reply::(
                 Local,
                 u64::MAX,
                 unbounded(),
                 scope,
                 &mut frames,
+                Message::deserializer::(),
             ))
             .expect("canonical mixed reactions decode");
         prop_assert_eq!(
@@ -575,7 +591,7 @@ where
             "height {}",
             Self::HEIGHT
         );
-        assert_mixed_reply(
+        assert_mixed_reply::(
             &decoded.reply,
             &case,
             &case.nested,
@@ -600,12 +616,13 @@ where
         let parent = Prefix::::containing(&leaf.path()).pop().0;
         let mut frames = stream::iter(duplicate_frames(leaf));
         let error = runtime
-            .block_on(decode_reply::(
+            .block_on(decode_reply::(
                 Local,
                 u64::MAX,
                 unbounded(),
-                Scope::new(parent, &[]),
+                Scope::new(parent.erase(), &[]),
                 &mut frames,
+                Message::deserializer::(),
             ))
             .err()
             .expect("duplicate leaves are not strictly ascending");
@@ -626,12 +643,13 @@ where
         let foreign = foreign_parent::(leaf, actual);
         let mut frames = stream::iter([supplied_frame(leaf, Flow::End)]);
         let error = runtime
-            .block_on(decode_reply::(
+            .block_on(decode_reply::(
                 Local,
                 u64::MAX,
                 unbounded(),
-                Scope::new(foreign, &[]),
+                Scope::new(foreign.erase(), &[]),
                 &mut frames,
+                Message::deserializer::(),
             ))
             .err()
             .expect("a leaf outside the retained scope must fail");
@@ -639,14 +657,14 @@ where
     }
 }
 
-fn supplied_frame(leaf: &LeafCase, flow: Flow) -> Frame {
+fn supplied_frame(leaf: &LeafCase, flow: Flow) -> Frame {
     Frame::Reaction(
         WireReaction::Supply(leaf_run(&[(&leaf.version, &leaf.message)])),
         flow,
     )
 }
 
-fn duplicate_frames(leaf: &LeafCase) -> [Frame; 2] {
+fn duplicate_frames(leaf: &LeafCase) -> [Frame; 2] {
     [
         supplied_frame(leaf, Flow::Continue),
         supplied_frame(leaf, Flow::End),
@@ -662,9 +680,9 @@ fn mixed_reply(
     query_listing: &[(u8, Hash)],
     supply_at: usize,
     supply_radix: u8,
-    supply: typed::Node,
-) -> Reply {
-    let mut supply = Some(supply);
+    supply: typed::Node,
+) -> Reply {
+    let mut supply = Some(::erase(supply));
     let mut replies = Vec::with_capacity(case.radixes.len() + 1);
     for position in 0..=case.radixes.len() {
         if position == supply_at {
@@ -689,7 +707,7 @@ fn expected_mixed_frames(
     query_listing: &[(u8, Hash)],
     supply_at: usize,
     leaf: &LeafCase,
-) -> Vec> {
+) -> Vec {
     let mut reactions = Vec::with_capacity(case.radixes.len() + 1);
     for position in 0..=case.radixes.len() {
         if position == supply_at {
@@ -742,7 +760,7 @@ fn mixed_publications(
 }
 
 fn assert_mixed_reply(
-    reply: &Reply,
+    reply: &Reply,
     case: &PositionalCase,
     query_listing: &[(u8, Hash)],
     supply_at: usize,
@@ -787,7 +805,7 @@ where
 }
 
 fn assert_decoded_supply(
-    reply: &Reply,
+    reply: &Reply,
     expected_radix: u8,
     expected_leaf: &LeafCase,
     runtime: &tokio::runtime::Runtime,
@@ -806,7 +824,7 @@ where
 }
 
 fn assert_node_leaf(
-    node: &typed::Node,
+    node: &ErasedU64,
     expected_leaf: &LeafCase,
     runtime: &tokio::runtime::Runtime,
 ) -> TestCaseResult
@@ -815,8 +833,7 @@ where
 {
     let prefix = Prefix::::containing(&expected_leaf.path());
     let leaves = runtime.block_on(async {
-        Local
-            .leaves(prefix, node.clone())
+        ::leaves(Local, prefix, ::assume::(node.clone()))
             .try_collect::>()
             .await
             .expect("the local backend is infallible")
@@ -836,7 +853,7 @@ where
 }
 
 fn assert_match_encoding(
-    encoded: &[(Frame<()>, Option)],
+    encoded: &[(Frame, Option)],
     count: usize,
     height: usize,
 ) -> TestCaseResult {
@@ -866,11 +883,7 @@ fn assert_match_encoding(
     Ok(())
 }
 
-fn assert_matches(
-    reply: &Reply,
-    count: usize,
-    height: usize,
-) -> TestCaseResult {
+fn assert_matches(reply: &Reply, count: usize, height: usize) -> TestCaseResult {
     prop_assert_eq!(reply.replies.len(), count, "height {}", height);
     prop_assert!(
         reply
@@ -882,7 +895,7 @@ fn assert_matches(
     Ok(())
 }
 
-fn expected_positional_frames(case: &PositionalCase) -> Vec> {
+fn expected_positional_frames(case: &PositionalCase) -> Vec {
     if case.radixes.is_empty() {
         return vec![Frame::End(End::Reply)];
     }
@@ -903,8 +916,8 @@ fn expected_positional_frames(case: &PositionalCase) -> Vec> {
         .collect()
 }
 
-fn assert_positional_reply(
-    reply: &Reply,
+fn assert_positional_reply(
+    reply: &Reply,
     case: &PositionalCase,
     height: usize,
 ) -> TestCaseResult {
diff --git a/src/tree/mirror/streaming/remote/adapter/tests/runs.rs b/src/tree/mirror/streaming/remote/adapter/tests/runs.rs
index e86d49233..9b012fe0c 100644
--- a/src/tree/mirror/streaming/remote/adapter/tests/runs.rs
+++ b/src/tree/mirror/streaming/remote/adapter/tests/runs.rs
@@ -20,16 +20,13 @@ use crate::{
     tree::{
         mirror::streaming::{
             Backend, Local,
-            message::Reaction,
+            erased::Reaction,
             remote::codec::{
                 DEFAULT_TARGET_MESSAGE_SIZE, Flow, Frame, LeafRun, Reaction as WireReaction,
                 RunBudget, SUPPLY_FRAME_OVERHEAD,
             },
         },
-        typed::{
-            Prefix,
-            height::{UnderRoot, UnderUnderRoot},
-        },
+        typed::{Prefix, height::UnderRoot},
     },
 };
 
@@ -86,7 +83,7 @@ fn separated_leaves() -> [LeafCase; 2] {
 }
 
 /// One single-record supply frame per leaf: the least-batched wire form.
-fn unbatched_frames(leaves: &[LeafCase]) -> Vec> {
+fn unbatched_frames(leaves: &[LeafCase]) -> Vec {
     let count = leaves.len();
     leaves
         .iter()
@@ -107,16 +104,17 @@ fn unbatched_frames(leaves: &[LeafCase]) -> Vec> {
 
 /// Decode `frames` as one reply to the opening scope, then re-encode it
 /// under `budget`, returning the emitted wire frames.
-fn recode(frames: Vec>, budget: RunBudget) -> Vec> {
+fn recode(frames: Vec, budget: RunBudget) -> Vec {
     let runtime = runtime();
     runtime.block_on(async {
         let mut input = stream::iter(frames);
-        let decoded = decode_reply::(
+        let decoded = decode_reply::(
             Local,
             u64::MAX,
             unbounded(),
-            Scope::::opening(&[]),
+            Scope::opening(&[]),
             &mut input,
+            Message::deserializer::(),
         )
         .await
         .expect("ascending in-scope leaves assemble");
@@ -129,7 +127,7 @@ fn recode(frames: Vec>, budget: RunBudget) -> Vec> {
 }
 
 /// Split every emitted frame into its supply run, requiring supplies only.
-fn runs_of(frames: &[Frame]) -> Vec<&LeafRun> {
+fn runs_of(frames: &[Frame]) -> Vec<&LeafRun> {
     frames
         .iter()
         .map(|frame| match frame {
@@ -140,10 +138,10 @@ fn runs_of(frames: &[Frame]) -> Vec<&LeafRun> {
 }
 
 /// The decoded records of `runs`, flattened in wire order.
-fn records_of(runs: &[&LeafRun]) -> Vec<(Version, Message)> {
+fn records_of(runs: &[&LeafRun]) -> Vec<(Version, Message)> {
     runs.iter()
         .flat_map(|run| {
-            run.records()
+            run.records(Message::deserializer::())
                 .collect::, _>>()
                 .expect("an encoder-produced run holds canonical records")
         })
@@ -193,7 +191,7 @@ proptest! {
             // would have pushed its frame past the budget.
             if position + 1 < runs.len() {
                 let (version, message) = runs[position + 1]
-                    .records()
+                    .records(Message::deserializer::())
                     .next()
                     .expect("a nonempty run yields a first record")
                     .expect("an encoder-produced run holds canonical records");
@@ -267,12 +265,13 @@ fn a_batched_run_round_trips_the_reply() {
     let runtime = runtime();
     let reply = runtime.block_on(async {
         let mut input = stream::iter(frames);
-        decode_reply::(
+        decode_reply::(
             Local,
             u64::MAX,
             unbounded(),
-            Scope::::opening(&[]),
+            Scope::opening(&[]),
             &mut input,
+            Message::deserializer::(),
         )
         .await
         .expect("the batched frame decodes")
@@ -283,11 +282,14 @@ fn a_batched_run_round_trips_the_reply() {
     };
     let prefix = Prefix::::containing(&leaves[0].path());
     let rebuilt = runtime.block_on(async {
-        Local
-            .leaves(prefix, node.clone())
-            .try_collect::>()
-            .await
-            .expect("the local backend is infallible")
+        ::leaves(
+            Local,
+            prefix,
+            ::assume::(node.clone()),
+        )
+        .try_collect::>()
+        .await
+        .expect("the local backend is infallible")
     });
     assert_eq!(rebuilt.len(), leaves.len());
 }
diff --git a/src/tree/mirror/streaming/remote/codec.rs b/src/tree/mirror/streaming/remote/codec.rs
index 154c2f4db..69a4dd5b3 100644
--- a/src/tree/mirror/streaming/remote/codec.rs
+++ b/src/tree/mirror/streaming/remote/codec.rs
@@ -19,7 +19,7 @@
 //! An empty query is wholly represented by its signal. A nonempty query carries
 //! `count - 1` in one byte, covering 1 through 256. A supply body is a
 //! [`LeafRun`] behind an exact `u32` body length: one or more
-//! backend-neutral `(Version, Message)` leaf records, each behind its own
+//! backend-neutral `(Version, Message)` leaf records, each behind its own
 //! exact `u32` record length. The codec validates the run's record framing
 //! once its whole body arrives but leaves the records encoded; the adapter
 //! decodes them one at a time, constructs its backend-specific leaves, and
@@ -100,7 +100,7 @@ pub(crate) async fn decode_frame_discarded(
     budget: RunBudget,
 ) -> Result<(), DecodeError> {
     let mut read = FrameRead::new(Speaker::Initiator, budget, read);
-    read.frame::().await.map(|_| ())
+    read.frame().await.map(|_| ())
 }
 
 #[cfg(test)]
diff --git a/src/tree/mirror/streaming/remote/codec/capture.rs b/src/tree/mirror/streaming/remote/codec/capture.rs
index 4f9c2547c..503f288fc 100644
--- a/src/tree/mirror/streaming/remote/codec/capture.rs
+++ b/src/tree/mirror/streaming/remote/codec/capture.rs
@@ -324,7 +324,7 @@ fn query_lines(children: &[u8]) -> Vec {
 /// whose record framing or version encoding does not decode renders the
 /// failure explicitly.
 fn supply_lines(run: Vec) -> Vec {
-    let run = match LeafRun::<()>::from_encoded(run) {
+    let run = match LeafRun::from_encoded(run) {
         Ok(run) => run,
         Err(err) => {
             return vec![format!(
diff --git a/src/tree/mirror/streaming/remote/codec/decode.rs b/src/tree/mirror/streaming/remote/codec/decode.rs
index f6ae3ea1e..9b1b94fc8 100644
--- a/src/tree/mirror/streaming/remote/codec/decode.rs
+++ b/src/tree/mirror/streaming/remote/codec/decode.rs
@@ -26,26 +26,23 @@ use super::{
     signal::{Signal, Speaker, Stream, WireSignal},
 };
 
-#[cfg(test)]
-use serde::de::DeserializeOwned;
-
 /// Decode one frame from `read`, leaving subsequent bytes untouched.
 #[cfg(test)]
-pub fn decode(
+pub fn decode(
     speaker: Speaker,
     budget: RunBudget,
     read: &mut impl Read,
-) -> Result, DecodeError> {
+) -> Result {
     FrameDecoder::new(speaker, budget, read).decode()
 }
 
 /// Decode exactly one frame from a slice, rejecting bytes after it.
 #[cfg(test)]
-pub fn decode_exact(
+pub fn decode_exact(
     speaker: Speaker,
     budget: RunBudget,
     input: &[u8],
-) -> Result, DecodeError> {
+) -> Result {
     let mut rest = input;
     let (stream, frame) = decode(speaker, budget, &mut rest)?;
     if rest.is_empty() {
@@ -78,7 +75,7 @@ impl<'a, R: Read> FrameDecoder<'a, R> {
         }
     }
 
-    fn decode(mut self) -> Result, DecodeError> {
+    fn decode(mut self) -> Result {
         let (stream, signal) = self.signal()?;
         let frame = self
             .body(signal)
@@ -93,7 +90,7 @@ impl<'a, R: Read> FrameDecoder<'a, R> {
         decode_signal(self.speaker, byte)
     }
 
-    fn body(&mut self, signal: Signal) -> Result, DecodeErrorKind> {
+    fn body(&mut self, signal: Signal) -> Result {
         let frame = match signal {
             Signal::Match(flow) => Frame::Reaction(Reaction::Match, flow),
             Signal::QueryEmpty(flow) => Frame::Reaction(Reaction::Query(Vec::new()), flow),
@@ -113,7 +110,7 @@ impl<'a, R: Read> FrameDecoder<'a, R> {
         parse_query(&listing)
     }
 
-    fn supply(&mut self) -> Result, DecodeErrorKind> {
+    fn supply(&mut self) -> Result {
         let mut header = [0; LENGTH_HEADER_LEN];
         self.read_exact(&mut header, FramePart::SupplyLength)?;
         let len = u32::from_be_bytes(header) as usize;
diff --git a/src/tree/mirror/streaming/remote/codec/decode/async_io.rs b/src/tree/mirror/streaming/remote/codec/decode/async_io.rs
index 4a8866228..a490a0025 100644
--- a/src/tree/mirror/streaming/remote/codec/decode/async_io.rs
+++ b/src/tree/mirror/streaming/remote/codec/decode/async_io.rs
@@ -17,7 +17,6 @@ use crate::tree::{
     typed::Hash,
 };
 
-use serde::de::DeserializeOwned;
 /// Async frame reader over one speaker's transport direction.
 ///
 /// EOF before a signal is a clean direction close and returns `None`. Once a
@@ -63,9 +62,7 @@ impl FrameRead {
     /// as a signal. Either retain the in-flight future across polls until
     /// it resolves, or read nothing further from this direction after a
     /// cancellation.
-    pub async fn frame(
-        &mut self,
-    ) -> Result>, DecodeError> {
+    pub async fn frame(&mut self) -> Result, DecodeError> {
         let Some((stream, signal)) = read_signal(self.speaker, &mut self.read).await? else {
             return Ok(None);
         };
@@ -108,10 +105,7 @@ impl<'a, R: AsyncRead + Unpin> AsyncFrameDecoder<'a, R> {
         Self { read, budget }
     }
 
-    async fn body(
-        &mut self,
-        signal: Signal,
-    ) -> Result, DecodeErrorKind> {
+    async fn body(&mut self, signal: Signal) -> Result {
         let frame = match signal {
             Signal::Match(flow) => Frame::Reaction(Reaction::Match, flow),
             Signal::QueryEmpty(flow) => Frame::Reaction(Reaction::Query(Vec::new()), flow),
@@ -130,7 +124,7 @@ impl<'a, R: AsyncRead + Unpin> AsyncFrameDecoder<'a, R> {
         parse_query(&listing)
     }
 
-    async fn supply(&mut self) -> Result, DecodeErrorKind> {
+    async fn supply(&mut self) -> Result {
         let mut header = [0; LENGTH_HEADER_LEN];
         self.read_exact(&mut header, FramePart::SupplyLength)
             .await?;
diff --git a/src/tree/mirror/streaming/remote/codec/decode/tests.rs b/src/tree/mirror/streaming/remote/codec/decode/tests.rs
index 72289053f..e5df2a9a9 100644
--- a/src/tree/mirror/streaming/remote/codec/decode/tests.rs
+++ b/src/tree/mirror/streaming/remote/codec/decode/tests.rs
@@ -36,7 +36,7 @@ fn supply(stream: Stream, flow: Flow, body: &[u8]) -> Vec {
 
 /// One length-prefixed leaf record as it appears inside a run body: the
 /// version as one CBOR value, then the payload's CBOR bytes bare.
-fn record(version: &Version, message: &Message) -> Vec {
+fn record(version: &Version, message: &Message) -> Vec {
     let mut body = Vec::new();
     ciborium::ser::into_writer(version, &mut body).unwrap();
     body.extend_from_slice(message.as_slice());
@@ -68,7 +68,7 @@ fn invalid_signals_are_rejected() {
             let DecodeSignalError::Reserved(reserved) = invalid else {
                 panic!("unexpected signal error")
             };
-            let error = decode_exact::(speaker, RunBudget::default(), &[byte]).unwrap_err();
+            let error = decode_exact(speaker, RunBudget::default(), &[byte]).unwrap_err();
             assert_eq!(error.origin, Origin::stream(speaker, reserved.stream()));
             let DecodeErrorKind::InvalidSignal(DecodeSignalError::Reserved(source)) = error.kind
             else {
@@ -115,7 +115,7 @@ fn truncated_bodies_are_rejected() {
             ),
         ];
         for (encoded, missing, origin) in cases {
-            let error = decode_exact::(speaker, RunBudget::default(), &encoded).unwrap_err();
+            let error = decode_exact(speaker, RunBudget::default(), &encoded).unwrap_err();
             assert_eq!(error.origin, origin);
             let DecodeErrorKind::Truncated {
                 missing: actual,
@@ -149,7 +149,7 @@ proptest! {
 
         let expected = LeafRun::from_encoded(body).unwrap();
         prop_assert_eq!(
-            decode_exact::(speaker, RunBudget::default(), &encoded).unwrap(),
+            decode_exact(speaker, RunBudget::default(), &encoded).unwrap(),
             (stream, Frame::Reaction(Reaction::Supply(expected), flow))
         );
     }
@@ -164,7 +164,7 @@ fn malformed_run_structure_is_typed() {
 
     let stream = stream(8);
     for speaker in SPEAKERS {
-        let empty = decode_exact::(
+        let empty = decode_exact(
             speaker,
             RunBudget::default(),
             &supply(stream, Flow::Continue, &[]),
@@ -176,7 +176,7 @@ fn malformed_run_structure_is_typed() {
             DecodeErrorKind::InvalidRun(LeafRunError::Empty)
         ));
 
-        let short_header = decode_exact::(
+        let short_header = decode_exact(
             speaker,
             RunBudget::default(),
             &supply(stream, Flow::Continue, &[0, 0]),
@@ -190,7 +190,7 @@ fn malformed_run_structure_is_typed() {
 
         let mut overrun = 2_u32.to_be_bytes().to_vec();
         overrun.push(0);
-        let short_record = decode_exact::(
+        let short_record = decode_exact(
             speaker,
             RunBudget::default(),
             &supply(stream, Flow::Continue, &overrun),
@@ -219,13 +219,17 @@ fn a_zero_length_record_is_structurally_valid() {
     let encoded = supply(stream, Flow::End, &[0, 0, 0, 0]);
     for speaker in SPEAKERS {
         let (decoded_stream, frame) =
-            decode_exact::(speaker, RunBudget::default(), &encoded).unwrap();
+            decode_exact(speaker, RunBudget::default(), &encoded).unwrap();
         assert_eq!(decoded_stream, stream);
         let Frame::Reaction(Reaction::Supply(run), Flow::End) = frame else {
             panic!("a structurally valid run decodes as a supply reaction");
         };
         assert_eq!(run.record_count(), 1);
-        let error = run.records().next().unwrap().unwrap_err();
+        let error = run
+            .records(Message::deserializer::())
+            .next()
+            .unwrap()
+            .unwrap_err();
         let DecodeLeafError::Version(source) = error else {
             panic!("unexpected record error");
         };
@@ -239,8 +243,12 @@ fn a_zero_length_record_is_structurally_valid() {
 fn supplied_record_errors_are_typed() {
     let mut truncated_version = (TRUNCATED_VERSION.len() as u32).to_be_bytes().to_vec();
     truncated_version.extend_from_slice(TRUNCATED_VERSION);
-    let run = LeafRun::::from_encoded(truncated_version).unwrap();
-    let error = run.records().next().unwrap().unwrap_err();
+    let run = LeafRun::from_encoded(truncated_version).unwrap();
+    let error = run
+        .records(Message::deserializer::())
+        .next()
+        .unwrap()
+        .unwrap_err();
     let DecodeLeafError::Version(source) = error else {
         panic!("unexpected record error");
     };
@@ -250,20 +258,34 @@ fn supplied_record_errors_are_typed() {
     ciborium::ser::into_writer(&Version::new(), &mut version).unwrap();
     let mut missing_message = (version.len() as u32).to_be_bytes().to_vec();
     missing_message.extend_from_slice(&version);
-    let run = LeafRun::::from_encoded(missing_message).unwrap();
-    let error = run.records().next().unwrap().unwrap_err();
+    let run = LeafRun::from_encoded(missing_message).unwrap();
+    let error = run
+        .records(Message::deserializer::())
+        .next()
+        .unwrap()
+        .unwrap_err();
     let DecodeLeafError::Message(source) = error else {
         panic!("unexpected record error");
     };
     assert_eq!(source.kind(), std::io::ErrorKind::UnexpectedEof);
 
+    // Bytes past the canonical pair make the payload malformed: the
+    // payload runs to the record's end, so the deserializer's
+    // exactly-one-value check is what rejects the excess.
     ciborium::ser::into_writer(&0_u64, &mut version).unwrap();
     version.push(u8::MIN);
     let mut trailing = (version.len() as u32).to_be_bytes().to_vec();
     trailing.extend_from_slice(&version);
-    let run = LeafRun::::from_encoded(trailing).unwrap();
-    let error = run.records().next().unwrap().unwrap_err();
-    assert!(matches!(error, DecodeLeafError::TrailingBytes { count: 1 }));
+    let run = LeafRun::from_encoded(trailing).unwrap();
+    let error = run
+        .records(Message::deserializer::())
+        .next()
+        .unwrap()
+        .unwrap_err();
+    let DecodeLeafError::Message(source) = error else {
+        panic!("unexpected record error");
+    };
+    assert_eq!(source.kind(), std::io::ErrorKind::InvalidData);
 }
 
 proptest! {
@@ -288,7 +310,7 @@ proptest! {
             encoded.push(*radix);
             encoded.extend_from_slice(hash.as_bytes());
         }
-        let error = decode_exact::(speaker, RunBudget::default(), &encoded).unwrap_err();
+        let error = decode_exact(speaker, RunBudget::default(), &encoded).unwrap_err();
         prop_assert_eq!(error.origin, Origin::stream(speaker, stream));
         let correct = matches!(
             error.kind,
@@ -309,7 +331,7 @@ fn exact_decode_rejects_trailing_frame() {
     let second = signal(stream, Signal::End(End::Reply));
     let encoded = [first, second];
     for speaker in SPEAKERS {
-        let error = decode_exact::(speaker, RunBudget::default(), &encoded).unwrap_err();
+        let error = decode_exact(speaker, RunBudget::default(), &encoded).unwrap_err();
         assert_eq!(error.origin, Origin::stream(speaker, stream));
         assert!(matches!(
             error.kind,
@@ -319,7 +341,7 @@ fn exact_decode_rejects_trailing_frame() {
         ));
 
         let mut rest = encoded.as_slice();
-        let frame = decode::(speaker, RunBudget::default(), &mut rest).unwrap();
+        let frame = decode(speaker, RunBudget::default(), &mut rest).unwrap();
         assert_eq!(
             frame,
             (stream, Frame::Reaction(Reaction::Match, Flow::Continue))
@@ -335,7 +357,7 @@ fn async_eof_distinguishes_close_from_truncation() {
     let stream = stream(4);
     for speaker in SPEAKERS {
         let mut closed = FrameRead::new(speaker, RunBudget::default(), &[][..]);
-        assert_eq!(pollster::block_on(closed.frame::()).unwrap(), None);
+        assert_eq!(pollster::block_on(closed.frame()).unwrap(), None);
 
         let cases = [
             (
@@ -361,7 +383,7 @@ fn async_eof_distinguishes_close_from_truncation() {
         ];
         for (encoded, missing) in cases {
             let mut reader = FrameRead::new(speaker, RunBudget::default(), encoded.as_slice());
-            let error = pollster::block_on(reader.frame::()).unwrap_err();
+            let error = pollster::block_on(reader.frame()).unwrap_err();
             assert_eq!(error.origin, Origin::stream(speaker, stream));
             assert!(matches!(
                 error.kind,
@@ -404,14 +426,14 @@ fn async_invalid_signal_does_not_consume_a_body() {
         let bytes = [invalid, valid];
         let mut reader = FrameRead::new(speaker, RunBudget::default(), bytes.as_slice());
 
-        let error = pollster::block_on(reader.frame::()).unwrap_err();
+        let error = pollster::block_on(reader.frame()).unwrap_err();
         assert_eq!(error.origin, Origin::stream(speaker, stream));
         assert!(matches!(
             error.kind,
             DecodeErrorKind::InvalidSignal(DecodeSignalError::Placement(_))
         ));
         assert_eq!(
-            pollster::block_on(reader.frame::()).unwrap(),
+            pollster::block_on(reader.frame()).unwrap(),
             Some((stream, valid_frame)),
         );
     }
@@ -429,7 +451,7 @@ impl std::io::Read for FailingReader {
 #[test]
 fn reader_errors_are_contextual() {
     for speaker in SPEAKERS {
-        let error = decode::<()>(speaker, RunBudget::default(), &mut FailingReader).unwrap_err();
+        let error = decode(speaker, RunBudget::default(), &mut FailingReader).unwrap_err();
         assert_eq!(error.origin, Origin::direction(speaker));
         assert!(matches!(
             error.kind,
@@ -459,7 +481,7 @@ fn supply_truncation_at_chunk_boundaries_is_typed() {
             encoded.extend_from_slice(&u32::try_from(declared).unwrap().to_be_bytes());
             encoded.extend(vec![0xA5; delivered]);
             let mut reader = FrameRead::new(speaker, RunBudget::default(), encoded.as_slice());
-            let error = pollster::block_on(reader.frame::()).unwrap_err();
+            let error = pollster::block_on(reader.frame()).unwrap_err();
             assert_eq!(error.origin, Origin::stream(speaker, stream));
             assert!(
                 matches!(
@@ -498,12 +520,12 @@ fn decode_both(
     speaker: Speaker,
     budget: RunBudget,
     bytes: &[u8],
-) -> Result, DecodeError> {
+) -> Result {
     let mut reader = FrameRead::new(speaker, budget, bytes);
-    let from_async = pollster::block_on(reader.frame::())
+    let from_async = pollster::block_on(reader.frame())
         .map(|frame| frame.expect("a nonempty byte stream is not a clean close"));
     let mut rest = bytes;
-    let from_sync = decode::(speaker, budget, &mut rest);
+    let from_sync = decode(speaker, budget, &mut rest);
     match (from_async, from_sync) {
         (Ok(a), Ok(s)) => {
             assert_eq!(a, s, "the two decoders accept different frames");
diff --git a/src/tree/mirror/streaming/remote/codec/encode.rs b/src/tree/mirror/streaming/remote/codec/encode.rs
index 8bc985404..f62bf7326 100644
--- a/src/tree/mirror/streaming/remote/codec/encode.rs
+++ b/src/tree/mirror/streaming/remote/codec/encode.rs
@@ -26,9 +26,9 @@ use super::{
 
 /// Append `wire`'s canonical representation to `out`.
 #[cfg(test)]
-pub fn encode(
+pub fn encode(
     speaker: Speaker,
-    wire: &WireFrame,
+    wire: &WireFrame,
     out: &mut W,
 ) -> Result<(), EncodeError> {
     let (stream, frame) = wire;
@@ -43,12 +43,12 @@ pub fn encode(
 /// run record framing are guaranteed by its callers and checked only when
 /// bytes enter from the wire. Construction performs only the
 /// representational checks needed before any byte can be emitted.
-struct FrameEncoding<'a, T> {
+struct FrameEncoding<'a> {
     signal: [u8; WireSignal::ENCODED_LEN],
-    body: BodyEncoding<'a, T>,
+    body: BodyEncoding<'a>,
 }
 
-enum BodyEncoding<'a, T> {
+enum BodyEncoding<'a> {
     Empty,
     Query {
         count: [u8; 1],
@@ -56,12 +56,12 @@ enum BodyEncoding<'a, T> {
     },
     Supply {
         header: [u8; LENGTH_HEADER_LEN],
-        run: &'a LeafRun,
+        run: &'a LeafRun,
     },
 }
 
-impl<'a, T> FrameEncoding<'a, T> {
-    fn new(stream: Stream, frame: &'a Frame) -> Result {
+impl<'a> FrameEncoding<'a> {
+    fn new(stream: Stream, frame: &'a Frame) -> Result {
         let (signal, body) = match frame {
             Frame::Reaction(Reaction::Match, flow) => (Signal::Match(*flow), BodyEncoding::Empty),
             Frame::Reaction(Reaction::Query(children), flow) if children.is_empty() => {
diff --git a/src/tree/mirror/streaming/remote/codec/encode/async_io.rs b/src/tree/mirror/streaming/remote/codec/encode/async_io.rs
index f8c5dc4da..fa834f2bc 100644
--- a/src/tree/mirror/streaming/remote/codec/encode/async_io.rs
+++ b/src/tree/mirror/streaming/remote/codec/encode/async_io.rs
@@ -43,7 +43,7 @@ impl FrameWrite {
     /// reader. Either retain the in-flight future across polls until it
     /// resolves, or write nothing further on this direction after a
     /// cancellation.
-    pub async fn frame(&mut self, wire: &WireFrame) -> Result<(), EncodeError> {
+    pub async fn frame(&mut self, wire: &WireFrame) -> Result<(), EncodeError> {
         let (stream, frame) = wire;
         let result = async {
             let encoding = FrameEncoding::new(*stream, frame)?;
@@ -55,9 +55,9 @@ impl FrameWrite {
     }
 }
 
-async fn write_encoding(
+async fn write_encoding(
     out: &mut (impl AsyncWrite + Unpin),
-    encoding: &FrameEncoding<'_, T>,
+    encoding: &FrameEncoding<'_>,
 ) -> Result<(), EncodeErrorKind> {
     write(out, FramePart::Signal, &encoding.signal).await?;
     match &encoding.body {
diff --git a/src/tree/mirror/streaming/remote/codec/encode/tests.rs b/src/tree/mirror/streaming/remote/codec/encode/tests.rs
index 8a3bd85cc..4be2237e7 100644
--- a/src/tree/mirror/streaming/remote/codec/encode/tests.rs
+++ b/src/tree/mirror/streaming/remote/codec/encode/tests.rs
@@ -53,7 +53,7 @@ fn query_count_covers_every_fan_and_flow() {
             })
             .collect::>();
         for flow in FLOWS {
-            let frame: WireFrame = (
+            let frame: WireFrame = (
                 stream,
                 Frame::Reaction(Reaction::Query(children.clone()), flow),
             );
@@ -79,7 +79,7 @@ fn query_count_covers_every_fan_and_flow() {
 #[test]
 fn one_byte_frames_are_exhaustive() {
     let stream = stream(4);
-    let cases: Vec<(WireFrame, u8)> = vec![
+    let cases: Vec<(WireFrame, u8)> = vec![
         (
             (stream, Frame::Reaction(Reaction::Match, Flow::Continue)),
             signal(stream, Signal::Match(Flow::Continue)),
@@ -157,7 +157,7 @@ impl std::io::Write for FailingWriter {
 #[test]
 fn writer_errors_are_contextual() {
     let stream = stream(12);
-    let frame: WireFrame<()> = (stream, Frame::End(End::Reply));
+    let frame: WireFrame = (stream, Frame::End(End::Reply));
     for speaker in SPEAKERS {
         let error = encode(speaker, &frame, &mut FailingWriter).unwrap_err();
         assert_eq!(error.origin, Origin::stream(speaker, stream));
@@ -207,7 +207,7 @@ impl AsyncWrite for FailingAsyncWriter {
 #[test]
 fn async_writer_errors_are_contextual() {
     let stream = stream(12);
-    let frame: WireFrame<()> = (stream, Frame::End(End::Reply));
+    let frame: WireFrame = (stream, Frame::End(End::Reply));
     for speaker in SPEAKERS {
         let mut writer = FrameWrite::new(speaker, FailingAsyncWriter(AsyncFailure::Write));
         let error = pollster::block_on(writer.frame(&frame)).unwrap_err();
diff --git a/src/tree/mirror/streaming/remote/codec/error.rs b/src/tree/mirror/streaming/remote/codec/error.rs
index 71f281492..68405f945 100644
--- a/src/tree/mirror/streaming/remote/codec/error.rs
+++ b/src/tree/mirror/streaming/remote/codec/error.rs
@@ -106,8 +106,6 @@ pub enum DecodeLeafError {
     Version(#[source] std::io::Error),
     #[error("supplied Message could not be decoded")]
     Message(#[source] std::io::Error),
-    #[error("{count} trailing bytes follow the supplied Version and Message")]
-    TrailingBytes { count: usize },
 }
 
 /// Why an incoming frame could not be decoded.
diff --git a/src/tree/mirror/streaming/remote/codec/frame.rs b/src/tree/mirror/streaming/remote/codec/frame.rs
index 712f6c46c..9b21f0b2f 100644
--- a/src/tree/mirror/streaming/remote/codec/frame.rs
+++ b/src/tree/mirror/streaming/remote/codec/frame.rs
@@ -1,10 +1,8 @@
 //! Semantic wire frames after signal decoding.
 
-use std::marker::PhantomData;
-
 use crate::{
     Version,
-    message::Message,
+    message::{Message, PayloadDeserializer},
     tree::{
         mirror::framing::{LENGTH_HEADER_LEN, LengthOverflow, length_header},
         typed::{Hash, hash::MERKLE_HASH_LEN},
@@ -14,7 +12,6 @@ use crate::{
 use super::error::{DecodeLeafError, QueryOrderError};
 use super::signal::{End, Flow, Stream};
 
-use serde::de::DeserializeOwned;
 /// The count byte stores one less than the nonempty query's actual fan.
 pub const QUERY_COUNT_BIAS: usize = 1;
 
@@ -32,27 +29,27 @@ const ADJACENT_CHILD_COUNT: usize = 2;
 
 /// The body of one complete reaction frame.
 #[derive(Debug, Clone, PartialEq, Eq)]
-pub enum Reaction {
+pub enum Reaction {
     Match,
     Query(Vec<(u8, Hash)>),
-    Supply(LeafRun),
+    Supply(LeafRun),
 }
 
 /// A protocol reaction frame or a boundary-only frame.
 #[derive(Debug, Clone, PartialEq, Eq)]
-pub enum Frame {
+pub enum Frame {
     /// A reaction and whether another follows in its reply.
-    Reaction(Reaction, Flow),
+    Reaction(Reaction, Flow),
     /// An empty reply or a transport-level stream-end control.
     End(End),
 }
 
 /// A frame paired with the logical stream named by its signal byte.
-pub type WireFrame = (Stream, Frame);
+pub type WireFrame = (Stream, Frame);
 
 /// One supply frame's run of leaf records, held in encoded form.
 ///
-/// A run is a delimited sequence of one or more `(Version, Message)`
+/// A run is a delimited sequence of one or more `(Version, Message)`
 /// records: each record is a [`LENGTH_HEADER_LEN`]-byte big-endian length
 /// followed by one CBOR value (a byte string wrapping the version's
 /// canonical encoding) and then the message's CBOR payload, back to back —
@@ -72,35 +69,33 @@ pub type WireFrame = (Stream, Frame);
 ///
 /// [`push`]: Self::push
 /// [`records`]: Self::records
-pub struct LeafRun {
+pub struct LeafRun {
     bytes: Vec,
-    marker: PhantomData T>,
 }
 
-impl Default for LeafRun {
+impl Default for LeafRun {
     fn default() -> Self {
         Self::new()
     }
 }
 
-impl Clone for LeafRun {
+impl Clone for LeafRun {
     fn clone(&self) -> Self {
         Self {
             bytes: self.bytes.clone(),
-            marker: PhantomData,
         }
     }
 }
 
-impl PartialEq for LeafRun {
+impl PartialEq for LeafRun {
     fn eq(&self, other: &Self) -> bool {
         self.bytes == other.bytes
     }
 }
 
-impl Eq for LeafRun {}
+impl Eq for LeafRun {}
 
-impl std::fmt::Debug for LeafRun {
+impl std::fmt::Debug for LeafRun {
     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
         f.debug_struct("LeafRun")
             .field("records", &self.record_count())
@@ -109,14 +104,11 @@ impl std::fmt::Debug for LeafRun {
     }
 }
 
-impl LeafRun {
+impl LeafRun {
     /// Start an empty run; at least one record must be pushed before it may
     /// become a frame.
     pub fn new() -> Self {
-        Self {
-            bytes: Vec::new(),
-            marker: PhantomData,
-        }
+        Self { bytes: Vec::new() }
     }
 
     /// Whether no record has been pushed yet.
@@ -142,7 +134,7 @@ impl LeafRun {
     /// `record_len_matches_an_actual_push`. Saturating: a sum past
     /// `usize::MAX` cannot occur for in-memory slices, and an over-large
     /// record is rejected by [`push`](Self::push) regardless.
-    pub fn record_len(version: &Version, message: &Message) -> usize {
+    pub fn record_len(version: &Version, message: &Message) -> usize {
         let version = version.as_bytes().len();
         LENGTH_HEADER_LEN
             .saturating_add(cbor_bytes_header_len(version))
@@ -157,7 +149,7 @@ impl LeafRun {
     /// Rejects a record no run can carry — one whose combined encoding plus
     /// its own record header exceeds the `u32` run-body limit — leaving the
     /// run untouched.
-    pub fn push(&mut self, version: &Version, message: &Message) -> Result<(), LengthOverflow> {
+    pub fn push(&mut self, version: &Version, message: &Message) -> Result<(), LengthOverflow> {
         let version = version.as_bytes();
         let message = message.as_slice();
         let len = cbor_bytes_header_len(version.len())
@@ -194,10 +186,7 @@ impl LeafRun {
             }
             rest = &body[len..];
         }
-        Ok(Self {
-            bytes,
-            marker: PhantomData,
-        })
+        Ok(Self { bytes })
     }
 
     /// The number of records in this run.
@@ -206,11 +195,12 @@ impl LeafRun {
     }
 
     /// Iterate the run's records, decoding each into its canonical pair.
-    pub fn records(&self) -> impl Iterator), DecodeLeafError>>
-    where
-        T: DeserializeOwned,
-    {
-        self.record_slices().map(parse_record)
+    pub fn records(
+        &self,
+        deserializer: PayloadDeserializer,
+    ) -> impl Iterator> {
+        self.record_slices()
+            .map(move |record| parse_record(record, deserializer))
     }
 
     /// Split the validated run back into its exact record slices.
@@ -263,9 +253,10 @@ fn record_header(header: &[u8]) -> usize {
 }
 
 /// Decode one exact record body into its canonical pair.
-fn parse_record(
+fn parse_record(
     record: &[u8],
-) -> Result<(Version, Message), DecodeLeafError> {
+    deserializer: PayloadDeserializer,
+) -> Result<(Version, Message), DecodeLeafError> {
     // Both fields are self-delimiting CBOR values, so the exact record
     // body parses without retrying, and whatever the payload's parse does
     // not consume is trailing.
@@ -278,13 +269,12 @@ fn parse_record(
     let mut input = record;
     let version: Version =
         ciborium::de::from_reader(&mut input).map_err(|e| DecodeLeafError::Version(de_error(e)))?;
-    let payload = input;
-    let message: T =
-        ciborium::de::from_reader(&mut input).map_err(|e| DecodeLeafError::Message(de_error(e)))?;
-    if !input.is_empty() {
-        return Err(DecodeLeafError::TrailingBytes { count: input.len() });
-    }
-    let message = Message::from_decoded(message, bytes::Bytes::copy_from_slice(payload));
+    // The deserializer owns the payload parse, including the
+    // exactly-one-value check the record framing otherwise cannot make
+    // (the payload runs to the record's end), so trailing bytes surface
+    // as its InvalidData.
+    let message = Message::from_wire(bytes::Bytes::copy_from_slice(input), deserializer)
+        .map_err(DecodeLeafError::Message)?;
     Ok((version, message))
 }
 
diff --git a/src/tree/mirror/streaming/remote/codec/frame/tests.rs b/src/tree/mirror/streaming/remote/codec/frame/tests.rs
index 3f2acdb0c..f21de3348 100644
--- a/src/tree/mirror/streaming/remote/codec/frame/tests.rs
+++ b/src/tree/mirror/streaming/remote/codec/frame/tests.rs
@@ -59,7 +59,7 @@ fn record_len_matches_an_actual_push() {
         }
         checked_regimes.insert(super::cbor_bytes_header_len(version.as_bytes().len()));
         for message in [Message::new(0u64), Message::new(u64::MAX)] {
-            let mut run = LeafRun::::new();
+            let mut run = LeafRun::new();
             run.push(&version, &message).expect("test records fit");
             assert_eq!(
                 run.encoded_len(),
diff --git a/src/tree/mirror/streaming/remote/codec/tests.rs b/src/tree/mirror/streaming/remote/codec/tests.rs
index 8b7890835..cfda12463 100644
--- a/src/tree/mirror/streaming/remote/codec/tests.rs
+++ b/src/tree/mirror/streaming/remote/codec/tests.rs
@@ -67,7 +67,7 @@ const MAX_ARBITRARY_SUFFIX_LEN: usize = 32;
 const MAX_ARBITRARY_RUN_RECORDS: usize = 4;
 
 /// Build a supply run from decoded leaf records.
-fn leaf_run(records: &[(Version, T)]) -> LeafRun {
+fn leaf_run(records: &[(Version, T)]) -> LeafRun {
     let mut run = LeafRun::new();
     for (version, value) in records {
         run.push(version, &Message::new(value.clone()))
@@ -93,7 +93,7 @@ fn arb_flow() -> impl Strategy {
     prop_oneof![Just(Flow::Continue), Just(Flow::End)]
 }
 
-fn arb_frame() -> impl Strategy> {
+fn arb_frame() -> impl Strategy {
     prop_oneof![
         (arb_stream(), arb_flow())
             .prop_map(|(stream, flow)| (stream, Frame::Reaction(Reaction::Match, flow))),
@@ -135,7 +135,7 @@ proptest! {
         encoded.extend_from_slice(&suffix);
 
         let mut rest = encoded.as_slice();
-        let decoded = decode::(speaker, RunBudget::default(), &mut rest).unwrap();
+        let decoded = decode(speaker, RunBudget::default(), &mut rest).unwrap();
         prop_assert_eq!(&decoded, &frame);
         prop_assert_eq!(rest, suffix.as_slice());
 
@@ -166,9 +166,9 @@ proptest! {
         prop_assert_eq!(&written.bytes, &canonical);
 
         let mut reader = FrameRead::new(speaker, RunBudget::default(), written.bytes.as_slice());
-        let decoded = pollster::block_on(reader.frame::()).unwrap();
+        let decoded = pollster::block_on(reader.frame()).unwrap();
         prop_assert_eq!(decoded, Some(frame));
-        prop_assert_eq!(pollster::block_on(reader.frame::()).unwrap(), None);
+        prop_assert_eq!(pollster::block_on(reader.frame()).unwrap(), None);
     }
 }
 
@@ -233,9 +233,9 @@ async fn async_duplex_preserves_adjacent_frame_boundaries() {
         };
         let receiving = async {
             let mut reader = FrameRead::new(speaker, RunBudget::default(), receive);
-            assert_eq!(reader.frame::().await.unwrap(), Some(first));
-            assert_eq!(reader.frame::().await.unwrap(), Some(second));
-            assert_eq!(reader.frame::().await.unwrap(), None);
+            assert_eq!(reader.frame().await.unwrap(), Some(first));
+            assert_eq!(reader.frame().await.unwrap(), Some(second));
+            assert_eq!(reader.frame().await.unwrap(), None);
         };
         futures::join!(sending, receiving);
     }
@@ -258,7 +258,7 @@ fn canonical_frame_atlas_snapshot() {
                         encode(speaker, &(stream, frame.clone()), &mut encoded).unwrap();
                         assert_eq!(encoded.first(), Some(&wire.to_byte()));
                         assert_eq!(
-                            decode_exact::<()>(speaker, RunBudget::default(), &encoded).unwrap(),
+                            decode_exact(speaker, RunBudget::default(), &encoded).unwrap(),
                             (stream, frame)
                         );
                         write!(atlas, "    {signal:?}: accepted len {} hex ", encoded.len())
@@ -267,9 +267,8 @@ fn canonical_frame_atlas_snapshot() {
                         atlas.push('\n');
                     }
                     Err(invalid) => {
-                        let error =
-                            decode_exact::<()>(speaker, RunBudget::default(), &[invalid.byte()])
-                                .unwrap_err();
+                        let error = decode_exact(speaker, RunBudget::default(), &[invalid.byte()])
+                            .unwrap_err();
                         assert_eq!(error.origin, Origin::stream(speaker, stream));
                         assert!(matches!(
                             error.kind,
@@ -297,7 +296,7 @@ fn write_hex(out: &mut impl Write, bytes: &[u8]) {
     }
 }
 
-fn representative_frame(signal: Signal) -> Frame<()> {
+fn representative_frame(signal: Signal) -> Frame {
     match signal {
         Signal::Match(flow) => Frame::Reaction(Reaction::Match, flow),
         Signal::QueryEmpty(flow) => Frame::Reaction(Reaction::Query(Vec::new()), flow),
@@ -442,7 +441,7 @@ fn enumerate_queries(
     }
 }
 
-fn check_both(frame: WireFrame<()>, accepted: &mut [usize; 2], buckets: &mut [CorpusBucket]) {
+fn check_both(frame: WireFrame, accepted: &mut [usize; 2], buckets: &mut [CorpusBucket]) {
     let signal = frame_signal(&frame.1);
     let signal_index = SIGNALS
         .iter()
@@ -459,7 +458,7 @@ fn check_both(frame: WireFrame<()>, accepted: &mut [usize; 2], buckets: &mut [Co
                 encode(speaker, &frame, &mut encoded).unwrap();
                 accepted[direction] += 1;
                 assert_eq!(
-                    decode_exact::<()>(speaker, RunBudget::default(), &encoded).unwrap(),
+                    decode_exact(speaker, RunBudget::default(), &encoded).unwrap(),
                     frame
                 );
                 bucket.accept(&encoded);
@@ -469,7 +468,7 @@ fn check_both(frame: WireFrame<()>, accepted: &mut [usize; 2], buckets: &mut [Co
     }
 }
 
-fn frame_signal(frame: &Frame) -> Signal {
+fn frame_signal(frame: &Frame) -> Signal {
     match frame {
         Frame::Reaction(Reaction::Match, flow) => Signal::Match(*flow),
         Frame::Reaction(Reaction::Query(children), flow) if children.is_empty() => {
@@ -499,7 +498,7 @@ fn generic_io_preserves_frame_boundaries() {
 
     let mut reader = Cursor::new(writer.into_inner());
     assert_eq!(
-        decode::<()>(Speaker::Initiator, RunBudget::default(), &mut reader).unwrap(),
+        decode(Speaker::Initiator, RunBudget::default(), &mut reader).unwrap(),
         frame
     );
     assert_eq!(reader.position(), frame_len);
diff --git a/src/tree/mirror/streaming/remote/codec/tests/error_atlas.rs b/src/tree/mirror/streaming/remote/codec/tests/error_atlas.rs
index 2929a65c0..eaf44cc30 100644
--- a/src/tree/mirror/streaming/remote/codec/tests/error_atlas.rs
+++ b/src/tree/mirror/streaming/remote/codec/tests/error_atlas.rs
@@ -55,7 +55,6 @@ const WITNESS_MARKERS: &[&str] = &[
     // DecodeLeafError (describe_leaf_kind).
     "kind: Record::Version(io=",
     "kind: Record::Message(io=",
-    "kind: Record::TrailingBytes(count=",
     // FramePart: every frame component must fail somewhere. These ride the
     // encode Write witnesses; FramePart has no exhaustive match here, so a
     // new component's marker must be added by hand alongside its witnesses.
@@ -82,7 +81,7 @@ const INTERIOR_STREAM: u8 = 8;
 const FIRST_RESERVED_SIGNAL: u8 = WireSignal::BYTE_COUNT;
 
 /// Build a supply run holding one leaf record.
-fn one_record_run(version: Version, value: T) -> LeafRun {
+fn one_record_run(version: Version, value: T) -> LeafRun {
     let mut run = LeafRun::new();
     run.push(&version, &Message::new(value))
         .expect("an atlas record fits the run framing");
@@ -127,14 +126,14 @@ fn build_atlas() -> String {
 fn encode_errors(atlas: &mut String) {
     writeln!(atlas, "ENCODE").unwrap();
     let stream = Stream::new(INTERIOR_STREAM).unwrap();
-    let query: WireFrame = (
+    let query: WireFrame = (
         stream,
         Frame::Reaction(
             Reaction::Query(vec![(1, Hash::default()), (2, Hash::default())]),
             Flow::Continue,
         ),
     );
-    let supply: WireFrame = (
+    let supply: WireFrame = (
         stream,
         Frame::Reaction(
             Reaction::Supply(one_record_run(Version::new(), 7)),
@@ -167,7 +166,7 @@ fn decode_errors(atlas: &mut String) {
         Speaker::Initiator,
         (
             stream,
-            Frame::::Reaction(
+            Frame::Reaction(
                 Reaction::Query(vec![(1, Hash::default()), (2, Hash::default())]),
                 Flow::Continue,
             ),
@@ -185,14 +184,11 @@ fn decode_errors(atlas: &mut String) {
     );
     let matched = encoded(
         Speaker::Initiator,
-        (
-            stream,
-            Frame::::Reaction(Reaction::Match, Flow::Continue),
-        ),
+        (stream, Frame::Reaction(Reaction::Match, Flow::Continue)),
     );
 
     for speaker in [Speaker::Initiator, Speaker::Responder] {
-        let error = decode::(
+        let error = decode(
             speaker,
             RunBudget::default(),
             &mut FailAfterReader::new(matched.clone(), 0),
@@ -201,7 +197,7 @@ fn decode_errors(atlas: &mut String) {
         record_decode(atlas, &format!("{speaker:?}/read/signal"), &error);
 
         for (label, offset) in [("query-count", 1), ("query-children", 2)] {
-            let error = decode::(
+            let error = decode(
                 speaker,
                 RunBudget::default(),
                 &mut FailAfterReader::new(query.clone(), offset),
@@ -210,7 +206,7 @@ fn decode_errors(atlas: &mut String) {
             record_decode(atlas, &format!("{speaker:?}/read/{label}"), &error);
         }
         for (label, offset) in [("supply-length", 1), ("supply-run", 5)] {
-            let error = decode::(
+            let error = decode(
                 speaker,
                 RunBudget::default(),
                 &mut FailAfterReader::new(supply.clone(), offset),
@@ -226,21 +222,21 @@ fn decode_errors(atlas: &mut String) {
             ("supply-length", &supply[..1]),
             ("supply-run", &supply[..5]),
         ] {
-            let error = decode_exact::(speaker, RunBudget::default(), bytes).unwrap_err();
+            let error = decode_exact(speaker, RunBudget::default(), bytes).unwrap_err();
             record_decode(atlas, &format!("{speaker:?}/truncated/{label}"), &error);
         }
 
-        let error = decode_exact::(speaker, RunBudget::default(), &[FIRST_RESERVED_SIGNAL])
-            .unwrap_err();
+        let error =
+            decode_exact(speaker, RunBudget::default(), &[FIRST_RESERVED_SIGNAL]).unwrap_err();
         record_decode(atlas, &format!("{speaker:?}/reserved-signal"), &error);
 
         let mut unordered = query.clone();
         unordered[2] = 2;
         unordered[2 + QUERY_CHILD_LEN] = 1;
-        let error = decode_exact::(speaker, RunBudget::default(), &unordered).unwrap_err();
+        let error = decode_exact(speaker, RunBudget::default(), &unordered).unwrap_err();
         record_decode(atlas, &format!("{speaker:?}/query-out-of-order"), &error);
 
-        let error = decode_exact::(
+        let error = decode_exact(
             speaker,
             RunBudget::default(),
             &raw_supply(stream, Flow::Continue, &[]),
@@ -248,7 +244,7 @@ fn decode_errors(atlas: &mut String) {
         .unwrap_err();
         record_decode(atlas, &format!("{speaker:?}/run/empty"), &error);
 
-        let error = decode_exact::(
+        let error = decode_exact(
             speaker,
             RunBudget::default(),
             &raw_supply(stream, Flow::Continue, &[0, 0]),
@@ -258,7 +254,7 @@ fn decode_errors(atlas: &mut String) {
 
         let mut overrun = 2_u32.to_be_bytes().to_vec();
         overrun.push(0);
-        let error = decode_exact::(
+        let error = decode_exact(
             speaker,
             RunBudget::default(),
             &raw_supply(stream, Flow::Continue, &overrun),
@@ -281,20 +277,19 @@ fn decode_errors(atlas: &mut String) {
             &mut batched,
         )
         .unwrap();
-        let error = decode_exact::(speaker, RunBudget::from_bytes(0), &batched).unwrap_err();
+        let error = decode_exact(speaker, RunBudget::from_bytes(0), &batched).unwrap_err();
         record_decode(atlas, &format!("{speaker:?}/run/overbatched"), &error);
 
         let mut trailing = matched.clone();
         trailing.push(0);
-        let error = decode_exact::(speaker, RunBudget::default(), &trailing).unwrap_err();
+        let error = decode_exact(speaker, RunBudget::default(), &trailing).unwrap_err();
         record_decode(atlas, &format!("{speaker:?}/frame/trailing"), &error);
     }
 
     for (label, speaker, stream, frame) in placement_witnesses() {
         let signal = frame_signal(&frame);
         let invalid = WireSignal::new(speaker, stream, signal).unwrap_err();
-        let error =
-            decode_exact::(speaker, RunBudget::default(), &[invalid.byte()]).unwrap_err();
+        let error = decode_exact(speaker, RunBudget::default(), &[invalid.byte()]).unwrap_err();
         record_decode(atlas, &format!("{label}/decode"), &error);
     }
 }
@@ -309,20 +304,21 @@ fn record_errors(atlas: &mut String) {
 
     // A zero-length record is structurally valid; its empty body fails
     // at the version decoder.
-    let run = LeafRun::::from_encoded(framed_record(&[])).unwrap();
+    let run = LeafRun::from_encoded(framed_record(&[])).unwrap();
     record_leaf(atlas, "record/version", &next_record_error(&run));
 
     // A record ending after its version fails at the message decoder.
     let mut version = Vec::new();
     ciborium::ser::into_writer(&Version::new(), &mut version).unwrap();
-    let run = LeafRun::::from_encoded(framed_record(&version)).unwrap();
+    let run = LeafRun::from_encoded(framed_record(&version)).unwrap();
     record_leaf(atlas, "record/message", &next_record_error(&run));
 
-    // Bytes past the canonical pair are trailing.
+    // Bytes past the canonical pair are a malformed payload: the payload
+    // runs to the record's end, so the deserializer rejects the excess.
     let mut padded = version.clone();
     ciborium::ser::into_writer(&0_u64, &mut padded).unwrap();
     padded.push(u8::MIN);
-    let run = LeafRun::::from_encoded(framed_record(&padded)).unwrap();
+    let run = LeafRun::from_encoded(framed_record(&padded)).unwrap();
     record_leaf(atlas, "record/trailing", &next_record_error(&run));
 }
 
@@ -334,8 +330,8 @@ fn framed_record(record: &[u8]) -> Vec {
 }
 
 /// The first record's decode failure from a structurally valid run.
-fn next_record_error(run: &LeafRun) -> DecodeLeafError {
-    run.records()
+fn next_record_error(run: &LeafRun) -> DecodeLeafError {
+    run.records(Message::deserializer::())
         .next()
         .expect("the run holds one record")
         .unwrap_err()
@@ -361,13 +357,10 @@ fn describe_leaf_kind(out: &mut String, kind: &DecodeLeafError) {
         DecodeLeafError::Message(source) => {
             write!(out, "Record::Message(io={:?})", source.kind()).unwrap()
         }
-        DecodeLeafError::TrailingBytes { count } => {
-            write!(out, "Record::TrailingBytes(count={count})").unwrap()
-        }
     }
 }
 
-fn placement_witnesses() -> [(&'static str, Speaker, Stream, Frame); 3] {
+fn placement_witnesses() -> [(&'static str, Speaker, Stream, Frame); 3] {
     [
         (
             "placement/opening-supplies",
@@ -390,7 +383,7 @@ fn placement_witnesses() -> [(&'static str, Speaker, Stream, Frame); 3] {
     ]
 }
 
-fn encoded(speaker: Speaker, frame: WireFrame) -> Vec {
+fn encoded(speaker: Speaker, frame: WireFrame) -> Vec {
     let mut bytes = Vec::new();
     encode(speaker, &frame, &mut bytes).unwrap();
     bytes
@@ -406,7 +399,7 @@ fn raw_supply(stream: Stream, flow: Flow, body: &[u8]) -> Vec {
     encoded
 }
 
-fn frame_signal(frame: &Frame) -> Signal {
+fn frame_signal(frame: &Frame) -> Signal {
     match frame {
         Frame::Reaction(Reaction::Match, flow) => Signal::Match(*flow),
         Frame::Reaction(Reaction::Query(children), flow) if children.is_empty() => {
diff --git a/src/tree/mirror/streaming/remote/codec/tests/snapshots/rumors__tree__mirror__streaming__remote__codec__tests__error_atlas__codec_error_atlas_snapshot.snap b/src/tree/mirror/streaming/remote/codec/tests/snapshots/rumors__tree__mirror__streaming__remote__codec__tests__error_atlas__codec_error_atlas_snapshot.snap
index 0b652e48f..299c92125 100644
--- a/src/tree/mirror/streaming/remote/codec/tests/snapshots/rumors__tree__mirror__streaming__remote__codec__tests__error_atlas__codec_error_atlas_snapshot.snap
+++ b/src/tree/mirror/streaming/remote/codec/tests/snapshots/rumors__tree__mirror__streaming__remote__codec__tests__error_atlas__codec_error_atlas_snapshot.snap
@@ -293,5 +293,6 @@ RECORD
     kind: Record::Message(io=UnexpectedEof)
     source[0]: Io(UnexpectedEof)
   record/trailing
-    display: 1 trailing bytes follow the supplied Version and Message
-    kind: Record::TrailingBytes(count=1)
+    display: supplied Message could not be decoded
+    kind: Record::Message(io=InvalidData)
+    source[0]: Io(InvalidData)
diff --git a/src/tree/mirror/streaming/remote/proxy.rs b/src/tree/mirror/streaming/remote/proxy.rs
index 96f1b0463..79f7a52a6 100644
--- a/src/tree/mirror/streaming/remote/proxy.rs
+++ b/src/tree/mirror/streaming/remote/proxy.rs
@@ -24,14 +24,14 @@ async fn send_or_cancel(sender: &Sender, value: T) {
 /// proxy analogue of the materialized implementation's `yield_resolve_query!`.
 macro_rules! yield_reply_scopes {
     (
-        $progress:expr, $height:ty, $count:expr;
+        $progress:expr, $height:expr, $count:expr;
         $yielded:expr;
         $scopes:expr => $next_scopes:expr;
     ) => {{
-        $progress.decoded_reply::<$height>($count);
+        $progress.decoded_reply($height, $count);
         $yielded;
         for scope in $next_scopes {
-            $progress.next_scope::<$height>();
+            $progress.next_scope($height);
             $crate::tree::mirror::streaming::remote::proxy::send_or_cancel(&$scopes, scope).await;
         }
     }};
diff --git a/src/tree/mirror/streaming/remote/proxy/start.rs b/src/tree/mirror/streaming/remote/proxy/start.rs
index ec4868000..bf5b4f2b3 100644
--- a/src/tree/mirror/streaming/remote/proxy/start.rs
+++ b/src/tree/mirror/streaming/remote/proxy/start.rs
@@ -1,7 +1,7 @@
 //! The wire participant's protocol handshake states.
 
+use crate::message::PayloadDeserializer;
 use std::io;
-use std::marker::PhantomData;
 
 use tokio::io::{AsyncRead, AsyncWrite};
 
@@ -35,17 +35,15 @@ use crate::{
     },
 };
 
-use serde::de::DeserializeOwned;
 /// A wire-bound protocol participant ready for the version handshake.
 ///
 /// Consumes a [`Link`] carrier for one session: the control halves host the
 /// causal-version handshake (and are the session's output), the connector
 /// and acceptor supply the descent's data streams, and the carrier's epoch
 /// labels every stream this session opens.
-pub struct Handshaking
+pub struct Handshaking
 where
-    B: Backend: Leaf>,
-    T: Send + Sync + 'static,
+    B: Backend: Leaf>,
 {
     backend: B,
     link: Link,
@@ -57,23 +55,28 @@ where
     /// The session's stats recorder: every stream this session binds
     /// counts its codec bytes through it.
     stats: Recorder,
-    marker: PhantomData T>,
+    /// The peer's payload deserializer: the typed ingress every supplied
+    /// leaf record decodes through (see
+    /// [`Message::deserializer`](crate::message::Message::deserializer)).
+    deserializer: PayloadDeserializer,
 }
 
-impl Handshaking
+impl Handshaking
 where
-    B: Backend: Leaf>,
-    T: Send + Sync + 'static,
+    B: Backend: Leaf>,
 {
     /// Bind one session's link carrier before exchanging causal versions.
-    pub fn start(backend: B, link: Link) -> Self {
+    ///
+    /// `deserializer` is the peer's payload deserializer: every leaf
+    /// record this session decodes builds its payload through it.
+    pub fn start(backend: B, link: Link, deserializer: PayloadDeserializer) -> Self {
         Self {
             backend,
             link,
             versions: Start,
             window: WindowConfig::default(),
             stats: Recorder::default(),
-            marker: PhantomData,
+            deserializer,
         }
     }
 
@@ -103,10 +106,9 @@ pub struct Connecting {
     remote: Greeting,
 }
 
-impl protocol::Protocol for Handshaking
+impl protocol::Protocol for Handshaking
 where
-    B: Backend: Leaf>,
-    T: Send + Sync + 'static,
+    B: Backend: Leaf>,
     R: Send,
     W: Send,
     C: Send,
@@ -118,16 +120,15 @@ where
     type Output = (R, W);
 }
 
-impl Connect for Handshaking
+impl Connect for Handshaking
 where
-    B: Backend: Leaf>,
-    T: DeserializeOwned + Send + Sync + 'static,
+    B: Backend: Leaf>,
     R: AsyncRead + Unpin + Send,
     W: AsyncWrite + Unpin + Send,
     C: Connector,
     A: Acceptor,
 {
-    type Next = Handshaking;
+    type Next = Handshaking;
 
     /// Receive the remote greeting before asking the local server to answer it.
     async fn connect(mut self) -> Result<(Greeting, Self::Next), Self::Error> {
@@ -139,22 +140,21 @@ where
             versions: Connecting { remote },
             window: self.window,
             stats: self.stats,
-            marker: PhantomData,
+            deserializer: self.deserializer,
         };
         Ok((greeting, next))
     }
 }
 
-impl CompleteConnect for Handshaking
+impl CompleteConnect for Handshaking
 where
-    B: Backend: Leaf>,
-    T: DeserializeOwned + Send + Sync + 'static,
+    B: Backend: Leaf>,
     R: AsyncRead + Unpin + Send,
     W: AsyncWrite + Unpin + Send,
     C: Connector,
     A: Acceptor,
 {
-    type Next = Connected;
+    type Next = Connected;
 
     /// Send the local server's greeting, then open only if versions differ.
     async fn complete_connect(mut self, theirs: Greeting) -> Result {
@@ -175,20 +175,20 @@ where
             self.versions.remote,
             self.link,
             self.stats,
+            self.deserializer,
         ))
     }
 }
 
-impl Accept for Handshaking
+impl Accept for Handshaking
 where
-    B: Backend: Leaf>,
-    T: DeserializeOwned + Send + Sync + 'static,
+    B: Backend: Leaf>,
     R: AsyncRead + Unpin + Send,
     W: AsyncWrite + Unpin + Send,
     C: Connector,
     A: Acceptor,
 {
-    type Next = Connected;
+    type Next = Connected;
 
     /// Exchange greetings concurrently, then open only if versions differ.
     async fn accept(mut self, request: Greeting) -> Result<(Greeting, Self::Next), Self::Error> {
@@ -212,6 +212,7 @@ where
             remote,
             self.link,
             self.stats,
+            self.deserializer,
         );
         Ok((greeting, next))
     }
@@ -312,7 +313,9 @@ where
 ///
 /// On equality both carried listings are dropped unused — the documented
 /// price of carrying them unconditionally.
-fn connected(
+#[allow(clippy::too_many_arguments)] // The argument list is the handshake's
+// dataflow into the elected session, one premise per argument.
+fn connected(
     backend: B,
     window: Window,
     budget: RunBudget,
@@ -320,10 +323,10 @@ fn connected(
     remote: Greeting,
     link: Link,
     stats: Recorder,
-) -> Connected
+    deserializer: PayloadDeserializer,
+) -> Connected
 where
-    B: Backend: Leaf>,
-    T: DeserializeOwned + Send + Sync + 'static,
+    B: Backend: Leaf>,
     C: Connector,
     A: Acceptor,
 {
@@ -352,6 +355,7 @@ where
         remote.listing,
         link,
         stats,
+        deserializer,
     )
 }
 
@@ -365,7 +369,7 @@ where
 /// version the remote supplies; `peer_set_len` is its declared set
 /// length, which the session charges per supplied record at ingress.
 #[allow(clippy::too_many_arguments)]
-fn open(
+fn open(
     backend: B,
     window: Window,
     budget: RunBudget,
@@ -375,10 +379,10 @@ fn open(
     peer_listing: Vec<(u8, Hash)>,
     link: Link,
     stats: Recorder,
-) -> Connected
+    deserializer: PayloadDeserializer,
+) -> Connected
 where
-    B: Backend: Leaf>,
-    T: DeserializeOwned + Send + Sync + 'static,
+    B: Backend: Leaf>,
     C: Connector,
     A: Acceptor,
 {
@@ -408,6 +412,7 @@ where
             accept,
             errors,
         },
+        deserializer,
     );
     Connected::new(remote, epoch, connector, claims, route, budget, stats, work)
 }
diff --git a/src/tree/mirror/streaming/remote/proxy/state.rs b/src/tree/mirror/streaming/remote/proxy/state.rs
index 04eab96dd..7d51a6601 100644
--- a/src/tree/mirror/streaming/remote/proxy/state.rs
+++ b/src/tree/mirror/streaming/remote/proxy/state.rs
@@ -7,12 +7,13 @@
 //! stage yield its reply before publishing the lower scopes derived from it,
 //! so one-slot backpressure cannot withhold the reply which releases it.
 
+use std::marker::PhantomData;
+
 use crate::link::{Acceptor, Connector};
 use crate::tree::{
     mirror::streaming::{
         Backend, Leaf,
         channel::Receiver,
-        convert::Convert,
         protocol::{self, BoxResponses, Requests},
         remote::{
             adapter::Scope,
@@ -25,12 +26,10 @@ use crate::tree::{
     typed::height::{Height, Root, S, UnderRoot, UnderUnderRoot, Z},
 };
 
-use serde::de::DeserializeOwned;
 /// Session endpoints and backend shared by every state in one proxy chain.
-struct Session
+struct Session
 where
-    B: Backend: Leaf>,
-    T: Send + Sync + 'static,
+    B: Backend: Leaf>,
     A: Acceptor,
 {
     remote: Speaker,
@@ -45,18 +44,17 @@ where
     /// The session's stats recorder, handed to every stream this session
     /// binds so the codec seam's byte counts accumulate in one place.
     stats: Recorder,
-    work: Work,
+    work: Work,
 }
 
-impl Session
+impl Session
 where
-    B: Backend: Leaf>,
-    T: DeserializeOwned + Send + Sync + 'static,
+    B: Backend: Leaf>,
     C: Connector,
     A: Acceptor,
 {
     /// Bind the incoming logical stream spoken by the remote at `height`.
-    fn incoming(&mut self) -> StreamReceiver {
+    fn incoming(&mut self) -> StreamReceiver {
         let stream = stream_at::(self.remote);
         StreamReceiver::new(
             self.claims.take(stream),
@@ -69,7 +67,7 @@ where
     }
 
     /// Bind the outgoing logical stream spoken locally at `height`.
-    fn outgoing(&mut self) -> StreamSender {
+    fn outgoing(&mut self) -> StreamSender {
         let local = self.remote.other();
         StreamSender::new(
             self.connector.clone(),
@@ -87,30 +85,27 @@ fn stream_at(speaker: Speaker) -> Stream {
 }
 
 /// A proxy after the version exchange but before its elected role is known.
-pub struct Connected
+pub struct Connected
 where
-    B: Backend: Leaf>,
-    T: Send + Sync + 'static,
+    B: Backend: Leaf>,
     A: Acceptor,
 {
-    state: ConnectedState,
+    state: ConnectedState,
 }
 
 /// Equal versions need no data streams; divergent versions own a session.
-enum ConnectedState
+enum ConnectedState
 where
-    B: Backend: Leaf>,
-    T: Send + Sync + 'static,
+    B: Backend: Leaf>,
     A: Acceptor,
 {
     Equal(R, W),
-    Diverged(Box>),
+    Diverged(Box>),
 }
 
-impl Connected
+impl Connected
 where
-    B: Backend: Leaf>,
-    T: DeserializeOwned + Send + Sync + 'static,
+    B: Backend: Leaf>,
     C: Connector,
     A: Acceptor,
 {
@@ -124,7 +119,7 @@ where
         route: ErrorRoute,
         budget: RunBudget,
         stats: Recorder,
-        work: Work,
+        work: Work,
     ) -> Self {
         Self {
             state: ConnectedState::Diverged(Box::new(Session {
@@ -148,7 +143,7 @@ where
     }
 
     /// Extract the session guaranteed by the driver's divergent-version path.
-    fn diverged(self) -> Session {
+    fn diverged(self) -> Session {
         match self.state {
             ConnectedState::Diverged(session) => *session,
             ConnectedState::Equal(..) => unreachable!("descent opened for equal versions"),
@@ -156,10 +151,9 @@ where
     }
 }
 
-impl protocol::Protocol for Connected
+impl protocol::Protocol for Connected
 where
-    B: Backend: Leaf>,
-    T: Send + Sync + 'static,
+    B: Backend: Leaf>,
     R: Send,
     W: Send,
     C: Send,
@@ -171,39 +165,42 @@ where
 }
 
 /// A proxy inside the descent with scopes for the next local reply stream.
-pub struct Descending
+pub struct Descending
 where
-    B: Backend: Leaf>,
-    T: Send + Sync + 'static,
+    B: Backend: Leaf>,
     H: Height,
     S: Height,
     A: Acceptor,
 {
-    session: Session,
-    scopes: Receiver>,
+    session: Session,
+    /// The next local reply's scopes, erased: the typestate's `H` is what
+    /// pins this queue to the stage that consumes it at the right height,
+    /// and every scope's parent prefix carries the runtime witness.
+    scopes: Receiver,
     /// The remote initiator's opening-supply stream (`None` below the
     /// stage right after the opening, the one whose scopes are root-level).
     ///
     /// The receiver claims its transport stream on first read, so a
     /// session without early supplies never touches it.
-    early: Option>,
+    early: Option>,
+    /// The stage's height, phantom (`fn() -> H` for the auto-trait
+    /// shortcut; see [`typed::Node`](crate::tree::typed::Node)).
+    height: PhantomData H>,
 }
 
 /// The initiator proxy's leaf terminal and accumulated transport work.
-pub struct Completing
+pub struct Completing
 where
-    B: Backend: Leaf>,
-    T: Send + Sync + 'static,
+    B: Backend: Leaf>,
     A: Acceptor,
 {
-    session: Session,
-    scopes: Receiver>,
+    session: Session,
+    scopes: Receiver,
 }
 
-impl protocol::Protocol for Descending
+impl protocol::Protocol for Descending
 where
-    B: Backend: Leaf>,
-    T: Send + Sync + 'static,
+    B: Backend: Leaf>,
     R: Send,
     W: Send,
     C: Send,
@@ -216,10 +213,9 @@ where
     type Output = (R, W);
 }
 
-impl protocol::Protocol for Completing
+impl protocol::Protocol for Completing
 where
-    B: Backend: Leaf>,
-    T: Send + Sync + 'static,
+    B: Backend: Leaf>,
     R: Send,
     W: Send,
     C: Send,
@@ -230,10 +226,9 @@ where
     type Output = (R, W);
 }
 
-impl protocol::CompleteEqual for Connected
+impl protocol::CompleteEqual for Connected
 where
-    B: Backend: Leaf>,
-    T: DeserializeOwned + Send + Sync + 'static,
+    B: Backend: Leaf>,
     R: Send,
     W: Send,
     C: Connector,
@@ -248,16 +243,15 @@ where
     }
 }
 
-impl protocol::Initiator for Connected
+impl protocol::Initiator for Connected
 where
-    B: Backend: Leaf>,
-    T: DeserializeOwned + Send + Sync + 'static,
+    B: Backend: Leaf>,
     R: Send,
     W: Send,
     C: Connector,
     A: Acceptor,
 {
-    type Next = Descending;
+    type Next = Descending;
 
     /// Replay the remote initiator's opening question from its greeting.
     ///
@@ -266,7 +260,7 @@ where
     /// stream carries the remote's early supplies instead: its receiver is
     /// bound now and handed to the next stage, which reads (and thereby
     /// claims) it only when a root-level request needs an opening supply.
-    fn initiator(self) -> (BoxResponses, Self::Next) {
+    fn initiator(self) -> (BoxResponses, Self::Next) {
         let mut session = self.diverged();
         debug_assert_eq!(session.remote, Speaker::Initiator);
         let early = session.incoming::();
@@ -275,22 +269,21 @@ where
             session,
             scopes,
             early: Some(early),
+            height: PhantomData,
         };
         (responses, next)
     }
 }
 
-impl protocol::Responder for Connected
+impl protocol::Responder for Connected
 where
-    B: Backend: Leaf>,
-    T: DeserializeOwned + Send + Sync + 'static,
+    B: Backend: Leaf>,
     R: Send,
     W: Send,
     C: Connector,
     A: Acceptor,
-    UnderRoot: crate::tree::mirror::streaming::convert::Convert,
 {
-    type Next = Descending;
+    type Next = Descending;
 
     /// Proxy the opening: consume the local question, write the early
     /// supplies, decode the remote's top-level reply.
@@ -303,8 +296,8 @@ where
     /// root children.
     fn responder(
         self,
-        requests: impl Requests,
-    ) -> (BoxResponses, Self::Next) {
+        requests: impl Requests,
+    ) -> (BoxResponses, Self::Next) {
         let mut session = self.diverged();
         debug_assert_eq!(session.remote, Speaker::Responder);
         let incoming = session.incoming::();
@@ -314,31 +307,31 @@ where
             session,
             scopes: next_scopes,
             early: None,
+            height: PhantomData,
         };
         (responses, next)
     }
 }
 
-impl protocol::Reply for Descending>, R, W, C, A>
+impl protocol::Reply for Descending>, R, W, C, A>
 where
-    B: Backend: Leaf>,
-    T: DeserializeOwned + Send + Sync + 'static,
+    B: Backend: Leaf>,
     R: Send,
     W: Send,
     C: Connector,
     A: Acceptor,
     H: Height,
-    S: Convert,
-    S>: Convert,
+    S: Height,
+    S>: Height,
     S>>: Height,
 {
-    type Next = Descending;
+    type Next = Descending;
 
     /// Proxy one ordinary two-height descent transition.
     fn reply(
         mut self,
-        requests: impl Requests>>,
-    ) -> (BoxResponses, Self::Error>, Self::Next) {
+        requests: impl Requests>>,
+    ) -> (BoxResponses, Self::Error>, Self::Next) {
         let incoming = self.session.incoming::>();
         let outgoing = self.session.outgoing::>>();
         let early = self.early.take();
@@ -350,27 +343,27 @@ where
             session: self.session,
             scopes: next_scopes,
             early: None,
+            height: PhantomData,
         };
         (responses, next)
     }
 }
 
-impl protocol::Reply for Descending, R, W, C, A>
+impl protocol::Reply for Descending, R, W, C, A>
 where
-    B: Backend: Leaf>,
-    T: DeserializeOwned + Send + Sync + 'static,
+    B: Backend: Leaf>,
     R: Send,
     W: Send,
     C: Connector,
     A: Acceptor,
 {
-    type Next = Completing;
+    type Next = Completing;
 
     /// Proxy the leaf-parent transition into the role-specific terminal.
     fn reply(
         mut self,
-        requests: impl Requests>,
-    ) -> (BoxResponses, Self::Next) {
+        requests: impl Requests>,
+    ) -> (BoxResponses, Self::Next) {
         debug_assert!(
             self.early.is_none(),
             "the opening-supply stream is consumed by the first descending stage"
@@ -389,10 +382,9 @@ where
     }
 }
 
-impl protocol::CompleteInitiator for Completing
+impl protocol::CompleteInitiator for Completing
 where
-    B: Backend: Leaf>,
-    T: DeserializeOwned + Send + Sync + 'static,
+    B: Backend: Leaf>,
     R: Send,
     W: Send,
     C: Connector,
@@ -401,7 +393,7 @@ where
     /// Encode the local responder's final leaf answers and close its stream.
     async fn complete_initiator(
         mut self,
-        requests: impl Requests,
+        requests: impl Requests,
     ) -> Result<(R, W), Self::Error> {
         debug_assert_eq!(self.session.remote, Speaker::Initiator);
         let outgoing = self.session.outgoing::();
@@ -412,10 +404,9 @@ where
     }
 }
 
-impl protocol::CompleteResponder for Descending
+impl protocol::CompleteResponder for Descending
 where
-    B: Backend: Leaf>,
-    T: DeserializeOwned + Send + Sync + 'static,
+    B: Backend: Leaf>,
     R: Send,
     W: Send,
     C: Connector,
@@ -424,9 +415,9 @@ where
     /// Proxy the final bidirectional leaf exchange to clean completion.
     fn complete_responder(
         mut self,
-        requests: impl Requests,
+        requests: impl Requests,
     ) -> (
-        BoxResponses,
+        BoxResponses,
         impl Future> + Send,
     ) {
         debug_assert_eq!(self.session.remote, Speaker::Responder);
diff --git a/src/tree/mirror/streaming/remote/proxy/tests.rs b/src/tree/mirror/streaming/remote/proxy/tests.rs
index 9c02da448..ec4dc5f0d 100644
--- a/src/tree/mirror/streaming/remote/proxy/tests.rs
+++ b/src/tree/mirror/streaming/remote/proxy/tests.rs
@@ -1,5 +1,6 @@
 //! End-to-end sessions between materialized peers and protocol-start proxies.
 
+use serde::de::DeserializeOwned;
 use std::convert::Infallible;
 use std::sync::Arc;
 use std::sync::atomic::{AtomicUsize, Ordering};
@@ -32,7 +33,6 @@ use crate::tree::{
 };
 use crate::{Version, message::Message, tree::mirror::Error as MirrorError};
 
-use serde::de::DeserializeOwned;
 type BackendFailure = Failure;
 type LocalFailure = MaterializedError;
 type ProxyFailure = RemoteError;
@@ -51,13 +51,15 @@ mod transport;
 const TRANSPORT_CAPACITY: usize = 37;
 
 /// Drive two local starts, each paired directly with its remote protocol start.
-async fn reconcile(a: TreeRoot<()>, b: TreeRoot<()>) -> (TreeRoot<()>, TreeRoot<()>) {
-    let a = Handshaking::start(Local, Root::from(a)).window(WindowConfig::FLOOR);
-    let b = Handshaking::start(Local, Root::from(b)).window(WindowConfig::FLOOR);
+async fn reconcile(a: TreeRoot, b: TreeRoot) -> (TreeRoot, TreeRoot) {
+    let a = Handshaking::start(Local, Root::::from(a)).window(WindowConfig::FLOOR);
+    let b = Handshaking::start(Local, Root::::from(b)).window(WindowConfig::FLOOR);
 
     let (a_link, b_link) = memory_with_capacity(TRANSPORT_CAPACITY);
-    let remote_b = RemoteHandshaking::start(Local, a_link).window(WindowConfig::FLOOR);
-    let remote_a = RemoteHandshaking::start(Local, b_link).window(WindowConfig::FLOOR);
+    let remote_b = RemoteHandshaking::start(Local, a_link, Message::deserializer::<()>())
+        .window(WindowConfig::FLOOR);
+    let remote_a = RemoteHandshaking::start(Local, b_link, Message::deserializer::<()>())
+        .window(WindowConfig::FLOOR);
 
     let (a, b) = join!(Box::pin(mirror(a, remote_b)), Box::pin(mirror(remote_a, b)));
     let (a, _control) = a.expect("endpoint A should reconcile through its proxy");
@@ -68,18 +70,20 @@ async fn reconcile(a: TreeRoot<()>, b: TreeRoot<()>) -> (TreeRoot<()>, TreeRoot<
 /// Drive the production topology: each materialized local is the client of
 /// its own proxy, so both physical endpoints execute `Accept` concurrently.
 async fn reconcile_symmetric_accepts(
-    a: TreeRoot,
-    b: TreeRoot,
+    a: TreeRoot,
+    b: TreeRoot,
     transport_capacity: usize,
-) -> (TreeRoot, TreeRoot)
+) -> (TreeRoot, TreeRoot)
 where
     T: DeserializeOwned + Send + Sync + 'static,
 {
-    let a = Handshaking::start(Local, Root::from(a)).window(WindowConfig::FLOOR);
-    let b = Handshaking::start(Local, Root::from(b)).window(WindowConfig::FLOOR);
+    let a = Handshaking::start(Local, Root::::from(a)).window(WindowConfig::FLOOR);
+    let b = Handshaking::start(Local, Root::::from(b)).window(WindowConfig::FLOOR);
     let (a_link, b_link) = memory_with_capacity(transport_capacity);
-    let remote_b = RemoteHandshaking::start(Local, a_link).window(WindowConfig::FLOOR);
-    let remote_a = RemoteHandshaking::start(Local, b_link).window(WindowConfig::FLOOR);
+    let remote_b = RemoteHandshaking::start(Local, a_link, Message::deserializer::())
+        .window(WindowConfig::FLOOR);
+    let remote_a = RemoteHandshaking::start(Local, b_link, Message::deserializer::())
+        .window(WindowConfig::FLOOR);
 
     let (a, b) = join!(Box::pin(mirror(a, remote_b)), Box::pin(mirror(b, remote_a)),);
     let (a, _control) = a.expect("endpoint A should reconcile through its proxy");
@@ -98,21 +102,23 @@ const REORDER_BATCH: usize = 3;
 /// `reordered` counts the genuine inversions both ends release; the caller
 /// asserts its disposition across the run.
 async fn reconcile_symmetric_accepts_reordered(
-    a: TreeRoot,
-    b: TreeRoot,
+    a: TreeRoot,
+    b: TreeRoot,
     transport_capacity: usize,
     reordered: Arc,
-) -> (TreeRoot, TreeRoot)
+) -> (TreeRoot, TreeRoot)
 where
     T: DeserializeOwned + Send + Sync + 'static,
 {
-    let a = Handshaking::start(Local, Root::from(a)).window(WindowConfig::FLOOR);
-    let b = Handshaking::start(Local, Root::from(b)).window(WindowConfig::FLOOR);
+    let a = Handshaking::start(Local, Root::::from(a)).window(WindowConfig::FLOOR);
+    let b = Handshaking::start(Local, Root::::from(b)).window(WindowConfig::FLOOR);
     let (a_link, b_link) = memory_with_capacity(transport_capacity);
     let a_link = reorder_accepts(a_link, REORDER_BATCH, reordered.clone());
     let b_link = reorder_accepts(b_link, REORDER_BATCH, reordered);
-    let remote_b = RemoteHandshaking::start(Local, a_link).window(WindowConfig::FLOOR);
-    let remote_a = RemoteHandshaking::start(Local, b_link).window(WindowConfig::FLOOR);
+    let remote_b = RemoteHandshaking::start(Local, a_link, Message::deserializer::())
+        .window(WindowConfig::FLOOR);
+    let remote_a = RemoteHandshaking::start(Local, b_link, Message::deserializer::())
+        .window(WindowConfig::FLOOR);
 
     let (a, b) = join!(Box::pin(mirror(a, remote_b)), Box::pin(mirror(b, remote_a)),);
     let (a, _control) = a.expect("endpoint A should reconcile through its proxy");
@@ -122,12 +128,12 @@ where
 
 /// Drive the production proxy topology after the shared preamble on the same
 /// transport halves, proving that neither phase consumes the other's bytes.
-async fn reconcile_after_preamble(a: TreeRoot, b: TreeRoot) -> (TreeRoot, TreeRoot)
+async fn reconcile_after_preamble(a: TreeRoot, b: TreeRoot) -> (TreeRoot, TreeRoot)
 where
     T: DeserializeOwned + Send + Sync + 'static,
 {
-    let a = Handshaking::start(Local, Root::from(a)).window(WindowConfig::FLOOR);
-    let b = Handshaking::start(Local, Root::from(b)).window(WindowConfig::FLOOR);
+    let a = Handshaking::start(Local, Root::::from(a)).window(WindowConfig::FLOOR);
+    let b = Handshaking::start(Local, Root::::from(b)).window(WindowConfig::FLOOR);
     let (mut a_link, mut b_link) = memory_with_capacity(64 * 1024);
     let network = crate::Network::from_bytes([1; 16]);
     let mut a_staged = handshake::Staged::new();
@@ -153,8 +159,10 @@ where
     seen_a.expect("A preamble");
     seen_b.expect("B preamble");
 
-    let remote_b = RemoteHandshaking::start(Local, a_link).window(WindowConfig::FLOOR);
-    let remote_a = RemoteHandshaking::start(Local, b_link).window(WindowConfig::FLOOR);
+    let remote_b = RemoteHandshaking::start(Local, a_link, Message::deserializer::())
+        .window(WindowConfig::FLOOR);
+    let remote_a = RemoteHandshaking::start(Local, b_link, Message::deserializer::())
+        .window(WindowConfig::FLOOR);
     let (a, b) = join!(Box::pin(mirror(a, remote_b)), Box::pin(mirror(b, remote_a)),);
     let (a, _control) = a.expect("endpoint A should reconcile through its proxy");
     let (b, _control) = b.expect("endpoint B should reconcile through its proxy");
@@ -162,9 +170,9 @@ where
 }
 
 /// Reconcile the same pair entirely in process as the behavioral oracle.
-async fn reconcile_locally(a: TreeRoot<()>, b: TreeRoot<()>) -> (TreeRoot<()>, TreeRoot<()>) {
-    let a = Handshaking::start(Local, Root::from(a)).window(WindowConfig::FLOOR);
-    let b = Handshaking::start(Local, Root::from(b)).window(WindowConfig::FLOOR);
+async fn reconcile_locally(a: TreeRoot, b: TreeRoot) -> (TreeRoot, TreeRoot) {
+    let a = Handshaking::start(Local, Root::::from(a)).window(WindowConfig::FLOOR);
+    let b = Handshaking::start(Local, Root::::from(b)).window(WindowConfig::FLOOR);
     let (a, b) = Box::pin(mirror(a, b))
         .await
         .expect("two honest local participants should reconcile");
@@ -172,7 +180,7 @@ async fn reconcile_locally(a: TreeRoot<()>, b: TreeRoot<()>) -> (TreeRoot<()>, T
 }
 
 /// Translate a local root into the composable failing backend's node type.
-fn failing_root(root: TreeRoot<()>) -> Root, ()> {
+fn failing_root(root: TreeRoot) -> Root> {
     Root {
         ceiling: root.ceiling,
         root: root.root.map(FailingNode::new),
@@ -181,8 +189,8 @@ fn failing_root(root: TreeRoot<()>) -> Root, ()> {
 
 /// Reconcile with exactly one proxy using the supplied failing backend.
 async fn reconcile_with_failing_proxy(
-    a: TreeRoot<()>,
-    b: TreeRoot<()>,
+    a: TreeRoot,
+    b: TreeRoot,
     failing: Failing,
     fail_left: bool,
 ) -> (Result<(), LeftFailure>, Result<(), RightFailure>) {
@@ -193,8 +201,8 @@ async fn reconcile_with_failing_proxy(
 
 /// Reconcile with independently stackable backend and transport failures.
 async fn reconcile_with_stacked_failures(
-    a: TreeRoot<()>,
-    b: TreeRoot<()>,
+    a: TreeRoot,
+    b: TreeRoot,
     failing: Failing,
     fail_left: bool,
     io_plan: IoPlan,
@@ -236,8 +244,10 @@ async fn reconcile_with_stacked_failures(
     } else {
         failing
     };
-    let remote_b = RemoteHandshaking::start(left_backend, a_link).window(WindowConfig::FLOOR);
-    let remote_a = RemoteHandshaking::start(right_backend, b_link).window(WindowConfig::FLOOR);
+    let remote_b = RemoteHandshaking::start(left_backend, a_link, Message::deserializer::<()>())
+        .window(WindowConfig::FLOOR);
+    let remote_a = RemoteHandshaking::start(right_backend, b_link, Message::deserializer::<()>())
+        .window(WindowConfig::FLOOR);
 
     let (left, right) = join!(Box::pin(mirror(a, remote_b)), Box::pin(mirror(remote_a, b)));
     (
@@ -274,7 +284,7 @@ async fn equal_versions_return_both_roots() {
 /// Concurrent version-addressed leaves cross every proxy layer and converge.
 #[pollster::test]
 async fn divergent_leaves_converge() {
-    let mut a = Tree::new();
+    let mut a = Tree::<()>::new();
     a.act(&nth_party(0), [Action::Insert(Message::new(()))]);
     let mut b = Tree::new();
     b.act(&nth_party(1), [Action::Insert(Message::new(()))]);
@@ -290,12 +300,12 @@ async fn divergent_leaves_converge() {
 /// live under deterministic closed-world polling.
 #[test]
 fn symmetric_accept_handshakes_are_live() {
-    let mut a = Tree::new();
+    let mut a = Tree::<()>::new();
     a.act(&nth_party(0), [Action::Insert(Message::new(()))]);
-    let mut b = Tree::new();
+    let mut b = Tree::<()>::new();
     b.act(&nth_party(1), [Action::Insert(Message::new(()))]);
 
-    let (a, b) = run_to_quiescence(reconcile_symmetric_accepts(a.root, b.root, 1))
+    let (a, b) = run_to_quiescence(reconcile_symmetric_accepts::<()>(a.root, b.root, 1))
         .expect("the production proxy topology became quiescent");
     assert_eq!(a, b);
 }
@@ -306,12 +316,12 @@ fn symmetric_accept_handshakes_are_live() {
 fn symmetric_accepts_with_distinct_payloads_are_live() {
     let mut a_party = before::Party::seed();
     let b_party = a_party.fork();
-    let mut a = Tree::new();
+    let mut a = Tree::::new();
     a.act(&a_party, [Action::Insert(Message::new(1_u64))]);
-    let mut b = Tree::new();
+    let mut b = Tree::::new();
     b.act(&b_party, [Action::Insert(Message::new(2_u64))]);
 
-    let (a, b) = run_to_quiescence(reconcile_after_preamble(a.root, b.root))
+    let (a, b) = run_to_quiescence(reconcile_after_preamble::(a.root, b.root))
         .expect("distinct-payload proxy topology became quiescent");
     assert_eq!(a, b);
 }
@@ -324,7 +334,7 @@ proptest! {
     fn symmetric_accepts_match_local((a, b) in arb_divergent_pair()) {
         let expected = run_to_quiescence(reconcile_locally(a.clone(), b.clone()))
             .expect("local reconciliation should remain live");
-        let actual = run_to_quiescence(reconcile_symmetric_accepts(a, b, TRANSPORT_CAPACITY))
+        let actual = run_to_quiescence(reconcile_symmetric_accepts::<()>(a, b, TRANSPORT_CAPACITY))
             .map_err(|stopped| TestCaseError::fail(format!(
                 "symmetric proxy reconciliation became quiescent: {stopped:?}",
             )))?;
@@ -381,7 +391,7 @@ proptest! {
     fn wide_symmetric_accepts_match_local((a, b) in arb_wide_divergent_pair()) {
         let expected = run_to_quiescence(reconcile_locally(a.clone(), b.clone()))
             .expect("local reconciliation should remain live");
-        let actual = run_to_quiescence(reconcile_symmetric_accepts(a, b, 1))
+        let actual = run_to_quiescence(reconcile_symmetric_accepts::<()>(a, b, 1))
             .map_err(|stopped| TestCaseError::fail(format!(
                 "wide symmetric proxy reconciliation became quiescent: {stopped:?}",
             )))?;
@@ -485,7 +495,7 @@ fn wide_symmetric_accepts_reordered_match_local() {
     let cases = runner.run(&arb_wide_divergent_pair(), move |(a, b)| {
         let expected = run_to_quiescence(reconcile_locally(a.clone(), b.clone()))
             .expect("local reconciliation should remain live");
-        let actual = run_to_quiescence(reconcile_symmetric_accepts_reordered(
+        let actual = run_to_quiescence(reconcile_symmetric_accepts_reordered::<()>(
             a,
             b,
             1,
@@ -527,17 +537,18 @@ fn early_first_child_dispute_is_live() {
     let (a, b) = early_first_child_dispute_pair();
     let expected = run_to_quiescence(reconcile_locally(a.clone(), b.clone()))
         .expect("local reconciliation of the trigger geometry remains live");
-    let (left, right) = run_to_quiescence(reconcile_symmetric_accepts(a, b, TRANSPORT_CAPACITY))
-        .expect("the trigger geometry must reconcile over the wire");
+    let (left, right) =
+        run_to_quiescence(reconcile_symmetric_accepts::<()>(a, b, TRANSPORT_CAPACITY))
+            .expect("the trigger geometry must reconcile over the wire");
     assert_eq!((left, right), expected);
 }
 
 /// Every proxy queue kind is exercised and remains within its one-slot bound.
 #[test]
 fn instrumented_channels_cover_every_proxy_edge() {
-    let mut a = Tree::new();
+    let mut a = Tree::<()>::new();
     a.act(&nth_party(0), [Action::Insert(Message::new(()))]);
-    let mut b = Tree::new();
+    let mut b = Tree::<()>::new();
     b.act(&nth_party(1), [Action::Insert(Message::new(()))]);
     let (result, report, trace) = instrumented_reconcile(a.root, b.root, Vec::new());
     result.expect("the instrumented wire session should remain live");
@@ -553,11 +564,11 @@ fn instrumented_channels_cover_every_proxy_edge() {
 
 /// Reconcile once under channel scheduling while collecting both instruments.
 fn instrumented_reconcile(
-    a: TreeRoot<()>,
-    b: TreeRoot<()>,
+    a: TreeRoot,
+    b: TreeRoot,
     schedule: Vec,
 ) -> (
-    Result<(TreeRoot<()>, TreeRoot<()>), Quiescence>,
+    Result<(TreeRoot, TreeRoot), Quiescence>,
     ChannelReport,
     Trace,
 ) {
diff --git a/src/tree/mirror/streaming/remote/proxy/tests/containment.rs b/src/tree/mirror/streaming/remote/proxy/tests/containment.rs
index 6de686857..3cce0da4c 100644
--- a/src/tree/mirror/streaming/remote/proxy/tests/containment.rs
+++ b/src/tree/mirror/streaming/remote/proxy/tests/containment.rs
@@ -1,5 +1,6 @@
 //! Version-containment enforcement over the full wire stack.
 
+use crate::message::Message;
 use futures::join;
 
 use crate::link::memory_with_capacity;
@@ -25,18 +26,17 @@ use super::harness::{LeftError, RightError};
 /// Drive the two-proxy topology, returning each endpoint's result instead
 /// of asserting success.
 async fn reconcile_results(
-    a: TreeRoot<()>,
-    b: TreeRoot<()>,
-) -> (
-    Result, LeftError>,
-    Result, RightError>,
-) {
-    let a = Handshaking::start(Local, Root::from(a)).window(WindowConfig::FLOOR);
-    let b = Handshaking::start(Local, Root::from(b)).window(WindowConfig::FLOOR);
+    a: TreeRoot,
+    b: TreeRoot,
+) -> (Result, Result) {
+    let a = Handshaking::start(Local, Root::::from(a)).window(WindowConfig::FLOOR);
+    let b = Handshaking::start(Local, Root::::from(b)).window(WindowConfig::FLOOR);
 
     let (a_link, b_link) = memory_with_capacity(TRANSPORT_CAPACITY);
-    let remote_b = RemoteHandshaking::start(Local, a_link).window(WindowConfig::FLOOR);
-    let remote_a = RemoteHandshaking::start(Local, b_link).window(WindowConfig::FLOOR);
+    let remote_b = RemoteHandshaking::start(Local, a_link, Message::deserializer::<()>())
+        .window(WindowConfig::FLOOR);
+    let remote_a = RemoteHandshaking::start(Local, b_link, Message::deserializer::<()>())
+        .window(WindowConfig::FLOOR);
 
     let (a, b) = join!(Box::pin(mirror(a, remote_b)), Box::pin(mirror(remote_a, b)));
     (
diff --git a/src/tree/mirror/streaming/remote/proxy/tests/declarations.rs b/src/tree/mirror/streaming/remote/proxy/tests/declarations.rs
index f37297bfb..2b8e3fec5 100644
--- a/src/tree/mirror/streaming/remote/proxy/tests/declarations.rs
+++ b/src/tree/mirror/streaming/remote/proxy/tests/declarations.rs
@@ -31,24 +31,24 @@ use crate::tree::{
 use super::harness::{self, GreetingRewrite};
 
 /// The observable root hash of a reconciled `tree::Root`.
-fn hash_of(root: &crate::tree::Root<()>) -> [u8; MERKLE_HASH_LEN] {
-    Tree { root: root.clone() }.hash()
+fn hash_of(root: &crate::tree::Root) -> [u8; MERKLE_HASH_LEN] {
+    Tree::<()>::from_root(root.clone()).hash()
 }
 
 /// The expected reconciled union, computed by the in-memory join oracle.
-fn union_hash(a: &crate::tree::Root<()>, b: &crate::tree::Root<()>) -> [u8; MERKLE_HASH_LEN] {
-    let mut union = Tree { root: a.clone() };
-    union.join(Tree { root: b.clone() });
+fn union_hash(a: &crate::tree::Root, b: &crate::tree::Root) -> [u8; MERKLE_HASH_LEN] {
+    let mut union = Tree::<()>::from_root(a.clone());
+    union.join(Tree::from_root(b.clone()));
     union.hash()
 }
 
 /// A divergent pair whose live set sizes differ strictly: one message
 /// against four, on distinct parties, so the smaller side wins the
 /// initiator election under honest declarations.
-fn uneven_pair() -> (crate::tree::Root<()>, crate::tree::Root<()>) {
-    let mut small = Tree::new();
+fn uneven_pair() -> (crate::tree::Root, crate::tree::Root) {
+    let mut small = Tree::<()>::new();
     small.act(&nth_party(1), [Action::Insert(Message::new(()))]);
-    let mut large = Tree::new();
+    let mut large = Tree::<()>::new();
     large.act(
         &nth_party(0),
         (0..4).map(|_| Action::Insert(Message::new(()))),
@@ -68,10 +68,10 @@ const BULK_MESSAGES: usize = FAN + 1;
 /// exclusive root children — at least one of which spans multiple leaves
 /// — reach it as whole supplied subtrees, so the traffic toward the small
 /// side includes a genuinely batched multi-record run.
-fn batched_uneven_pair() -> (crate::tree::Root<()>, crate::tree::Root<()>) {
-    let mut small = Tree::new();
+fn batched_uneven_pair() -> (crate::tree::Root, crate::tree::Root) {
+    let mut small = Tree::<()>::new();
     small.act(&nth_party(1), [Action::Insert(Message::new(()))]);
-    let mut large = Tree::new();
+    let mut large = Tree::<()>::new();
     large.act(
         &nth_party(0),
         (0..BULK_MESSAGES).map(|_| Action::Insert(Message::new(()))),
@@ -272,13 +272,13 @@ fn understated_set_len_fails_the_session() {
 /// A divergent pair of four messages against eight, on distinct parties:
 /// the four-message side wins the initiator election, and its whole
 /// exclusive content rides the opening-supply stream as one reply.
-fn opening_bulk_pair() -> (crate::tree::Root<()>, crate::tree::Root<()>) {
-    let mut small = Tree::new();
+fn opening_bulk_pair() -> (crate::tree::Root, crate::tree::Root) {
+    let mut small = Tree::<()>::new();
     small.act(
         &nth_party(1),
         (0..4).map(|_| Action::Insert(Message::new(()))),
     );
-    let mut large = Tree::new();
+    let mut large = Tree::<()>::new();
     large.act(
         &nth_party(0),
         (0..8).map(|_| Action::Insert(Message::new(()))),
diff --git a/src/tree/mirror/streaming/remote/proxy/tests/failures.rs b/src/tree/mirror/streaming/remote/proxy/tests/failures.rs
index 8ee6a36e1..7a5a7550c 100644
--- a/src/tree/mirror/streaming/remote/proxy/tests/failures.rs
+++ b/src/tree/mirror/streaming/remote/proxy/tests/failures.rs
@@ -53,13 +53,13 @@ fn injected(error: &RemoteError) -> Option {
 }
 
 /// A deterministic pair whose proxy backends perform real conversion work.
-fn stacked_pair() -> (crate::tree::Root<()>, crate::tree::Root<()>) {
-    let mut left = Tree::new();
+fn stacked_pair() -> (crate::tree::Root, crate::tree::Root) {
+    let mut left = Tree::<()>::new();
     left.act(
         &nth_party(0),
         (0..8).map(|_| Action::Insert(Message::new(()))),
     );
-    let mut right = Tree::new();
+    let mut right = Tree::<()>::new();
     right.act(
         &nth_party(1),
         (0..8).map(|_| Action::Insert(Message::new(()))),
diff --git a/src/tree/mirror/streaming/remote/proxy/tests/greeting.rs b/src/tree/mirror/streaming/remote/proxy/tests/greeting.rs
index 62bca8ac9..4ddaf39d1 100644
--- a/src/tree/mirror/streaming/remote/proxy/tests/greeting.rs
+++ b/src/tree/mirror/streaming/remote/proxy/tests/greeting.rs
@@ -18,16 +18,16 @@ use crate::tree::{
 use super::harness;
 
 /// The observable root hash of a reconciled `tree::Root`.
-fn hash_of(root: &crate::tree::Root<()>) -> [u8; MERKLE_HASH_LEN] {
-    Tree { root: root.clone() }.hash()
+fn hash_of(root: &crate::tree::Root) -> [u8; MERKLE_HASH_LEN] {
+    Tree::<()>::from_root(root.clone()).hash()
 }
 
 /// Reconcile through the two-proxy wire harness, requiring both sides to
 /// succeed, and return `(left, right)` reconciled roots.
 fn wire_reconcile(
-    left: crate::tree::Root<()>,
-    right: crate::tree::Root<()>,
-) -> (crate::tree::Root<()>, crate::tree::Root<()>) {
+    left: crate::tree::Root,
+    right: crate::tree::Root,
+) -> (crate::tree::Root, crate::tree::Root) {
     let outcome = run_to_quiescence(harness::reconcile(
         left,
         right,
@@ -44,9 +44,9 @@ fn wire_reconcile(
 
 /// The deep divergent pair's expected union, computed by the in-memory join
 /// oracle.
-fn union_hash(a: &crate::tree::Root<()>, b: &crate::tree::Root<()>) -> [u8; MERKLE_HASH_LEN] {
-    let mut union = Tree { root: a.clone() };
-    union.join(Tree { root: b.clone() });
+fn union_hash(a: &crate::tree::Root, b: &crate::tree::Root) -> [u8; MERKLE_HASH_LEN] {
+    let mut union = Tree::<()>::from_root(a.clone());
+    union.join(Tree::from_root(b.clone()));
     union.hash()
 }
 
@@ -82,15 +82,15 @@ fn carried_listing_converges_with_right_initiator() {
 /// will elect initiator (the smaller live set; ties fall back to the greater
 /// causal version in canonical bytes).
 fn order_by_election(
-    a: crate::tree::Root<()>,
-    b: crate::tree::Root<()>,
-) -> (crate::tree::Root<()>, crate::tree::Root<()>) {
+    a: crate::tree::Root,
+    b: crate::tree::Root,
+) -> (crate::tree::Root, crate::tree::Root) {
     assert_ne!(
         a.ceiling.as_bytes(),
         b.ceiling.as_bytes(),
         "a divergent fixture must elect deterministically"
     );
-    let len = |root: &crate::tree::Root<()>| {
+    let len = |root: &crate::tree::Root| {
         root.root
             .as_ref()
             .map(|node| node.len() as u64)
@@ -114,7 +114,7 @@ fn order_by_election(
 #[test]
 fn empty_carried_listing_asks_for_everything() {
     // The populated responder: one message on party 0.
-    let mut populated = Tree::new();
+    let mut populated = Tree::<()>::new();
     populated.act(&nth_party(0), [Action::Insert(Message::new(()))]);
 
     // The emptied initiator: insert-then-forget on party 1 ticks its version
@@ -150,7 +150,7 @@ fn empty_carried_listing_asks_for_everything() {
 #[test]
 fn converged_session_carries_listings_unused() {
     let build = || {
-        let mut tree = Tree::new();
+        let mut tree = Tree::<()>::new();
         tree.act(&nth_party(0), [Action::Insert(Message::new(()))]);
         tree
     };
@@ -187,7 +187,7 @@ fn converged_session_carries_listings_unused() {
 #[test]
 fn mixed_empty_and_populated_converges() {
     let empty = Tree::<()>::new();
-    let mut populated = Tree::new();
+    let mut populated = Tree::<()>::new();
     populated.act(
         &nth_party(0),
         (0..4).map(|_| Action::Insert(Message::new(()))),
@@ -238,7 +238,7 @@ fn overlapping_sessions_lose_innocent_leaf_after_honored_redaction() {
     // redaction empties its root radix — and sweeping the redacted leaf
     // puts shared runs on both sides of every divergence point.
     let p = nth_party(0);
-    let mut t0 = Tree::new();
+    let mut t0 = Tree::<()>::new();
     t0.act(&p, (0..25).map(|_| Action::Insert(Message::new(()))));
     let leaves: Vec<_> = t0
         .iter()
@@ -254,9 +254,7 @@ fn overlapping_sessions_lose_innocent_leaf_after_honored_redaction() {
         // S1's counterparty: converged at T0, then redacted the leaf at
         // `k` (a local act rebuilds its own fans afresh; the sharing that
         // matters is created by our install below, not here).
-        let mut twin = Tree {
-            root: t0.root.clone(),
-        };
+        let mut twin = Tree::<()>::from_root(t0.root.clone());
         twin.act(&nth_party(1), [Action::Forget(*k)]);
 
         // S1's session and install: reconcile T0 against the redacting
@@ -264,19 +262,13 @@ fn overlapping_sessions_lose_innocent_leaf_after_honored_redaction() {
         // Deletion honoring drops radix `r_h`: the live tree's root fan is
         // now a clone-derived sibling of M0 missing one radix.
         let (s1_reconciled, _) = wire_reconcile(t0.root.clone(), twin.root.clone());
-        let mut live = Tree {
-            root: t0.root.clone(),
-        };
-        live.join(Tree {
-            root: s1_reconciled,
-        });
+        let mut live = Tree::<()>::from_root(t0.root.clone());
+        live.join(Tree::from_root(s1_reconciled));
         let expected = live.hash();
 
         // S2's install, after S1's: joining our own causal past must be an
         // identity on the tree.
-        live.join(Tree {
-            root: s2_reconciled.clone(),
-        });
+        live.join(Tree::from_root(s2_reconciled.clone()));
 
         if live.hash() != expected {
             let missing: Vec<_> = leaves
diff --git a/src/tree/mirror/streaming/remote/proxy/tests/harness.rs b/src/tree/mirror/streaming/remote/proxy/tests/harness.rs
index 411a0cb1b..b77a3a611 100644
--- a/src/tree/mirror/streaming/remote/proxy/tests/harness.rs
+++ b/src/tree/mirror/streaming/remote/proxy/tests/harness.rs
@@ -1,5 +1,6 @@
 //! Reusable two-proxy session harness for transport-adversity properties.
 
+use crate::message::Message;
 use std::{
     convert::Infallible,
     io,
@@ -53,9 +54,9 @@ pub type RightError = MirrorError, MaterializedError, LeftError>,
+    pub left: Result,
     /// The second materialized tree, or its session failure.
-    pub right: Result, RightError>,
+    pub right: Result,
     /// I/O performed by the first proxy endpoint.
     pub left_io: IoReportHandle,
     /// I/O performed by the second proxy endpoint.
@@ -344,8 +345,8 @@ impl AsyncRead for RewriteRead {
 
 /// Reconcile one pair through two proxies over independently wrapped links.
 pub async fn reconcile(
-    left: TreeRoot<()>,
-    right: TreeRoot<()>,
+    left: TreeRoot,
+    right: TreeRoot,
     capacity: usize,
     left_plan: IoPlan,
     right_plan: IoPlan,
@@ -369,14 +370,11 @@ pub async fn reconcile(
 /// Runs under the default budget-derived window so the rewritten
 /// declarations flow into the live window solve, not a fixed test floor.
 pub async fn reconcile_rewritten_greetings(
-    left: TreeRoot<()>,
-    right: TreeRoot<()>,
+    left: TreeRoot,
+    right: TreeRoot,
     left_hears: Option,
     right_hears: Option,
-) -> (
-    Result, LeftError>,
-    Result, RightError>,
-) {
+) -> (Result, Result) {
     let (left_link, right_link) = memory_with_capacity(TRANSPORT_CAPACITY);
     drive(
         left,
@@ -418,8 +416,8 @@ fn rewritten(
 /// initiates is a function of live counts and canonical version bytes,
 /// both of which move whenever the wire coding or a fixture's content
 /// addresses do.
-pub fn left_initiates(left: &TreeRoot<()>, right: &TreeRoot<()>) -> bool {
-    let len = |root: &TreeRoot<()>| {
+pub fn left_initiates(left: &TreeRoot, right: &TreeRoot) -> bool {
+    let len = |root: &TreeRoot| {
         root.root
             .as_ref()
             .map(|node| node.len() as u64)
@@ -435,14 +433,11 @@ pub fn left_initiates(left: &TreeRoot<()>, right: &TreeRoot<()>) -> bool {
 
 /// Reconcile while mutating at most one data-stream frame on each side.
 pub async fn reconcile_scripted(
-    left: TreeRoot<()>,
-    right: TreeRoot<()>,
+    left: TreeRoot,
+    right: TreeRoot,
     left_script: Option