From e5392c7cf5c19b645e0870d495f037642fc3a7f2 Mon Sep 17 00:00:00 2001 From: finch Date: Wed, 19 Aug 2026 15:55:08 -0400 Subject: [PATCH 01/18] streaming: add the height-erasure seam on Backend One runtime representation per backend (Backend::Erased) with paired erase/assume conversions, plus the ErasedNode trait carrying the height-independent observations (span, hash, len). Local's conversions are the phantom wrap/unwrap the typed node already is; the Failing, Charged, and Materializing test backends pass erasure through their wrappers (Charged settles and reopens its ledger entry, so the census peak is untouched). Pure addition: no caller changes, no behavior change. The seam exists for the erased session plumbing (design/height-erasure.md step 1); channels and workers move onto it in subsequent commits. Baseline measured at the parent commit for attribution: cargo llvm-lines --test pairwise (debug, default features) = 2,884,887 lines / 109,613 copies. --- src/conformance/backend.rs | 31 +++++++++++- src/conformance/backend/tests.rs | 38 +++++++++++++- src/tree/mirror/streaming.rs | 2 +- src/tree/mirror/streaming/backend.rs | 52 ++++++++++++++++++++ src/tree/mirror/streaming/backend/local.rs | 29 ++++++++++- src/tree/mirror/streaming/testing/failing.rs | 28 ++++++++++- src/tree/typed/node.rs | 12 ++++- 7 files changed, 185 insertions(+), 7 deletions(-) diff --git a/src/conformance/backend.rs b/src/conformance/backend.rs index 6d8390b26..8dae0603e 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}, @@ -222,6 +222,20 @@ where } } +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, @@ -269,8 +283,23 @@ where T: Send + Sync + 'static, { 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) } diff --git a/src/conformance/backend/tests.rs b/src/conformance/backend/tests.rs index 758069884..a14550d62 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, @@ -266,13 +266,49 @@ fn bounds_of(node: &typed::Node) -> u node.ceiling().as_bytes().len() + node.floor().as_bytes().len() } +// 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 where T: Send + Sync + 'static, { 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::>>() + PRICED_HEADER.get() diff --git a/src/tree/mirror/streaming.rs b/src/tree/mirror/streaming.rs index e5e389346..f40952555 100644 --- a/src/tree/mirror/streaming.rs +++ b/src/tree/mirror/streaming.rs @@ -55,7 +55,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)] diff --git a/src/tree/mirror/streaming/backend.rs b/src/tree/mirror/streaming/backend.rs index 71c69db75..d133b0d45 100644 --- a/src/tree/mirror/streaming/backend.rs +++ b/src/tree/mirror/streaming/backend.rs @@ -50,9 +50,42 @@ where /// The type of nodes carrying messages of type `T`, indexed by height `H`. 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. @@ -250,6 +283,25 @@ 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 { diff --git a/src/tree/mirror/streaming/backend/local.rs b/src/tree/mirror/streaming/backend/local.rs index e0bec2975..557952fba 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, }, @@ -56,6 +56,22 @@ impl Node for typed::Node { } } +// 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() @@ -96,8 +112,19 @@ const _: () = 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) } diff --git a/src/tree/mirror/streaming/testing/failing.rs b/src/tree/mirror/streaming/testing/failing.rs index 654eb60b7..1ab69ff99 100644 --- a/src/tree/mirror/streaming/testing/failing.rs +++ b/src/tree/mirror/streaming/testing/failing.rs @@ -15,7 +15,7 @@ use crate::{ Version, message::Message, tree::{ - mirror::streaming::{Backend, Leaf, Node, backend::NodeStream}, + mirror::streaming::{Backend, ErasedNode, Leaf, Node, backend::NodeStream}, typed::{ Hash, Path, Prefix, height::{Height, S, Z}, @@ -161,6 +161,20 @@ where } } +impl ErasedNode for FailingNode { + fn span(&self) -> Span<'_> { + self.0.span() + } + + fn hash(&self) -> Hash { + self.0.hash() + } + + fn len(&self) -> usize { + self.0.len() + } +} + impl Leaf for FailingNode where T: Send + Sync + 'static, @@ -189,7 +203,19 @@ where T: Send + Sync + 'static, { type Node = FailingNode>; + // Erasure passes through the wrapper: fault injection targets the + // traversal operations, and re-tagging is not one. + type Erased = FailingNode; type Error = Failure; + + fn erase(node: Self::Node) -> Self::Erased { + FailingNode(B::erase(node.0)) + } + + fn assume(erased: Self::Erased) -> Self::Node { + FailingNode(B::assume(erased.0)) + } + // The wrapper adds no resident state: a failing node is the inner // backend's node plus fault bookkeeping shared behind it. fn node_bytes(children: usize, version_bound: usize) -> usize { diff --git a/src/tree/typed/node.rs b/src/tree/typed/node.rs index 81e0eff3e..d76d88a51 100644 --- a/src/tree/typed/node.rs +++ b/src/tree/typed/node.rs @@ -155,14 +155,22 @@ where } impl Node { - fn from_untyped(inner: untyped::Node) -> Self { + /// Tag an untyped node at height `H`: the caller asserts the height. + /// + /// The streaming mirror's erasure seam + /// ([`Backend::assume`](crate::tree::mirror::streaming::Backend::assume)) + /// re-tags nodes it erased at the same height; internal tree code tags + /// nodes whose height its own traversal establishes. + pub(crate) fn from_untyped(inner: untyped::Node) -> Self { Self { height: PhantomData, inner, } } - fn into_untyped(self) -> untyped::Node { + /// Forget this node's height tag; the inverse of + /// [`from_untyped`](Self::from_untyped). + pub(crate) fn into_untyped(self) -> untyped::Node { self.inner } From d1b7f00e41563c80ef3453e8b0873b0c4ee45a42 Mon Sep 17 00:00:00 2001 From: finch Date: Wed, 19 Aug 2026 16:22:23 -0400 Subject: [PATCH 02/18] streaming: erase the materialized channels' payloads Every bounded channel in the materialized walk now carries the height-erased twin of its payload (streaming/erased.rs), behind typed facades minted per edge: both halves of an edge are created at one height parameter, so the walk's stage code keeps exactly the typed surface it had, and the tokio mpsc machinery instantiates once per backend instead of once per height. ErasedPrefix (the prefix bytes without the height tag, length re-checked on every re-tag in debug builds) is the runtime witness traveling with every payload. The one unerased edge is leaf_requests: its item is already the single-height Prefix. Measured on --test pairwise (debug, default features), parent 2,884,887 lines / 109,613 copies -> 2,609,789 / 94,312 (-9.5%); rows naming tokio's mpsc fall 632,145 -> 234,535 lines. The remaining per-height machinery (the async_stream walk generators at ~400k and the proxy's channels) is the target of the next steps (design/height-erasure.md steps 3-4). --- src/tree/mirror/streaming.rs | 1 + src/tree/mirror/streaming/erased.rs | 499 ++++++++++++++++++ src/tree/mirror/streaming/materialized.rs | 16 +- .../mirror/streaming/materialized/common.rs | 35 +- .../mirror/streaming/materialized/tests.rs | 3 +- .../mirror/streaming/materialized/work.rs | 5 +- .../streaming/materialized/work/assembly.rs | 9 +- .../streaming/materialized/work/levels.rs | 31 +- .../streaming/materialized/work/queues.rs | 113 ++-- .../materialized/work/tests/violations.rs | 7 +- src/tree/typed.rs | 1 + src/tree/typed/prefix.rs | 45 ++ 12 files changed, 632 insertions(+), 133 deletions(-) create mode 100644 src/tree/mirror/streaming/erased.rs diff --git a/src/tree/mirror/streaming.rs b/src/tree/mirror/streaming.rs index f40952555..a55534ff9 100644 --- a/src/tree/mirror/streaming.rs +++ b/src/tree/mirror/streaming.rs @@ -45,6 +45,7 @@ mod backend; mod channel; pub(crate) mod convert; mod driver; +mod erased; pub mod materialized; mod message; mod protocol; diff --git a/src/tree/mirror/streaming/erased.rs b/src/tree/mirror/streaming/erased.rs new file mode 100644 index 000000000..1a9ab803b --- /dev/null +++ b/src/tree/mirror/streaming/erased.rs @@ -0,0 +1,499 @@ +//! Height-erased twins of the session's channel payloads, and the typed +//! facades that are the only way in or out of them. +//! +//! Every item the walk's bounded channels carry is height-indexed in the +//! type system but height-uniform at runtime: nodes erase to one +//! representation per backend ([`Backend::Erased`]), prefixes to their +//! bytes ([`ErasedPrefix`]), and nothing else in a payload ever depended +//! on the height. Minting channels of the erased twins therefore costs +//! nothing at runtime — every conversion below is a phantom-tag swap over +//! the value the program already holds — and collapses the channel +//! machinery from one instantiation per height to one per backend. +//! +//! # What the types stop proving, and what catches it instead +//! +//! Outside this module, sending a height-5 payload into a height-6 queue +//! is a compile error, exactly as before: the constructors here pair each +//! erased channel with typed facades ([`TypedSender`], [`TypedReceiver`], +//! [`TypedStream`], [`TypedOkStream`]) minted at one height parameter, so +//! both halves of an edge speak the same height by construction, and a +//! mispairing can only be authored *inside this module* by wiring a +//! constructor's two halves to different conversions. That one-module +//! audit surface is the design's locality argument. At runtime, every +//! prefix re-tag debug-asserts its byte length against the claimed height +//! ([`ErasedPrefix::assume`]), and every channel keeps its +//! [`QueueRole`] height label for the instrumented diagnostics. + +use std::marker::PhantomData; +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}, + materialized::{self, Error}, + message, + }, + typed::{ + ErasedPrefix, Hash, + height::{Height, S, Z}, + }, +}; + +/// [`message::Reply`] with its height forgotten. +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)>), +} + +/// [`materialized::Query`] with its height forgotten. +pub(crate) struct Query { + pub prefix: ErasedPrefix, + pub ours: Vec<(u8, E)>, +} + +/// [`materialized::Resolution`] with its height forgotten. +pub(crate) struct Resolution { + pub prefix: ErasedPrefix, + pub resolved: Vec<(u8, Resolve)>, +} + +/// [`materialized::Resolve`] with its height forgotten. +pub(crate) enum Resolve { + Ready(Option), + Pending, +} + +/// Shorthand for the erased node representation of one backend. +type ErasedOf = >::Erased; + +/// The typed halves of the outgoing-response edge +/// ([`reply_channel`]): items are whole replies or the error that ends +/// the stream. +pub(crate) type ReplyResultSender = TypedSender< + Result, Error<>::Error>>, + Result>, Error<>::Error>>, +>; +/// The receiving half of [`reply_channel`], as the response stream shape. +pub(crate) type ReplyResultStream = TypedStream< + Result, Error<>::Error>>, + Result>, Error<>::Error>>, +>; +/// The typed sending half of a query edge ([`query_channel`]). +pub(crate) type QuerySender = + TypedSender, Query>>; +/// The typed receiving half of a query edge ([`query_channel`]). +pub(crate) type QueryReceiver = + TypedReceiver, Query>>; +/// The typed sending half of a return edge ([`return_channel`], +/// [`return_ok_channel`]): one reconciled node per query, in query order. +pub(crate) type ReturnSender = + TypedSender>::Node>, Option>>; +/// The typed receiving half of [`return_channel`]. +pub(crate) type ReturnReceiver = + TypedReceiver>::Node>, Option>>; +/// The receiving half of [`return_ok_channel`], as an `Ok`-wrapping stream. +pub(crate) type ReturnOkStream = TypedOkStream< + Option<>::Node>, + Option>, + Error<>::Error>, +>; +/// The typed sending half of a resolution edge ([`resolution_ok_channel`]). +pub(crate) type ResolutionSender = + TypedSender, Resolution>>; +/// The receiving half of [`resolution_ok_channel`], as an `Ok`-wrapping +/// stream. +pub(crate) type ResolutionOkStream = TypedOkStream< + materialized::Resolution, + Resolution>, + Error<>::Error>, +>; + +/// Mint the outgoing-response edge: a typed sender and the typed response +/// stream its receiver drains into. +pub(crate) fn reply_channel( + role: QueueRole, + capacity: usize, +) -> (ReplyResultSender, ReplyResultStream) +where + B: Backend: Leaf>, + T: Send + Sync + 'static, + H: Height, +{ + let (sender, receiver) = channel(role, capacity); + ( + TypedSender::new(sender, |item: Result<_, _>| { + item.map(erase_reply::) + }), + TypedStream::new(receiver, |item: Result<_, _>| { + item.map(assume_reply::) + }), + ) +} + +/// Mint one query edge at height `H` (the children's height; the scope +/// sits at `S`). +pub(crate) fn query_channel( + role: QueueRole, + capacity: usize, +) -> (QuerySender, QueryReceiver) +where + B: Backend: Leaf>, + T: Send + Sync + 'static, + H: Height, + S: Height, +{ + let (sender, receiver) = channel(role, capacity); + ( + TypedSender::new(sender, erase_query::), + TypedReceiver::new(receiver, assume_query::), + ) +} + +/// Mint one return edge at height `H`, received item by item. +pub(crate) fn return_channel( + role: QueueRole, + capacity: usize, +) -> (ReturnSender, ReturnReceiver) +where + B: Backend: Leaf>, + T: Send + Sync + 'static, + H: Height, +{ + let (sender, receiver) = channel(role, capacity); + ( + TypedSender::new(sender, erase_return::), + TypedReceiver::new(receiver, assume_return::), + ) +} + +/// Mint one return edge at height `H`, received as an `Ok`-wrapping stream. +pub(crate) fn return_ok_channel( + role: QueueRole, + capacity: usize, +) -> (ReturnSender, ReturnOkStream) +where + B: Backend: Leaf>, + T: Send + Sync + 'static, + H: Height, +{ + let (sender, receiver) = channel(role, capacity); + ( + TypedSender::new(sender, erase_return::), + TypedOkStream::new(receiver, assume_return::), + ) +} + +/// Mint one resolution edge at height `H`, received as an `Ok`-wrapping +/// stream. +pub(crate) fn resolution_ok_channel( + role: QueueRole, + capacity: usize, +) -> (ResolutionSender, ResolutionOkStream) +where + B: Backend: Leaf>, + T: Send + Sync + 'static, + H: Height, + S: Height, +{ + let (sender, receiver) = channel(role, capacity); + ( + TypedSender::new(sender, erase_resolution::), + TypedOkStream::new(receiver, assume_resolution::), + ) +} + +fn erase_reply(reply: message::Reply) -> Reply> +where + B: Backend: Leaf>, + T: Send + Sync + 'static, + 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(), + } +} + +fn assume_reply(reply: Reply>) -> message::Reply +where + B: Backend: Leaf>, + T: Send + Sync + 'static, + 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(), + } +} + +fn erase_query(query: materialized::Query) -> Query> +where + B: Backend: Leaf>, + T: Send + Sync + 'static, + H: Height, + S: Height, +{ + Query { + prefix: query.prefix.erase(), + ours: query + .ours + .into_iter() + .map(|(radix, node)| (radix, B::erase(node))) + .collect(), + } +} + +fn assume_query(query: Query>) -> materialized::Query +where + B: Backend: Leaf>, + T: Send + Sync + 'static, + H: Height, + S: Height, +{ + materialized::Query { + prefix: query.prefix.assume(), + ours: query + .ours + .into_iter() + .map(|(radix, node)| (radix, B::assume(node))) + .collect(), + } +} + +fn erase_resolution( + resolution: materialized::Resolution, +) -> Resolution> +where + B: Backend: Leaf>, + T: Send + Sync + 'static, + H: Height, + S: Height, +{ + Resolution { + prefix: resolution.prefix.erase(), + resolved: resolution + .resolved + .into_iter() + .map(|(radix, slot)| { + ( + radix, + match slot { + materialized::Resolve::Ready(node) => Resolve::Ready(node.map(B::erase)), + materialized::Resolve::Pending => Resolve::Pending, + }, + ) + }) + .collect(), + } +} + +fn assume_resolution( + resolution: Resolution>, +) -> materialized::Resolution +where + B: Backend: Leaf>, + T: Send + Sync + 'static, + H: Height, + S: Height, +{ + materialized::Resolution { + prefix: resolution.prefix.assume(), + resolved: resolution + .resolved + .into_iter() + .map(|(radix, slot)| { + ( + radix, + match slot { + Resolve::Ready(node) => { + materialized::Resolve::Ready(node.map(B::assume::)) + } + Resolve::Pending => materialized::Resolve::Pending, + }, + ) + }) + .collect(), + } +} + +fn erase_return(node: Option>) -> Option> +where + B: Backend: Leaf>, + T: Send + Sync + 'static, + H: Height, +{ + node.map(B::erase) +} + +fn assume_return(node: Option>) -> Option> +where + B: Backend: Leaf>, + T: Send + Sync + 'static, + H: Height, +{ + node.map(B::assume::) +} + +// -------------------------------------------------------------------------------- +// The typed facades: each pairs one erased channel half with the fixed +// conversion its constructor minted it with. +// -------------------------------------------------------------------------------- + +/// The typed sending half of an erased channel. +pub(crate) struct TypedSender { + inner: Sender, + erase: fn(M) -> E, +} + +impl Clone for TypedSender { + fn clone(&self) -> Self { + Self { + inner: self.inner.clone(), + erase: self.erase, + } + } +} + +impl TypedSender { + fn new(inner: Sender, erase: fn(M) -> E) -> Self { + Self { inner, erase } + } + + /// Send one typed item, erased in place. + /// + /// Like the underlying channel's send: an error means the receiving + /// half is gone, and the producer should wind down. + pub(crate) async fn send(&self, message: M) -> Result<(), ClosedChannel> { + self.inner + .send((self.erase)(message)) + .await + .map_err(|_| ClosedChannel) + } +} + +/// The receiver of a typed send has hung up; the payload is dropped. +/// +/// The typed sender cannot return the underlying +/// [`SendError`](tokio::sync::mpsc::error::SendError) because that hands +/// back the *erased* payload; no caller inspects it — a failed send means +/// "stop producing" on every edge. +#[derive(Debug)] +pub(crate) struct ClosedChannel; + +/// The typed receiving half of an erased channel. +pub(crate) struct TypedReceiver { + inner: Receiver, + assume: fn(E) -> M, +} + +impl TypedReceiver { + fn new(inner: Receiver, assume: fn(E) -> M) -> Self { + Self { inner, assume } + } + + /// Receive one typed item, re-tagged in place. + pub(crate) async fn recv(&mut self) -> Option { + self.inner.recv().await.map(self.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) + } +} + +/// An erased channel's receiving half as a stream of typed items. +pub(crate) struct TypedStream { + inner: ReceiverStreamOf, + assume: fn(E) -> M, +} + +impl TypedStream { + fn new(inner: Receiver, assume: fn(E) -> M) -> Self { + Self { + inner: receiver_stream(inner), + assume, + } + } +} + +impl Stream for TypedStream { + type Item = M; + + 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)) + } +} + +/// An erased channel's receiving half as a stream of `Ok`-wrapped typed +/// items: the shape the assembly and walk consumers pull from. +pub(crate) struct TypedOkStream { + inner: ReceiverStreamOf, + assume: fn(E) -> M, + error: PhantomData Err>, +} + +impl TypedOkStream { + fn new(inner: Receiver, assume: fn(E) -> M) -> Self { + Self { + inner: receiver_stream(inner), + assume, + error: PhantomData, + } + } +} + +impl Stream for TypedOkStream { + type Item = Result; + + 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(|item| Ok((this.assume)(item)))) + } +} diff --git a/src/tree/mirror/streaming/materialized.rs b/src/tree/mirror/streaming/materialized.rs index 931491c37..991ddba7a 100644 --- a/src/tree/mirror/streaming/materialized.rs +++ b/src/tree/mirror/streaming/materialized.rs @@ -106,6 +106,7 @@ use crate::tree::{ mirror::contained, mirror::streaming::{ Backend, Leaf, Node, Root, + erased::{QueryReceiver, ReturnSender}, materialized::{unknown::Unknown, work::Work}, message::{Greeting, Reaction, Reply}, protocol::{self, BoxResponses, Requests}, @@ -179,8 +180,7 @@ mod tests; pub(super) mod transcript; pub(super) mod unknown; mod work; -use channel::{Receiver, Sender}; -use common::*; +use channel::Receiver; // The remote proxy explodes early-supplied whole root children into the // same per-child shape the walks consume, with the walks' own helper. pub(crate) use common::children_of; @@ -274,9 +274,9 @@ where S: Height, { /// The prefix at which the resolved node will sit. - prefix: Prefix>, + pub(crate) prefix: Prefix>, /// The possibly-resolved children of the node. - resolved: Vec<(u8, Resolve)>, + pub(crate) resolved: Vec<(u8, Resolve)>, } /// One child's slot in a [`Resolution`]. @@ -364,9 +364,9 @@ where /// absorbed supply ([`SupplyLedger`]). ledger: SupplyLedger, /// The questions we asked, awaiting their replies in order. - queries: Receiver>, + queries: QueryReceiver, /// One resolved scope per query, in query order, to the stage above. - returns: Sender>>>, + returns: ReturnSender>, /// 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). @@ -403,7 +403,7 @@ 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: ReturnSender, /// The accumulated work to drive the pipeline. work: Work, /// The future result of the pipeline. @@ -860,7 +860,7 @@ async fn absorb( ledger: SupplyLedger, requests: impl Requests, mut queries: Receiver>, - returns: Sender>>, + returns: ReturnSender, stats: Recorder, ) -> Result<(), Error> where diff --git a/src/tree/mirror/streaming/materialized/common.rs b/src/tree/mirror/streaming/materialized/common.rs index 2f019607b..4eb983357 100644 --- a/src/tree/mirror/streaming/materialized/common.rs +++ b/src/tree/mirror/streaming/materialized/common.rs @@ -1,8 +1,6 @@ use std::pin::pin; -use futures::{StreamExt as _, stream}; -#[cfg(not(test))] -use tokio_stream::wrappers::ReceiverStream; +use futures::StreamExt as _; use crate::tree::{ mirror::streaming::{Backend, Leaf}, @@ -12,8 +10,6 @@ use crate::tree::{ }, }; -use super::channel::{QueueRole, Receiver, Sender, channel}; - /// Collect one node's children, addressed by radix. pub async fn children_of( backend: &B, @@ -35,32 +31,3 @@ where } Ok(fan) } - -/// Create a pair of a sender and a receiver stream, where the receiver -/// wraps items in `Ok`. -pub fn ok_channel( - role: QueueRole, - buffer: usize, -) -> (Sender, OkReceiverStream) { - ok_channel_with(channel(role, buffer)) -} - -fn ok_channel_with( - (tx, rx): (Sender, Receiver), -) -> (Sender, OkReceiverStream) { - #[cfg(test)] - { - (tx, rx.map(Ok)) - } - #[cfg(not(test))] - { - (tx, ReceiverStream::new(rx).map(Ok)) - } -} - -/// The type of a receiver stream wrapping items in `Ok`. -#[cfg(test)] -pub type OkReceiverStream = stream::Map, fn(T) -> Result>; -/// The type of a receiver stream wrapping items in `Ok`. -#[cfg(not(test))] -pub type OkReceiverStream = stream::Map, fn(T) -> Result>; diff --git a/src/tree/mirror/streaming/materialized/tests.rs b/src/tree/mirror/streaming/materialized/tests.rs index 21b7c3911..4845fd9e6 100644 --- a/src/tree/mirror/streaming/materialized/tests.rs +++ b/src/tree/mirror/streaming/materialized/tests.rs @@ -15,6 +15,7 @@ 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, @@ -64,7 +65,7 @@ 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) = erased::return_channel::( QueueRole::new(QueueKind::TerminalLeafResolutions, Z::HEIGHT), 1, ); diff --git a/src/tree/mirror/streaming/materialized/work.rs b/src/tree/mirror/streaming/materialized/work.rs index 9fce3badb..201f131f7 100644 --- a/src/tree/mirror/streaming/materialized/work.rs +++ b/src/tree/mirror/streaming/materialized/work.rs @@ -22,7 +22,8 @@ use super::{progress, transcript}; use crate::tree::{ mirror::streaming::{ Backend, Leaf, - materialized::{Error, channel::Sender}, + erased::ReturnSender, + materialized::Error, protocol::{BoxResponses, Responses}, stats::Recorder, tasks::{complete, park_after_published_error}, @@ -114,7 +115,7 @@ where /// Forward a stream of nodes into an upward return channel. fn return_into( &mut self, - returns: Sender>>, + returns: ReturnSender, stream: impl Stream>, Error>> + Send + 'static, ) { self.tasks.push(Box::pin(async move { diff --git a/src/tree/mirror/streaming/materialized/work/assembly.rs b/src/tree/mirror/streaming/materialized/work/assembly.rs index 108eadc15..728aec2ec 100644 --- a/src/tree/mirror/streaming/materialized/work/assembly.rs +++ b/src/tree/mirror/streaming/materialized/work/assembly.rs @@ -10,7 +10,8 @@ use super::{Work, queues::assembly_level_returns}; use crate::tree::{ mirror::streaming::{ Backend, Leaf, - materialized::{Error, Resolution, Resolve, channel::Sender}, + erased::ReturnSender, + materialized::{Error, Resolution, Resolve}, tasks::next_or_cancelled, }, typed::height::{Height, S, Z}, @@ -28,9 +29,9 @@ where /// on blocked sender futures remaining independently runnable. pub fn assemble( &mut self, - returns: Sender>>>, + returns: ReturnSender>, resolutions: impl Stream, Error>> + Send + 'static, - ) -> Sender>> + ) -> ReturnSender where H: Height, S: Height, @@ -46,7 +47,7 @@ where /// Assemble leaf resolutions upward with no level beneath them. pub fn assemble_leaves( &mut self, - returns: Sender>>>, + returns: ReturnSender>, resolutions: impl Stream, Error>> + Send + 'static, ) { self.return_into( diff --git a/src/tree/mirror/streaming/materialized/work/levels.rs b/src/tree/mirror/streaming/materialized/work/levels.rs index 3829b7a62..2bc5ca0b2 100644 --- a/src/tree/mirror/streaming/materialized/work/levels.rs +++ b/src/tree/mirror/streaming/materialized/work/levels.rs @@ -16,9 +16,10 @@ use crate::tree::{ mirror::contained, mirror::streaming::{ Backend, Leaf, Node, Root, + erased::{QueryReceiver, ResolutionOkStream, ReturnSender}, materialized::{ - Error, OkReceiverStream, Query, Resolution, Resolve, SupplyLedger, Violation, - channel::{Receiver, Sender}, + Error, Query, Resolution, Resolve, SupplyLedger, Violation, + channel::Receiver, children_of, fan_listing, unknown::{Unknown, unknown, unknown_providing}, violation, @@ -62,8 +63,8 @@ where their_listing: Vec<(u8, Hash)>, ) -> ( BoxResponses>, - Receiver>, - Sender>>, + QueryReceiver, + ReturnSender, oneshot::Receiver>)>>, BoxFuture<'static, Result, Error>>, ) @@ -163,8 +164,8 @@ where requests: impl Requests, ) -> ( BoxResponses>, - Receiver>, - Sender>>, + QueryReceiver, + ReturnSender, oneshot::Receiver)>)>>, BoxFuture<'static, Result, Error>>, ) @@ -265,12 +266,12 @@ where early_survivors: Option>>>)>>>, early_supplies: Option>>)>)>>>, requests: impl Requests>>, - mut queries: Receiver>>>, + mut queries: QueryReceiver>>, ) -> ( BoxResponses, Error>, - Receiver>, - OkReceiverStream>>, Error>, - OkReceiverStream>, Error>, + QueryReceiver, + ResolutionOkStream>>, + ResolutionOkStream>, ) where B: Sync, @@ -434,12 +435,12 @@ where their_version: Version, ledger: SupplyLedger, requests: impl Requests>, - mut queries: Receiver>>, + mut queries: QueryReceiver>, ) -> ( BoxResponses>, Receiver>, - OkReceiverStream>, Error>, - OkReceiverStream, Error>, + ResolutionOkStream>, + ResolutionOkStream, ) where B: Sync, @@ -521,10 +522,10 @@ where their_version: Version, ledger: SupplyLedger, requests: impl Requests, - mut queries: Receiver>, + mut queries: QueryReceiver, ) -> ( BoxResponses>, - OkReceiverStream, Error>, + ResolutionOkStream, ) { let (upper, upper_rx) = terminal_leaf_resolutions(); let stats = self.stats.clone(); diff --git a/src/tree/mirror/streaming/materialized/work/queues.rs b/src/tree/mirror/streaming/materialized/work/queues.rs index c61343f29..750b76c14 100644 --- a/src/tree/mirror/streaming/materialized/work/queues.rs +++ b/src/tree/mirror/streaming/materialized/work/queues.rs @@ -2,7 +2,10 @@ //! //! 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. The channels beneath carry the +//! height-erased payload twins — one channel-machinery instantiation per +//! backend rather than one per height — behind typed facades minted per +//! edge (see [`erased`]). //! //! 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,18 +19,17 @@ //! 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, QueryReceiver, QuerySender, ResolutionOkStream, ResolutionSender, ReturnOkStream, + ReturnReceiver, ReturnSender, + }, materialized::{ - Error, OkReceiverStream, Query, Resolution, + Error, channel::{QueueKind, QueueRole, Receiver, Sender, channel}, - ok_channel, }, - message::Reply, protocol::BoxResponses, window::FAN, }, @@ -43,7 +45,7 @@ use crate::tree::{ /// 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>>, + erased::ReplyResultSender, BoxResponses>, ) where @@ -51,12 +53,9 @@ where T: Send + Sync + 'static, 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) + let (sender, responses) = + erased::reply_channel(QueueRole::new(QueueKind::OutgoingResponses, H::HEIGHT), 1); + (sender, Box::pin(responses)) } /// Buffer lower-level completions until their enclosing resolution arrives. @@ -81,16 +80,13 @@ 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() -> (ReturnSender, ReturnOkStream) where B: Backend: Leaf>, T: Send + Sync + 'static, H: Height, { - ok_channel( + erased::return_ok_channel::( QueueRole::new(QueueKind::AssemblyLevelReturns, H::HEIGHT), FAN, ) @@ -100,15 +96,13 @@ where /// /// 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() +-> (QuerySender, QueryReceiver) where B: Backend: Leaf>, T: Send + Sync + 'static, { - channel( + erased::query_channel( QueueRole::new(QueueKind::InitiatorRootQuery, UnderRoot::HEIGHT), 1, ) @@ -118,15 +112,12 @@ 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() -> (ReturnSender, ReturnReceiver) where B: Backend: Leaf>, T: Send + Sync + 'static, { - channel( + erased::return_channel::( QueueRole::new(QueueKind::InitiatorRootReturn, Root::HEIGHT), 1, ) @@ -137,19 +128,19 @@ where /// The opening wire reply and root resolution are published before these /// queries, so one slot is the liveness floor. The window widens it so the next /// 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. +/// [`Query`](crate::tree::mirror::streaming::materialized::Query) may own a +/// fan of node handles, which is priced by the window's node budget. pub(super) fn responder_child_queries( capacity: usize, ) -> ( - Sender>, - Receiver>, + QuerySender, + QueryReceiver, ) where B: Backend: Leaf>, T: Send + Sync + 'static, { - channel( + erased::query_channel( QueueRole::new(QueueKind::ResponderChildQueries, UnderUnderRoot::HEIGHT), capacity, ) @@ -160,14 +151,14 @@ 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>, + ResolutionSender, + ResolutionOkStream, ) where B: Backend: Leaf>, T: Send + Sync + 'static, { - ok_channel( + erased::resolution_ok_channel( QueueRole::new(QueueKind::ResponderRootResolution, UnderRoot::HEIGHT), 1, ) @@ -179,14 +170,14 @@ where /// 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>, + ReturnSender, + ReturnOkStream, ) where B: Backend: Leaf>, T: Send + Sync + 'static, { - ok_channel( + erased::return_ok_channel::( QueueRole::new(QueueKind::ResponderRootReturns, UnderRoot::HEIGHT), 1, ) @@ -200,14 +191,14 @@ where /// height, so its capacity is what lets sibling scopes' round trips overlap. pub(super) fn internal_child_queries( capacity: usize, -) -> (Sender>, Receiver>) +) -> (QuerySender, QueryReceiver) where B: Backend: Leaf>, T: Send + Sync + 'static, H: Height, S: Height, { - channel( + erased::query_channel( QueueRole::new(QueueKind::InternalChildQueries, H::HEIGHT), capacity, ) @@ -222,8 +213,8 @@ where pub(super) fn internal_parent_resolutions( capacity: usize, ) -> ( - Sender>>>, - OkReceiverStream>>, Error>, + ResolutionSender>>, + ResolutionOkStream>>, ) where B: Backend: Leaf>, @@ -233,7 +224,7 @@ where S>: Height, S>>: Height, { - ok_channel( + erased::resolution_ok_channel( QueueRole::new(QueueKind::InternalParentResolutions, >>::HEIGHT), capacity, ) @@ -246,10 +237,7 @@ where /// them while earlier subtrees are still reconciling. pub(super) fn internal_child_resolutions( capacity: usize, -) -> ( - Sender>>, - OkReceiverStream>, Error>, -) +) -> (ResolutionSender>, ResolutionOkStream>) where B: Backend: Leaf>, T: Send + Sync + 'static, @@ -257,7 +245,7 @@ where S: Height, S>: Height, { - ok_channel( + erased::resolution_ok_channel( QueueRole::new(QueueKind::InternalChildResolutions, >::HEIGHT), capacity, ) @@ -268,6 +256,9 @@ where /// The corresponding leaf-scope resolution is published first, so one slot is /// the liveness floor. This queue is the leaf-height question window: its /// capacity is how many requested leaves may await the peer's supplies at once. +/// +/// The one materialized edge with no erased twin: its item is already the +/// single-height [`Prefix`]. pub(super) fn leaf_requests(capacity: usize) -> (Sender>, Receiver>) { channel(QueueRole::new(QueueKind::LeafRequests, Z::HEIGHT), capacity) } @@ -280,15 +271,12 @@ pub(super) fn leaf_requests(capacity: usize) -> (Sender>, Receiver
(
     capacity: usize,
-) -> (
-    Sender>>,
-    OkReceiverStream>, Error>,
-)
+) -> (ResolutionSender>, ResolutionOkStream>)
 where
     B: Backend: Leaf>,
     T: Send + Sync + 'static,
 {
-    ok_channel(
+    erased::resolution_ok_channel(
         QueueRole::new(QueueKind::LeafParentResolutions, >::HEIGHT),
         capacity,
     )
@@ -302,15 +290,12 @@ where
 /// scopes await their supplies.
 pub(super) fn leaf_child_resolutions(
     capacity: usize,
-) -> (
-    Sender>,
-    OkReceiverStream, Error>,
-)
+) -> (ResolutionSender, ResolutionOkStream)
 where
     B: Backend: Leaf>,
     T: Send + Sync + 'static,
 {
-    ok_channel(
+    erased::resolution_ok_channel(
         QueueRole::new(QueueKind::LeafChildResolutions, Z::HEIGHT),
         capacity,
     )
@@ -327,15 +312,13 @@ 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()
+-> (ResolutionSender, ResolutionOkStream)
 where
     B: Backend: Leaf>,
     T: Send + Sync + 'static,
 {
-    ok_channel(
+    erased::resolution_ok_channel(
         QueueRole::new(QueueKind::TerminalLeafResolutions, Z::HEIGHT),
         FAN,
     )
diff --git a/src/tree/mirror/streaming/materialized/work/tests/violations.rs b/src/tree/mirror/streaming/materialized/work/tests/violations.rs
index f1c3359ea..90173d0c2 100644
--- a/src/tree/mirror/streaming/materialized/work/tests/violations.rs
+++ b/src/tree/mirror/streaming/materialized/work/tests/violations.rs
@@ -11,10 +11,9 @@ use crate::{
     Version,
     tree::mirror::streaming::{
         Local,
+        erased::QueryReceiver,
         materialized::{
-            Error, Query, SupplyLedger, Violation, Work,
-            channel::{Receiver, with_schedule},
-            unknown::Unknown,
+            Error, Query, SupplyLedger, Violation, Work, channel::with_schedule, unknown::Unknown,
             work::queues::internal_child_queries,
         },
         message::{Reaction, Reply},
@@ -198,7 +197,7 @@ where
 }
 
 /// Put the script's optional outstanding query into the walk's pairing queue.
-fn query_receiver(query: Option>) -> Receiver>
+fn query_receiver(query: Option>) -> QueryReceiver
 where
     H: Height,
     S: Height,
diff --git a/src/tree/typed.rs b/src/tree/typed.rs
index 5b31b7d93..fab783da9 100644
--- a/src/tree/typed.rs
+++ b/src/tree/typed.rs
@@ -32,5 +32,6 @@ pub use hash::Hash;
 pub use levels::{Level, Levels};
 pub use node::{Children, Node};
 pub use path::Path;
+pub(crate) use prefix::ErasedPrefix;
 pub use prefix::Prefix;
 pub use untyped::{Iter, Leaf, RangeOwned};
diff --git a/src/tree/typed/prefix.rs b/src/tree/typed/prefix.rs
index ab7911925..00bb66d33 100644
--- a/src/tree/typed/prefix.rs
+++ b/src/tree/typed/prefix.rs
@@ -23,6 +23,46 @@ pub struct Prefix {
     hash: ArrayVec<[u8; 32]>,
 }
 
+/// A prefix with its height tag forgotten: the same accumulated path
+/// bytes, whose length *is* the height (`32 - height` bytes at `height`).
+///
+/// The typed [`Prefix`] wraps runtime bytes in a compile-time tag; this
+/// is those bytes without the tag, for plumbing that carries prefixes of
+/// every height through one instantiation. [`Prefix::erase`] forgets the
+/// tag and [`ErasedPrefix::assume`] restores it, checking the length
+/// against the claimed height in debug builds.
+#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
+pub(crate) struct ErasedPrefix {
+    hash: ArrayVec<[u8; 32]>,
+}
+
+impl ErasedPrefix {
+    /// Re-tag this prefix at height `H`.
+    ///
+    /// `H` must be the height the prefix was erased at — equivalently,
+    /// `32 - H::HEIGHT` must be its byte length, debug-asserted here. A
+    /// cross-height re-tag is a programmer error in the erased plumbing,
+    /// never a consequence of peer input: every wire prefix decodes
+    /// through the typed reader, which fixes the length from the type.
+    pub fn assume(self) -> Prefix {
+        debug_assert_eq!(
+            self.hash.len(),
+            32 - H::HEIGHT,
+            "an erased prefix re-tags at the height it was erased at",
+        );
+        Prefix {
+            height: PhantomData,
+            hash: self.hash,
+        }
+    }
+}
+
+impl Debug for ErasedPrefix {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        self.hash.fmt(f)
+    }
+}
+
 impl Prefix {
     /// Make a new empty prefix.
     pub fn new() -> Self {
@@ -85,6 +125,11 @@ impl Prefix {
         &self.hash
     }
 
+    /// Forget this prefix's height tag; [`ErasedPrefix::assume`] restores it.
+    pub(crate) fn erase(self) -> ErasedPrefix {
+        ErasedPrefix { hash: self.hash }
+    }
+
     /// The prefix naming the height-`H` subtree that contains `path`: its
     /// first `32 - H::HEIGHT` bytes.
     pub fn containing(path: &Path) -> Self {

From bf1a5b4bc61a984e90ede4f94978476b3037922b Mon Sep 17 00:00:00 2001
From: finch 
Date: Wed, 19 Aug 2026 17:05:55 -0400
Subject: [PATCH 03/18] streaming: erase the materialized walk's workers
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

The walk now runs on the erased vocabulary end to end, one
instantiation per backend: Query/Resolution/Resolve are generic over
the erased node representation (their prefix an ErasedPrefix whose
byte length is the height witness), and the level loops, answerer,
resolver, assembler, and deletion-honoring filter are shared bodies
behind thin typed shells. Each shell erases its stage's request stream
on the way in; Work::respond's reply-channel exit re-tags responses at
the stage's height on the way out — the walk's only remaining typed
boundary, plus the two fixed-height root re-tags at the finish
futures.

Erased code reaches the height-typed Backend surface through a
33-arm runtime-to-type dispatch (erased::ops), keyed on the prefix
length so the coordinate and the witness cannot drift apart; the
numbered height aliases it indexes live in typed::height with a
pinned-endpoint tripwire. The Unknown trait tower dissolves into one
prefix-guided recursion (depth bounded by the 32-byte path, boxed per
step exactly as the typed tower was). Step 2's per-payload facades
dissolve with it: the channels carry the erased vocabulary bare, and
their QueueRole height labels stay runtime data for the instrumented
diagnostics and capacity suites.

Measured on --test pairwise (debug, default features):
2,609,789 lines / 94,312 copies -> 1,717,836 / 67,158 (parent
2,884,887 / 109,613; -40.5% cumulative). The materialized-labeled
rows fall 662k -> 274k and async_stream 400k -> 229k; the remaining
per-height machinery is the proxy's (remote-labeled rows: 837k),
design/height-erasure.md step 4.
---
 src/tree/mirror/streaming/erased.rs           | 556 ++++++------------
 src/tree/mirror/streaming/materialized.rs     | 108 ++--
 .../mirror/streaming/materialized/common.rs   |  35 +-
 .../mirror/streaming/materialized/progress.rs |  55 +-
 .../mirror/streaming/materialized/tests.rs    |  17 +-
 .../streaming/materialized/transcript.rs      |  21 +-
 .../mirror/streaming/materialized/unknown.rs  | 183 +++---
 .../streaming/materialized/unknown/tests.rs   |   9 +-
 .../mirror/streaming/materialized/work.rs     |  94 +--
 .../streaming/materialized/work/answer.rs     |  66 +--
 .../streaming/materialized/work/assembly.rs   |  41 +-
 .../streaming/materialized/work/levels.rs     | 273 ++++++---
 .../streaming/materialized/work/queues.rs     | 152 ++---
 .../streaming/materialized/work/resolver.rs   |  35 +-
 .../streaming/materialized/work/tests.rs      |  50 +-
 .../materialized/work/tests/violations.rs     |  46 +-
 src/tree/mirror/streaming/window.rs           |   8 +-
 src/tree/typed/height.rs                      |  33 ++
 src/tree/typed/prefix.rs                      |  26 +
 19 files changed, 881 insertions(+), 927 deletions(-)

diff --git a/src/tree/mirror/streaming/erased.rs b/src/tree/mirror/streaming/erased.rs
index 1a9ab803b..16cb3defb 100644
--- a/src/tree/mirror/streaming/erased.rs
+++ b/src/tree/mirror/streaming/erased.rs
@@ -1,30 +1,44 @@
-//! Height-erased twins of the session's channel payloads, and the typed
-//! facades that are the only way in or out of them.
+//! 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.
 //!
-//! Every item the walk's bounded channels carry is height-indexed in the
-//! type system but height-uniform at runtime: nodes erase to one
-//! representation per backend ([`Backend::Erased`]), prefixes to their
-//! bytes ([`ErasedPrefix`]), and nothing else in a payload ever depended
-//! on the height. Minting channels of the erased twins therefore costs
-//! nothing at runtime — every conversion below is a phantom-tag swap over
-//! the value the program already holds — and collapses the channel
-//! machinery from one instantiation per height to one per backend.
+//! 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 this module, sending a height-5 payload into a height-6 queue
-//! is a compile error, exactly as before: the constructors here pair each
-//! erased channel with typed facades ([`TypedSender`], [`TypedReceiver`],
-//! [`TypedStream`], [`TypedOkStream`]) minted at one height parameter, so
-//! both halves of an edge speak the same height by construction, and a
-//! mispairing can only be authored *inside this module* by wiring a
-//! constructor's two halves to different conversions. That one-module
-//! audit surface is the design's locality argument. At runtime, every
-//! prefix re-tag debug-asserts its byte length against the claimed height
-//! ([`ErasedPrefix::assume`]), and every channel keeps its
-//! [`QueueRole`] height label for the instrumented diagnostics.
+//! 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::marker::PhantomData;
 use std::pin::Pin;
 use std::task::{Context, Poll};
 
@@ -36,16 +50,17 @@ use crate::tree::{
     mirror::streaming::{
         Backend, Leaf,
         channel::{QueueRole, Receiver, Sender, channel},
-        materialized::{self, Error},
+        materialized::Error,
         message,
     },
     typed::{
-        ErasedPrefix, Hash,
-        height::{Height, S, Z},
+        Hash,
+        height::{Height, Z},
     },
 };
 
-/// [`message::Reply`] with its height forgotten.
+/// [`message::Reply`] with its height forgotten: what the walk's workers
+/// produce and consume.
 pub(crate) struct Reply {
     pub replies: Vec>,
 }
@@ -57,164 +72,9 @@ pub(crate) enum Reaction {
     Query(Vec<(u8, Hash)>),
 }
 
-/// [`materialized::Query`] with its height forgotten.
-pub(crate) struct Query {
-    pub prefix: ErasedPrefix,
-    pub ours: Vec<(u8, E)>,
-}
-
-/// [`materialized::Resolution`] with its height forgotten.
-pub(crate) struct Resolution {
-    pub prefix: ErasedPrefix,
-    pub resolved: Vec<(u8, Resolve)>,
-}
-
-/// [`materialized::Resolve`] with its height forgotten.
-pub(crate) enum Resolve {
-    Ready(Option),
-    Pending,
-}
-
-/// Shorthand for the erased node representation of one backend.
-type ErasedOf = >::Erased;
-
-/// The typed halves of the outgoing-response edge
-/// ([`reply_channel`]): items are whole replies or the error that ends
-/// the stream.
-pub(crate) type ReplyResultSender = TypedSender<
-    Result, Error<>::Error>>,
-    Result>, Error<>::Error>>,
->;
-/// The receiving half of [`reply_channel`], as the response stream shape.
-pub(crate) type ReplyResultStream = TypedStream<
-    Result, Error<>::Error>>,
-    Result>, Error<>::Error>>,
->;
-/// The typed sending half of a query edge ([`query_channel`]).
-pub(crate) type QuerySender =
-    TypedSender, Query>>;
-/// The typed receiving half of a query edge ([`query_channel`]).
-pub(crate) type QueryReceiver =
-    TypedReceiver, Query>>;
-/// The typed sending half of a return edge ([`return_channel`],
-/// [`return_ok_channel`]): one reconciled node per query, in query order.
-pub(crate) type ReturnSender =
-    TypedSender>::Node>, Option>>;
-/// The typed receiving half of [`return_channel`].
-pub(crate) type ReturnReceiver =
-    TypedReceiver>::Node>, Option>>;
-/// The receiving half of [`return_ok_channel`], as an `Ok`-wrapping stream.
-pub(crate) type ReturnOkStream = TypedOkStream<
-    Option<>::Node>,
-    Option>,
-    Error<>::Error>,
->;
-/// The typed sending half of a resolution edge ([`resolution_ok_channel`]).
-pub(crate) type ResolutionSender =
-    TypedSender, Resolution>>;
-/// The receiving half of [`resolution_ok_channel`], as an `Ok`-wrapping
-/// stream.
-pub(crate) type ResolutionOkStream = TypedOkStream<
-    materialized::Resolution,
-    Resolution>,
-    Error<>::Error>,
->;
-
-/// Mint the outgoing-response edge: a typed sender and the typed response
-/// stream its receiver drains into.
-pub(crate) fn reply_channel(
-    role: QueueRole,
-    capacity: usize,
-) -> (ReplyResultSender, ReplyResultStream)
-where
-    B: Backend: Leaf>,
-    T: Send + Sync + 'static,
-    H: Height,
-{
-    let (sender, receiver) = channel(role, capacity);
-    (
-        TypedSender::new(sender, |item: Result<_, _>| {
-            item.map(erase_reply::)
-        }),
-        TypedStream::new(receiver, |item: Result<_, _>| {
-            item.map(assume_reply::)
-        }),
-    )
-}
-
-/// Mint one query edge at height `H` (the children's height; the scope
-/// sits at `S`).
-pub(crate) fn query_channel(
-    role: QueueRole,
-    capacity: usize,
-) -> (QuerySender, QueryReceiver)
-where
-    B: Backend: Leaf>,
-    T: Send + Sync + 'static,
-    H: Height,
-    S: Height,
-{
-    let (sender, receiver) = channel(role, capacity);
-    (
-        TypedSender::new(sender, erase_query::),
-        TypedReceiver::new(receiver, assume_query::),
-    )
-}
-
-/// Mint one return edge at height `H`, received item by item.
-pub(crate) fn return_channel(
-    role: QueueRole,
-    capacity: usize,
-) -> (ReturnSender, ReturnReceiver)
-where
-    B: Backend: Leaf>,
-    T: Send + Sync + 'static,
-    H: Height,
-{
-    let (sender, receiver) = channel(role, capacity);
-    (
-        TypedSender::new(sender, erase_return::),
-        TypedReceiver::new(receiver, assume_return::),
-    )
-}
-
-/// Mint one return edge at height `H`, received as an `Ok`-wrapping stream.
-pub(crate) fn return_ok_channel(
-    role: QueueRole,
-    capacity: usize,
-) -> (ReturnSender, ReturnOkStream)
-where
-    B: Backend: Leaf>,
-    T: Send + Sync + 'static,
-    H: Height,
-{
-    let (sender, receiver) = channel(role, capacity);
-    (
-        TypedSender::new(sender, erase_return::),
-        TypedOkStream::new(receiver, assume_return::),
-    )
-}
-
-/// Mint one resolution edge at height `H`, received as an `Ok`-wrapping
-/// stream.
-pub(crate) fn resolution_ok_channel(
-    role: QueueRole,
-    capacity: usize,
-) -> (ResolutionSender, ResolutionOkStream)
-where
-    B: Backend: Leaf>,
-    T: Send + Sync + 'static,
-    H: Height,
-    S: Height,
-{
-    let (sender, receiver) = channel(role, capacity);
-    (
-        TypedSender::new(sender, erase_resolution::),
-        TypedOkStream::new(receiver, assume_resolution::),
-    )
-}
-
-fn erase_reply(reply: message::Reply) -> Reply>
+/// 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>,
     T: Send + Sync + 'static,
@@ -233,7 +93,8 @@ where
     }
 }
 
-fn assume_reply(reply: Reply>) -> message::Reply
+/// Re-tag one erased reply at the typed exit of [`reply_channel`].
+fn assume_reply(reply: Reply) -> message::Reply
 where
     B: Backend: Leaf>,
     T: Send + Sync + 'static,
@@ -252,174 +113,33 @@ where
     }
 }
 
-fn erase_query(query: materialized::Query) -> Query>
-where
-    B: Backend: Leaf>,
-    T: Send + Sync + 'static,
-    H: Height,
-    S: Height,
-{
-    Query {
-        prefix: query.prefix.erase(),
-        ours: query
-            .ours
-            .into_iter()
-            .map(|(radix, node)| (radix, B::erase(node)))
-            .collect(),
-    }
-}
-
-fn assume_query(query: Query>) -> materialized::Query
-where
-    B: Backend: Leaf>,
-    T: Send + Sync + 'static,
-    H: Height,
-    S: Height,
-{
-    materialized::Query {
-        prefix: query.prefix.assume(),
-        ours: query
-            .ours
-            .into_iter()
-            .map(|(radix, node)| (radix, B::assume(node)))
-            .collect(),
-    }
-}
-
-fn erase_resolution(
-    resolution: materialized::Resolution,
-) -> Resolution>
-where
-    B: Backend: Leaf>,
-    T: Send + Sync + 'static,
-    H: Height,
-    S: Height,
-{
-    Resolution {
-        prefix: resolution.prefix.erase(),
-        resolved: resolution
-            .resolved
-            .into_iter()
-            .map(|(radix, slot)| {
-                (
-                    radix,
-                    match slot {
-                        materialized::Resolve::Ready(node) => Resolve::Ready(node.map(B::erase)),
-                        materialized::Resolve::Pending => Resolve::Pending,
-                    },
-                )
-            })
-            .collect(),
-    }
-}
-
-fn assume_resolution(
-    resolution: Resolution>,
-) -> materialized::Resolution
-where
-    B: Backend: Leaf>,
-    T: Send + Sync + 'static,
-    H: Height,
-    S: Height,
-{
-    materialized::Resolution {
-        prefix: resolution.prefix.assume(),
-        resolved: resolution
-            .resolved
-            .into_iter()
-            .map(|(radix, slot)| {
-                (
-                    radix,
-                    match slot {
-                        Resolve::Ready(node) => {
-                            materialized::Resolve::Ready(node.map(B::assume::))
-                        }
-                        Resolve::Pending => materialized::Resolve::Pending,
-                    },
-                )
-            })
-            .collect(),
-    }
-}
-
-fn erase_return(node: Option>) -> Option>
+/// The typed exit of [`reply_channel`]: the erased receiver as a stream
+/// of schedule-typed replies.
+pub(crate) struct ReplyResultStream
 where
     B: Backend: Leaf>,
     T: Send + Sync + 'static,
     H: Height,
 {
-    node.map(B::erase)
+    inner: ReceiverStreamOf, Error>>,
+    assume: fn(
+        Result, Error>,
+    ) -> Result, Error>,
 }
 
-fn assume_return(node: Option>) -> Option>
+impl Stream for ReplyResultStream
 where
     B: Backend: Leaf>,
     T: Send + Sync + 'static,
     H: Height,
 {
-    node.map(B::assume::)
-}
-
-// --------------------------------------------------------------------------------
-// The typed facades: each pairs one erased channel half with the fixed
-// conversion its constructor minted it with.
-// --------------------------------------------------------------------------------
-
-/// The typed sending half of an erased channel.
-pub(crate) struct TypedSender {
-    inner: Sender,
-    erase: fn(M) -> E,
-}
-
-impl Clone for TypedSender {
-    fn clone(&self) -> Self {
-        Self {
-            inner: self.inner.clone(),
-            erase: self.erase,
-        }
-    }
-}
-
-impl TypedSender {
-    fn new(inner: Sender, erase: fn(M) -> E) -> Self {
-        Self { inner, erase }
-    }
-
-    /// Send one typed item, erased in place.
-    ///
-    /// Like the underlying channel's send: an error means the receiving
-    /// half is gone, and the producer should wind down.
-    pub(crate) async fn send(&self, message: M) -> Result<(), ClosedChannel> {
-        self.inner
-            .send((self.erase)(message))
-            .await
-            .map_err(|_| ClosedChannel)
-    }
-}
-
-/// The receiver of a typed send has hung up; the payload is dropped.
-///
-/// The typed sender cannot return the underlying
-/// [`SendError`](tokio::sync::mpsc::error::SendError) because that hands
-/// back the *erased* payload; no caller inspects it — a failed send means
-/// "stop producing" on every edge.
-#[derive(Debug)]
-pub(crate) struct ClosedChannel;
-
-/// The typed receiving half of an erased channel.
-pub(crate) struct TypedReceiver {
-    inner: Receiver,
-    assume: fn(E) -> M,
-}
-
-impl TypedReceiver {
-    fn new(inner: Receiver, assume: fn(E) -> M) -> Self {
-        Self { inner, assume }
-    }
+    type Item = Result, Error>;
 
-    /// Receive one typed item, re-tagged in place.
-    pub(crate) async fn recv(&mut self) -> Option {
-        self.inner.recv().await.map(self.assume)
+    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))
     }
 }
 
@@ -443,57 +163,121 @@ fn receiver_stream(receiver: Receiver) -> ReceiverStreamOf {
     }
 }
 
-/// An erased channel's receiving half as a stream of typed items.
-pub(crate) struct TypedStream {
-    inner: ReceiverStreamOf,
-    assume: fn(E) -> M,
-}
-
-impl TypedStream {
-    fn new(inner: Receiver, assume: fn(E) -> M) -> Self {
-        Self {
-            inner: receiver_stream(inner),
-            assume,
-        }
-    }
+/// 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, Error>>,
+    ReplyResultStream,
+)
+where
+    B: Backend: Leaf>,
+    T: Send + Sync + 'static,
+    H: Height,
+{
+    let (sender, receiver) = channel(role, capacity);
+    (
+        sender,
+        ReplyResultStream {
+            inner: receiver_stream(receiver),
+            assume: |item| item.map(assume_reply::),
+        },
+    )
 }
 
-impl Stream for TypedStream {
-    type Item = M;
-
-    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))
+/// 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 super::*;
+    use crate::tree::{
+        mirror::streaming::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"),
+                }
+            })
+        }};
     }
-}
-
-/// An erased channel's receiving half as a stream of `Ok`-wrapped typed
-/// items: the shape the assembly and walk consumers pull from.
-pub(crate) struct TypedOkStream {
-    inner: ReceiverStreamOf,
-    assume: fn(E) -> M,
-    error: PhantomData Err>,
-}
 
-impl TypedOkStream {
-    fn new(inner: Receiver, assume: fn(E) -> M) -> Self {
-        Self {
-            inner: receiver_stream(inner),
-            assume,
-            error: PhantomData,
-        }
+    /// 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>,
+        T: Send + Sync + 'static,
+    {
+        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())
+        })
     }
-}
-
-impl Stream for TypedOkStream {
-    type Item = Result;
 
-    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(|item| Ok((this.assume)(item))))
+    /// 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>,
+        T: Send + Sync + 'static,
+    {
+        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))
+        })
     }
 }
diff --git a/src/tree/mirror/streaming/materialized.rs b/src/tree/mirror/streaming/materialized.rs
index 991ddba7a..c283c0aac 100644
--- a/src/tree/mirror/streaming/materialized.rs
+++ b/src/tree/mirror/streaming/materialized.rs
@@ -105,17 +105,17 @@ use std::sync::atomic::{AtomicU64, Ordering};
 use crate::tree::{
     mirror::contained,
     mirror::streaming::{
-        Backend, Leaf, Node, Root,
-        erased::{QueryReceiver, ReturnSender},
-        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},
     },
 };
@@ -180,7 +180,8 @@ mod tests;
 pub(super) mod transcript;
 pub(super) mod unknown;
 mod work;
-use channel::Receiver;
+use channel::{Receiver, Sender};
+use common::*;
 // The remote proxy explodes early-supplied whole root children into the
 // same per-child shape the walks consume, with the walks' own helper.
 pub(crate) use common::children_of;
@@ -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.
-    pub(crate) prefix: Prefix>,
+    pub(crate) prefix: ErasedPrefix,
     /// The possibly-resolved children of the node.
-    pub(crate) 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,
 }
@@ -330,7 +328,10 @@ pub struct Start {
 /// second time (the memory model's one-query-per-prefix rule).
 pub struct Connecting: Leaf>, T: Send + Sync + 'static> {
     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
@@ -349,7 +350,8 @@ 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)
@@ -364,13 +366,18 @@ where
     /// absorbed supply ([`SupplyLedger`]).
     ledger: SupplyLedger,
     /// The questions we asked, awaiting their replies in order.
-    queries: QueryReceiver,
+    ///
+    /// 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: ReturnSender>,
+    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,
     /// Resolves to this side's reconciled root once the top return arrives.
     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
@@ -403,7 +416,7 @@ 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: ReturnSender,
+    returns: Sender>,
     /// The accumulated work to drive the pipeline.
     work: Work,
     /// The future result of the pipeline.
@@ -469,9 +482,11 @@ impl: Leaf>, T: Send + Sync + 'static, V: Send> protoco
 pub(crate) async fn greeting_fan: Leaf>, T: Send + Sync + 'static>(
     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,9 +499,7 @@ 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()
@@ -635,6 +648,7 @@ impl: Leaf> + Sync, T: Send + Sync + 'static> protocol:
                 early_supplies: None,
                 work,
                 finish,
+                height: std::marker::PhantomData,
             },
         )
     }
@@ -688,6 +702,7 @@ impl: Leaf> + Sync, T: Send + Sync + 'static> protocol:
                 early_supplies: Some(early),
                 work,
                 finish,
+                height: std::marker::PhantomData,
             },
         )
     }
@@ -707,9 +722,9 @@ impl protocol::Reply for Descending>>
 where
     B: Backend: Leaf> + Sync,
     T: Send + Sync + 'static,
-    H: Unknown,
-    S: Unknown,
-    S>: Unknown,
+    H: Height,
+    S: Height,
+    S>: Height,
     S>>: Height,
 {
     type Next = Descending;
@@ -718,7 +733,7 @@ where
         mut self,
         requests: impl Requests>>,
     ) -> (BoxResponses, Self::Error>, Self::Next) {
-        let (responses, queries, upper, lower) = self.work.internal_level(
+        let (responses, queries, upper, lower) = self.work.internal_level::(
             self.their_version.clone(),
             self.ledger.clone(),
             self.early_survivors.take(),
@@ -726,8 +741,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,6 +755,7 @@ where
                 early_supplies: None,
                 work: self.work,
                 finish: self.finish,
+                height: std::marker::PhantomData,
             },
         )
     }
@@ -766,8 +782,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,
@@ -821,10 +837,10 @@ where
         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,
@@ -858,9 +874,9 @@ where
 async fn absorb(
     their_version: Version,
     ledger: SupplyLedger,
-    requests: impl Requests,
+    requests: impl futures::Stream> + Send,
     mut queries: Receiver>,
-    returns: ReturnSender,
+    returns: Sender>,
     stats: Recorder,
 ) -> Result<(), Error>
 where
diff --git a/src/tree/mirror/streaming/materialized/common.rs b/src/tree/mirror/streaming/materialized/common.rs
index 4eb983357..2f019607b 100644
--- a/src/tree/mirror/streaming/materialized/common.rs
+++ b/src/tree/mirror/streaming/materialized/common.rs
@@ -1,6 +1,8 @@
 use std::pin::pin;
 
-use futures::StreamExt as _;
+use futures::{StreamExt as _, stream};
+#[cfg(not(test))]
+use tokio_stream::wrappers::ReceiverStream;
 
 use crate::tree::{
     mirror::streaming::{Backend, Leaf},
@@ -10,6 +12,8 @@ use crate::tree::{
     },
 };
 
+use super::channel::{QueueRole, Receiver, Sender, channel};
+
 /// Collect one node's children, addressed by radix.
 pub async fn children_of(
     backend: &B,
@@ -31,3 +35,32 @@ where
     }
     Ok(fan)
 }
+
+/// Create a pair of a sender and a receiver stream, where the receiver
+/// wraps items in `Ok`.
+pub fn ok_channel(
+    role: QueueRole,
+    buffer: usize,
+) -> (Sender, OkReceiverStream) {
+    ok_channel_with(channel(role, buffer))
+}
+
+fn ok_channel_with(
+    (tx, rx): (Sender, Receiver),
+) -> (Sender, OkReceiverStream) {
+    #[cfg(test)]
+    {
+        (tx, rx.map(Ok))
+    }
+    #[cfg(not(test))]
+    {
+        (tx, ReceiverStream::new(rx).map(Ok))
+    }
+}
+
+/// The type of a receiver stream wrapping items in `Ok`.
+#[cfg(test)]
+pub type OkReceiverStream = stream::Map, fn(T) -> Result>;
+/// The type of a receiver stream wrapping items in `Ok`.
+#[cfg(not(test))]
+pub type OkReceiverStream = stream::Map, fn(T) -> Result>;
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 4845fd9e6..18c45f43f 100644
--- a/src/tree/mirror/streaming/materialized/tests.rs
+++ b/src/tree/mirror/streaming/materialized/tests.rs
@@ -22,10 +22,7 @@ use crate::{
     message::Message,
     tree::{
         arb::nth_party,
-        mirror::streaming::{
-            Local,
-            message::{Reaction, Reply},
-        },
+        mirror::streaming::{Backend, Local},
         typed::{
             self, Path, Prefix,
             height::{Height, Z},
@@ -65,14 +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) = erased::return_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::(
@@ -83,7 +83,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..4a5f19d47 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,54 @@ 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, T>(
     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,
 {
-    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 +130,21 @@ 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,
 {
     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 +157,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..cc5151143 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},
     },
@@ -54,14 +54,15 @@ fn stream_prune(
     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 201f131f7..0a41b15e2 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,10 +27,9 @@ mod resolver;
 use super::{progress, transcript};
 use crate::tree::{
     mirror::streaming::{
-        Backend, Leaf,
-        erased::ReturnSender,
-        materialized::Error,
-        protocol::{BoxResponses, Responses},
+        Backend, Leaf, erased,
+        materialized::{Error, channel::Sender},
+        protocol::BoxResponses,
         stats::Recorder,
         tasks::{complete, park_after_published_error},
         window::Window,
@@ -79,47 +84,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>,
+        messages: impl Stream, Error>> + Send + 'static,
     ) -> 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
+        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: ReturnSender,
-        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(());
@@ -138,5 +127,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..d3bdc5041 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,25 @@ 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,
 {
     let mut reactions = Vec::new();
     let mut asked = Vec::new();
@@ -68,7 +66,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 +111,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 +147,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 +170,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 728aec2ec..c35113f2b 100644
--- a/src/tree/mirror/streaming/materialized/work/assembly.rs
+++ b/src/tree/mirror/streaming/materialized/work/assembly.rs
@@ -10,11 +10,11 @@ use super::{Work, queues::assembly_level_returns};
 use crate::tree::{
     mirror::streaming::{
         Backend, Leaf,
-        erased::ReturnSender,
-        materialized::{Error, Resolution, Resolve},
+        erased::ops,
+        materialized::{Error, Resolution, Resolve, channel::Sender},
         tasks::next_or_cancelled,
     },
-    typed::height::{Height, S, Z},
+    typed::height::Z,
 };
 
 impl Work
@@ -24,19 +24,19 @@ where
 {
     /// 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: ReturnSender>,
-        resolutions: impl Stream, Error>> + Send + 'static,
-    ) -> ReturnSender
-    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),
@@ -47,8 +47,8 @@ where
     /// Assemble leaf resolutions upward with no level beneath them.
     pub fn assemble_leaves(
         &mut self,
-        returns: ReturnSender>,
-        resolutions: impl Stream, Error>> + Send + 'static,
+        returns: Sender>,
+        resolutions: impl Stream, Error>> + Send + 'static,
     ) {
         self.return_into(
             returns,
@@ -62,13 +62,14 @@ 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>,
+    T: Send + Sync + 'static,
 {
     try_stream! {
         let mut level = pin!(level.fuse());
@@ -86,7 +87,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 2bc5ca0b2..2254aef58 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,16 +21,15 @@ use crate::tree::mirror::streaming::materialized::progress;
 use crate::tree::{
     mirror::contained,
     mirror::streaming::{
-        Backend, Leaf, Node, Root,
-        erased::{QueryReceiver, ResolutionOkStream, ReturnSender},
+        Backend, ErasedNode, Leaf, Root,
+        erased::{self, Reaction, Reply},
         materialized::{
-            Error, Query, Resolution, Resolve, SupplyLedger, Violation,
-            channel::Receiver,
-            children_of, fan_listing,
-            unknown::{Unknown, unknown, unknown_providing},
+            Error, OkReceiverStream, Query, Resolution, Resolve, SupplyLedger, Violation,
+            channel::{Receiver, Sender},
+            fan_listing,
+            unknown::{unknown, unknown_providing},
             violation,
         },
-        message::{self, Reaction, Reply},
         protocol::{BoxResponses, Requests},
         tasks::next_or_cancelled,
     },
@@ -34,6 +39,11 @@ use crate::tree::{
     },
 };
 
+/// 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>,
@@ -55,23 +65,24 @@ 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>,
-        QueryReceiver,
-        ReturnSender,
-        oneshot::Receiver>)>>,
+        Receiver>,
+        Sender>,
+        oneshot::Receiver)>>,
         BoxFuture<'static, Result, Error>>,
     )
     where
         B: Sync,
     {
-        let (queries, queries_rx) = initiator_root_query();
+        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();
@@ -80,6 +91,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.
@@ -97,10 +109,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));
             }
@@ -108,14 +120,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)]
@@ -127,11 +139,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,
@@ -160,40 +178,43 @@ where
         their_version: Version,
         ledger: SupplyLedger,
         ceiling: Version,
-        fan: Vec<(u8, B::Node)>,
+        fan: Vec<(u8, B::Erased)>,
         requests: impl Requests,
     ) -> (
         BoxResponses>,
-        QueryReceiver,
-        ReturnSender,
-        oneshot::Receiver)>)>>,
+        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
@@ -206,7 +227,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
@@ -214,13 +235,13 @@ 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;
@@ -234,14 +255,29 @@ where
             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
@@ -263,38 +299,86 @@ where
         &mut self,
         their_version: Version,
         ledger: SupplyLedger,
-        early_survivors: Option>>>)>>>,
-        early_supplies: Option>>)>)>>>,
+        early_survivors: Option)>>>,
+        early_supplies: Option)>>>,
         requests: impl Requests>>,
-        mut queries: QueryReceiver>>,
+        queries: Receiver>,
     ) -> (
         BoxResponses, Error>,
-        QueryReceiver,
-        ResolutionOkStream>>,
-        ResolutionOkStream>,
+        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,
+    ) -> (
+        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)?;
@@ -343,7 +427,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;
@@ -389,7 +474,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,
@@ -426,7 +511,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.
@@ -435,12 +520,36 @@ where
         their_version: Version,
         ledger: SupplyLedger,
         requests: impl Requests>,
-        mut queries: QueryReceiver>,
+        queries: Receiver>,
     ) -> (
         BoxResponses>,
         Receiver>,
-        ResolutionOkStream>,
-        ResolutionOkStream,
+        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>,
+    ) -> (
+        impl Stream, Error>> + Send + 'static + use,
+        Receiver>,
+        OkReceiverStream, Error>,
+        OkReceiverStream, Error>,
     )
     where
         B: Sync,
@@ -448,19 +557,21 @@ 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;
@@ -483,7 +594,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!(
@@ -513,7 +624,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.
@@ -522,24 +633,42 @@ where
         their_version: Version,
         ledger: SupplyLedger,
         requests: impl Requests,
-        mut queries: QueryReceiver,
+        queries: Receiver>,
     ) -> (
         BoxResponses>,
-        ResolutionOkStream,
+        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>,
+    ) -> (
+        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;
@@ -568,6 +697,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 750b76c14..dd36a5206 100644
--- a/src/tree/mirror/streaming/materialized/work/queues.rs
+++ b/src/tree/mirror/streaming/materialized/work/queues.rs
@@ -1,11 +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. The channels beneath carry the
-//! height-erased payload twins — one channel-machinery instantiation per
-//! backend rather than one per height — behind typed facades minted per
-//! edge (see [`erased`]).
+//! 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
@@ -22,15 +24,12 @@
 use crate::tree::{
     mirror::streaming::{
         Backend, Leaf,
-        erased::{
-            self, QueryReceiver, QuerySender, ResolutionOkStream, ResolutionSender, ReturnOkStream,
-            ReturnReceiver, ReturnSender,
-        },
+        erased::{self, Reply, ReplyResultStream},
         materialized::{
-            Error,
+            Error, OkReceiverStream, Query, Resolution,
             channel::{QueueKind, QueueRole, Receiver, Sender, channel},
+            ok_channel,
         },
-        protocol::BoxResponses,
         window::FAN,
     },
     typed::{
@@ -45,21 +44,21 @@ use crate::tree::{
 /// and consuming that reply is sufficient to release the producer. More slots
 /// retain whole messages without breaking another dependency.
 pub(super) fn outgoing_responses() -> (
-    erased::ReplyResultSender,
-    BoxResponses>,
+    Sender, Error>>,
+    ReplyResultStream,
 )
 where
     B: Backend: Leaf>,
     T: Send + Sync + 'static,
     H: Height,
 {
-    let (sender, responses) =
-        erased::reply_channel(QueueRole::new(QueueKind::OutgoingResponses, H::HEIGHT), 1);
-    (sender, Box::pin(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
@@ -80,29 +79,29 @@ 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() -> (ReturnSender, ReturnOkStream)
+pub(super) fn assembly_level_returns(
+    height: usize,
+) -> (
+    Sender>,
+    OkReceiverStream, Error>,
+)
 where
     B: Backend: Leaf>,
     T: Send + Sync + 'static,
-    H: Height,
 {
-    erased::return_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()
--> (QuerySender, QueryReceiver)
+pub(super) fn initiator_root_query() -> (Sender>, Receiver>)
 where
     B: Backend: Leaf>,
     T: Send + Sync + 'static,
 {
-    erased::query_channel(
+    channel(
         QueueRole::new(QueueKind::InitiatorRootQuery, UnderRoot::HEIGHT),
         1,
     )
@@ -112,12 +111,13 @@ where
 ///
 /// Reconciliation produces exactly one root node and the terminal future
 /// consumes it directly.
-pub(super) fn initiator_root_return() -> (ReturnSender, ReturnReceiver)
+pub(super) fn initiator_root_return()
+-> (Sender>, Receiver>)
 where
     B: Backend: Leaf>,
     T: Send + Sync + 'static,
 {
-    erased::return_channel::(
+    channel(
         QueueRole::new(QueueKind::InitiatorRootReturn, Root::HEIGHT),
         1,
     )
@@ -128,19 +128,16 @@ where
 /// The opening wire reply and root resolution are published before these
 /// queries, so one slot is the liveness floor. The window widens it so the next
 /// stage can hold a pipeline of disputed children in flight; each buffered
-/// [`Query`](crate::tree::mirror::streaming::materialized::Query) may own a
-/// fan of node handles, which is priced by the window's node budget.
+/// [`Query`] may own a fan of node handles, which is priced by the window's
+/// node budget.
 pub(super) fn responder_child_queries(
     capacity: usize,
-) -> (
-    QuerySender,
-    QueryReceiver,
-)
+) -> (Sender>, Receiver>)
 where
     B: Backend: Leaf>,
     T: Send + Sync + 'static,
 {
-    erased::query_channel(
+    channel(
         QueueRole::new(QueueKind::ResponderChildQueries, UnderUnderRoot::HEIGHT),
         capacity,
     )
@@ -151,14 +148,14 @@ where
 /// The responder processes exactly one opening request and therefore
 /// publishes exactly one resolution for the root scope.
 pub(super) fn responder_root_resolution() -> (
-    ResolutionSender,
-    ResolutionOkStream,
+    Sender>,
+    OkReceiverStream, Error>,
 )
 where
     B: Backend: Leaf>,
     T: Send + Sync + 'static,
 {
-    erased::resolution_ok_channel(
+    ok_channel(
         QueueRole::new(QueueKind::ResponderRootResolution, UnderRoot::HEIGHT),
         1,
     )
@@ -170,14 +167,14 @@ where
 /// 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() -> (
-    ReturnSender,
-    ReturnOkStream,
+    Sender>,
+    OkReceiverStream, Error>,
 )
 where
     B: Backend: Leaf>,
     T: Send + Sync + 'static,
 {
-    erased::return_ok_channel::(
+    ok_channel(
         QueueRole::new(QueueKind::ResponderRootReturns, UnderRoot::HEIGHT),
         1,
     )
@@ -185,68 +182,74 @@ 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,
-) -> (QuerySender, QueryReceiver)
+) -> (Sender>, Receiver>)
 where
     B: Backend: Leaf>,
     T: Send + Sync + 'static,
-    H: Height,
-    S: Height,
 {
-    erased::query_channel(
-        QueueRole::new(QueueKind::InternalChildQueries, H::HEIGHT),
+    channel(
+        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,
 ) -> (
-    ResolutionSender>>,
-    ResolutionOkStream>>,
+    Sender>,
+    OkReceiverStream, Error>,
 )
 where
     B: Backend: Leaf>,
     T: Send + Sync + 'static,
-    H: Height,
-    S: Height,
-    S>: Height,
-    S>>: Height,
 {
-    erased::resolution_ok_channel(
-        QueueRole::new(QueueKind::InternalParentResolutions, >>::HEIGHT),
+    ok_channel(
+        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,
-) -> (ResolutionSender>, ResolutionOkStream>)
+) -> (
+    Sender>,
+    OkReceiverStream, Error>,
+)
 where
     B: Backend: Leaf>,
     T: Send + Sync + 'static,
-    H: Height,
-    S: Height,
-    S>: Height,
 {
-    erased::resolution_ok_channel(
-        QueueRole::new(QueueKind::InternalChildResolutions, >::HEIGHT),
+    ok_channel(
+        QueueRole::new(QueueKind::InternalChildResolutions, height),
         capacity,
     )
 }
@@ -256,9 +259,6 @@ where
 /// The corresponding leaf-scope resolution is published first, so one slot is
 /// the liveness floor. This queue is the leaf-height question window: its
 /// capacity is how many requested leaves may await the peer's supplies at once.
-///
-/// The one materialized edge with no erased twin: its item is already the
-/// single-height [`Prefix`].
 pub(super) fn leaf_requests(capacity: usize) -> (Sender>, Receiver>) {
     channel(QueueRole::new(QueueKind::LeafRequests, Z::HEIGHT), capacity)
 }
@@ -271,12 +271,15 @@ pub(super) fn leaf_requests(capacity: usize) -> (Sender>, Receiver
(
     capacity: usize,
-) -> (ResolutionSender>, ResolutionOkStream>)
+) -> (
+    Sender>,
+    OkReceiverStream, Error>,
+)
 where
     B: Backend: Leaf>,
     T: Send + Sync + 'static,
 {
-    erased::resolution_ok_channel(
+    ok_channel(
         QueueRole::new(QueueKind::LeafParentResolutions, >::HEIGHT),
         capacity,
     )
@@ -290,12 +293,15 @@ where
 /// scopes await their supplies.
 pub(super) fn leaf_child_resolutions(
     capacity: usize,
-) -> (ResolutionSender, ResolutionOkStream)
+) -> (
+    Sender>,
+    OkReceiverStream, Error>,
+)
 where
     B: Backend: Leaf>,
     T: Send + Sync + 'static,
 {
-    erased::resolution_ok_channel(
+    ok_channel(
         QueueRole::new(QueueKind::LeafChildResolutions, Z::HEIGHT),
         capacity,
     )
@@ -312,13 +318,15 @@ 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()
--> (ResolutionSender, ResolutionOkStream)
+pub(super) fn terminal_leaf_resolutions() -> (
+    Sender>,
+    OkReceiverStream, Error>,
+)
 where
     B: Backend: Leaf>,
     T: Send + Sync + 'static,
 {
-    erased::resolution_ok_channel(
+    ok_channel(
         QueueRole::new(QueueKind::TerminalLeafResolutions, Z::HEIGHT),
         FAN,
     )
diff --git a/src/tree/mirror/streaming/materialized/work/resolver.rs b/src/tree/mirror/streaming/materialized/work/resolver.rs
index 43cced73d..a5359aa50 100644
--- a/src/tree/mirror/streaming/materialized/work/resolver.rs
+++ b/src/tree/mirror/streaming/materialized/work/resolver.rs
@@ -5,28 +5,26 @@ 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, T>
 where
-    S: Height,
+    B: Backend: Leaf>,
+    T: Send + Sync + 'static,
 {
-    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 +39,13 @@ where
     stats: Recorder,
 }
 
-impl<'v, B, T, H> Resolver<'v, B, T, H>
+impl<'v, B, T> Resolver<'v, B, T>
 where
     B: Backend: Leaf>,
     T: Send + Sync + 'static,
-    H: Height,
-    S: Height,
 {
     pub fn new(
-        Query { prefix, ours }: Query,
+        Query { prefix, ours }: Query,
         their_version: &'v Version,
         ledger: &'v SupplyLedger,
         stats: Recorder,
@@ -64,10 +60,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 +110,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 +118,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..cc255ff81 100644
--- a/src/tree/mirror/streaming/materialized/work/tests.rs
+++ b/src/tree/mirror/streaming/materialized/work/tests.rs
@@ -33,6 +33,15 @@ 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<(), Z>) -> Erased {
+    >::erase(node)
+}
+
 /// A distinct leaf per call: the versions differ so hashes do.
 fn leaf(version: &mut Version) -> typed::Node<(), Z> {
     version.tick(&nth_party(0));
@@ -61,8 +70,8 @@ fn parent_of(
     pollster::block_on(Local.parent(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]
@@ -96,9 +105,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 +120,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 +153,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 +176,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 +188,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,22 +202,22 @@ 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");
@@ -242,7 +252,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 90173d0c2..b52e40df5 100644
--- a/src/tree/mirror/streaming/materialized/work/tests/violations.rs
+++ b/src/tree/mirror/streaming/materialized/work/tests/violations.rs
@@ -10,10 +10,10 @@ use crate::tree::mirror::streaming::stats::Recorder;
 use crate::{
     Version,
     tree::mirror::streaming::{
-        Local,
-        erased::QueryReceiver,
+        Backend, Local,
         materialized::{
-            Error, Query, SupplyLedger, Violation, Work, channel::with_schedule, unknown::Unknown,
+            Error, Query, SupplyLedger, Violation, Work,
+            channel::{Receiver, with_schedule},
             work::queues::internal_child_queries,
         },
         message::{Reaction, Reply},
@@ -25,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)]
@@ -98,11 +102,7 @@ fn violation_script(
     injection: Injection,
     parent: u8,
     radixes: &BTreeSet,
-) -> (
-    Option>,
-    Vec>,
-    Version,
-)
+) -> (Option>, Vec>, Version)
 where
     H: TestHeight,
     S: Height,
@@ -119,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 = || {
@@ -167,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 {
@@ -184,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 {
@@ -196,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>) -> QueryReceiver
+/// 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");
     }
@@ -242,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,
@@ -257,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,
@@ -271,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/window.rs b/src/tree/mirror/streaming/window.rs
index e06bf9310..08ff08dd3 100644
--- a/src/tree/mirror/streaming/window.rs
+++ b/src/tree/mirror/streaming/window.rs
@@ -16,7 +16,7 @@
 //! capacities** using what the two replicas exchange in their greetings —
 //! exact set sizes and version-size bounds — priced through the storage
 //! backend's own cost function
-//! ([`Backend::node_bytes`](super::Backend::node_bytes)). Channels stay
+//! ([`Backend::node_bytes`]). Channels stay
 //! plain bounded queues; the [link](crate::link) remains the only
 //! backpressure boundary with runtime semantics.
 //!
@@ -120,7 +120,7 @@
 //!   its own producer — the premise the session's whole liveness argument
 //!   already rests on.
 
-use super::{Local, materialized::Resolve};
+use super::{Backend, Local, materialized::Resolve};
 use crate::link::STREAM_COUNT;
 use crate::tree::typed::{self, Prefix, height::Z};
 
@@ -152,7 +152,7 @@ const KEY_DEPTH: usize = 32;
 /// layout pads the real slots beyond this constant and owes that padding
 /// to its own `node_bytes` price.
 const REFERENCE_SLOT_BYTES: usize = std::mem::size_of::<(u8, typed::Node<(), Z>)>()
-    + std::mem::size_of::<(u8, Resolve)>()
+    + std::mem::size_of::<(u8, Resolve<>::Erased>)>()
     + std::mem::size_of::<(u8, typed::Hash)>();
 
 /// Fixed in-memory bytes per buffered scope beyond its per-child slots:
@@ -328,7 +328,7 @@ impl Window {
     /// materialized, so the pair sum there is a priced envelope, pinned
     /// against reality by the census suite's reconciled-bound
     /// measurements. `node_bytes` must be an upper bound and monotone in
-    /// both arguments ([`Backend::node_bytes`](super::Backend::node_bytes)),
+    /// both arguments ([`Backend::node_bytes`]),
     /// so evaluating it at quantiles keeps the whole charge an upper
     /// bound; monotonicity is spot-checked here in debug builds.
     pub(crate) fn from_budget(
diff --git a/src/tree/typed/height.rs b/src/tree/typed/height.rs
index 854bca832..b9c47ac63 100644
--- a/src/tree/typed/height.rs
+++ b/src/tree/typed/height.rs
@@ -132,6 +132,39 @@ pub type Root =
 //  0 1 2 3 4 5 6 7 8 9 a b c d e f
     Z>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>;
 
+/// Numbered aliases `H0` (= [`Z`]) through `H32` (= [`Root`]), one per
+/// height.
+///
+/// The vocabulary a runtime-to-type dispatch table indexes with
+/// `seq_macro` ident pasting, which can splice a numbered name but cannot
+/// build an `S<…>` chain (see the erased plumbing's `at_parent_height!`).
+/// Each alias is one successor over its predecessor by construction of
+/// the emitting macro, so a name always denotes its own number's height.
+macro_rules! alias_heights {
+    (@emit $t:ty; $name:ident $($rest:ident)*) => {
+        pub(crate) type $name = $t;
+        alias_heights!(@emit S<$t>; $($rest)*);
+    };
+    (@emit $t:ty;) => {};
+    ($($name:ident)*) => {
+        alias_heights!(@emit Z; $($name)*);
+    };
+}
+
+#[rustfmt::skip]
+alias_heights!(
+    H0  H1  H2  H3  H4  H5  H6  H7
+    H8  H9  H10 H11 H12 H13 H14 H15
+    H16 H17 H18 H19 H20 H21 H22 H23
+    H24 H25 H26 H27 H28 H29 H30 H31
+    H32
+);
+
+/// The table's endpoints are what their names claim; each alias being one
+/// successor over its predecessor by construction, every name between
+/// denotes its own number's height.
+const _: () = assert!(H0::HEIGHT == 0 && H32::HEIGHT == 32);
+
 mod sealed {
     use super::*;
     pub trait Sealed {}
diff --git a/src/tree/typed/prefix.rs b/src/tree/typed/prefix.rs
index 00bb66d33..ff08226a9 100644
--- a/src/tree/typed/prefix.rs
+++ b/src/tree/typed/prefix.rs
@@ -55,6 +55,32 @@ impl ErasedPrefix {
             hash: self.hash,
         }
     }
+
+    /// The height this prefix sits at: its byte length's complement,
+    /// exactly the `H::HEIGHT` of the [`Prefix`] it erases.
+    pub fn height(&self) -> usize {
+        32 - self.hash.len()
+    }
+
+    /// The accumulated path bytes, shallowest-first ([`Prefix::as_bytes`]).
+    pub fn as_bytes(&self) -> &[u8] {
+        &self.hash
+    }
+
+    /// Push one hash byte onto the end of the prefix, descending one
+    /// height ([`Prefix::push`]).
+    ///
+    /// # Panics
+    ///
+    /// If the prefix is already at height zero (a full 32-byte path).
+    pub fn push(mut self, byte: u8) -> ErasedPrefix {
+        assert!(
+            self.height() > 0,
+            "a leaf-height prefix has no level to descend into",
+        );
+        self.hash.push(byte);
+        self
+    }
 }
 
 impl Debug for ErasedPrefix {

From d8bef16b93d1cbf782e256538dfae8bcbb57754e Mon Sep 17 00:00:00 2001
From: finch 
Date: Wed, 19 Aug 2026 17:52:12 -0400
Subject: [PATCH 04/18] streaming: erase the remote proxy's scopes, adapter,
 and pumps

The proxy joins the walk on the erased vocabulary. Scope collapses to
one type whose parent prefix is the height witness; the adapter's
encode/decode workers, the reply pumps, and the encoders are shared
bodies behind thin typed phase methods, with the decoded-reply exit
re-tagging at each stage's height exactly like the walk's respond. Two
new dispatches (erased::ops::{leaves, assemble}) carry the stream-shaped
backend operations, and the proxy's progress trace takes its heights as
the runtime data they already were.

Measured on --test pairwise (debug, default features):
1,717,836 lines / 67,158 copies -> 1,039,534 / 41,172. The
remote-labeled rows fall 837k -> 162k, tokio-mpsc-naming rows to 29k.
Cumulative from the parent: 2,884,887 / 109,613 -> 1,039,534 / 41,172
(-64% lines, -62% copies), completing design/height-erasure.md
steps 1-4. The wire snapshots are byte-identical throughout.
---
 src/tree/mirror/streaming/erased.rs           |  87 ++++++-
 .../streaming/materialized/work/queues.rs     |   7 +-
 .../mirror/streaming/remote/adapter/decode.rs | 159 +++++------
 .../mirror/streaming/remote/adapter/encode.rs |  56 ++--
 .../mirror/streaming/remote/adapter/scope.rs  |  50 ++--
 .../remote/adapter/tests/backend_errors.rs    |  13 +-
 .../remote/adapter/tests/fan_occupancy.rs     |  13 +-
 .../remote/adapter/tests/malformed.rs         |  86 +++---
 .../streaming/remote/adapter/tests/opening.rs |  54 ++--
 .../streaming/remote/adapter/tests/parking.rs |  25 +-
 .../remote/adapter/tests/properties.rs        |  93 +++----
 .../streaming/remote/adapter/tests/runs.rs    |  20 +-
 src/tree/mirror/streaming/remote/proxy.rs     |   6 +-
 .../mirror/streaming/remote/proxy/state.rs    |  21 +-
 .../mirror/streaming/remote/proxy/work.rs     |  57 ++--
 .../streaming/remote/proxy/work/encode.rs     |  70 ++---
 .../streaming/remote/proxy/work/progress.rs   |  21 +-
 .../streaming/remote/proxy/work/pump.rs       | 246 +++++++++++-------
 .../streaming/remote/proxy/work/queues.rs     |  39 ++-
 src/tree/typed/prefix.rs                      |  14 +
 20 files changed, 629 insertions(+), 508 deletions(-)

diff --git a/src/tree/mirror/streaming/erased.rs b/src/tree/mirror/streaming/erased.rs
index 16cb3defb..3fb93ec99 100644
--- a/src/tree/mirror/streaming/erased.rs
+++ b/src/tree/mirror/streaming/erased.rs
@@ -50,7 +50,6 @@ use crate::tree::{
     mirror::streaming::{
         Backend, Leaf,
         channel::{QueueRole, Receiver, Sender, channel},
-        materialized::Error,
         message,
     },
     typed::{
@@ -114,26 +113,26 @@ where
 }
 
 /// The typed exit of [`reply_channel`]: the erased receiver as a stream
-/// of schedule-typed replies.
-pub(crate) struct ReplyResultStream
+/// of schedule-typed replies. `Err` is the session's error type, passed
+/// through untouched.
+pub(crate) struct ReplyResultStream
 where
     B: Backend: Leaf>,
     T: Send + Sync + 'static,
     H: Height,
 {
-    inner: ReceiverStreamOf, Error>>,
-    assume: fn(
-        Result, Error>,
-    ) -> Result, Error>,
+    inner: ReceiverStreamOf, Err>>,
+    assume: fn(Result, Err>) -> Result, Err>,
 }
 
-impl Stream for ReplyResultStream
+impl Stream for ReplyResultStream
 where
     B: Backend: Leaf>,
     T: Send + Sync + 'static,
     H: Height,
+    Err: Send,
 {
-    type Item = Result, Error>;
+    type Item = Result, Err>;
 
     fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> {
         let this = self.get_mut();
@@ -170,17 +169,18 @@ fn receiver_stream(receiver: Receiver) -> ReceiverStreamOf {
 /// 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(
+pub(crate) fn reply_channel(
     role: QueueRole,
     capacity: usize,
 ) -> (
-    Sender, Error>>,
-    ReplyResultStream,
+    Sender, Err>>,
+    ReplyResultStream,
 )
 where
     B: Backend: Leaf>,
     T: Send + Sync + 'static,
     H: Height,
+    Err: Send,
 {
     let (sender, receiver) = channel(role, capacity);
     (
@@ -202,9 +202,13 @@ where
 /// 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::materialized::children_of as children_of_typed,
+        mirror::streaming::{
+            backend::BoxNodeStream, materialized::children_of as children_of_typed,
+        },
         typed::{ErasedPrefix, height::Pred},
     };
 
@@ -225,6 +229,19 @@ pub(crate) mod ops {
         }};
     }
 
+    /// 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).
     ///
@@ -280,4 +297,48 @@ pub(crate) mod ops {
                 .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, T, Z>
+    where
+        B: Backend: Leaf>,
+        T: Send + Sync + 'static,
+    {
+        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, T, Z>,
+    ) -> Pin> + Send>>
+    where
+        B: Backend: Leaf>,
+        T: Send + Sync + 'static,
+    {
+        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/work/queues.rs b/src/tree/mirror/streaming/materialized/work/queues.rs
index dd36a5206..fc1285e58 100644
--- a/src/tree/mirror/streaming/materialized/work/queues.rs
+++ b/src/tree/mirror/streaming/materialized/work/queues.rs
@@ -45,14 +45,17 @@ use crate::tree::{
 /// retain whole messages without breaking another dependency.
 pub(super) fn outgoing_responses() -> (
     Sender, Error>>,
-    ReplyResultStream,
+    ReplyResultStream>,
 )
 where
     B: Backend: Leaf>,
     T: Send + Sync + 'static,
     H: Height,
 {
-    erased::reply_channel::(QueueRole::new(QueueKind::OutgoingResponses, H::HEIGHT), 1)
+    erased::reply_channel::>(
+        QueueRole::new(QueueKind::OutgoingResponses, H::HEIGHT),
+        1,
+    )
 }
 
 /// Buffer lower-level completions until their enclosing resolution arrives.
diff --git a/src/tree/mirror/streaming/remote/adapter/decode.rs b/src/tree/mirror/streaming/remote/adapter/decode.rs
index d08d55011..819e637af 100644
--- a/src/tree/mirror/streaming/remote/adapter/decode.rs
+++ b/src/tree/mirror/streaming/remote/adapter/decode.rs
@@ -10,15 +10,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::{
@@ -29,13 +25,8 @@ use super::{
 
 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,18 +65,16 @@ 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
+) -> impl Stream>> + Send
 where
     B: Backend: Leaf>,
     T: DeserializeOwned + Send + Sync + 'static,
-    G: Convert,
-    S: Height,
     F: Stream> + Unpin + Send + 'static,
 {
     try_stream! {
@@ -99,8 +85,12 @@ where
         #[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 mut assembled = pin!(ops::assemble(
+            backend.clone(),
+            parent.height() - 1,
+            leaves
+        ));
+        let mut read = pin!(read_early::(
             version_bytes,
             &ledger,
             parent,
@@ -142,21 +132,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>>,
 ) -> Result<(), DecodeError>
 where
     B: Backend: Leaf>,
     T: DeserializeOwned + Send + Sync + 'static,
-    G: Height,
-    S: Height,
     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 {
@@ -211,19 +199,16 @@ 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>
+) -> Result>, DecodeError>
 where
     B: Backend: Leaf>,
     T: DeserializeOwned + Send + Sync + 'static,
-    H: Height,
-    S: Convert,
-    S>: Height,
     F: Stream> + Unpin,
 {
     decode(
@@ -245,9 +230,9 @@ pub async fn decode_leaf_reply(
     backend: B,
     version_bytes: u64,
     ledger: SupplyLedger,
-    scope: Scope,
+    scope: Scope,
     frames: &mut F,
-) -> Result>>, DecodeError>
+) -> Result>, DecodeError>
 where
     B: Backend: Leaf>,
     T: DeserializeOwned + Send + Sync + 'static,
@@ -270,22 +255,23 @@ where
     .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>
+) -> 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,
+    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 +283,8 @@ 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);
+    let assemble = assemble_supplies::(backend, children_height, rx);
     let (read, assembled) = futures::future::join(read, assemble).await;
     let Some(ReadReply {
         skeleton,
@@ -309,26 +295,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>
+) -> 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,
+    Q: FnMut(&mut Scope, &[(u8, Hash)]) -> Result,
 {
     let mut read = ReadReply::new(version_bytes);
     loop {
@@ -404,21 +388,22 @@ 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,
 {
     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, T, 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 +412,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 +438,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 +454,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 +478,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 +501,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 +539,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..a2231a9c6 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::{
@@ -60,13 +56,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 +78,15 @@ 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,
 {
     render(
         backend,
@@ -124,9 +113,9 @@ where
 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,
@@ -155,20 +144,17 @@ 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>
+    D: FnMut(&mut Scope, &ProtocolReaction) -> Result, ScopeError>
         + Send
         + 'static,
 {
@@ -200,7 +186,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 +250,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/backend_errors.rs b/src/tree/mirror/streaming/remote/adapter/tests/backend_errors.rs
index 541a44a71..0eb0dff9e 100644
--- a/src/tree/mirror/streaming/remote/adapter/tests/backend_errors.rs
+++ b/src/tree/mirror/streaming/remote/adapter/tests/backend_errors.rs
@@ -6,8 +6,8 @@ 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,
@@ -68,7 +68,8 @@ 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,11 +130,11 @@ where
                 sentinel.clone(),
             ]);
             let error = runtime
-                .block_on(decode_reply::, u64, H, _>(
+                .block_on(decode_reply::, u64, _>(
                     backend.clone(),
                     u64::MAX,
                     unbounded(),
-                    Scope::new(parent, &[]),
+                    Scope::new(parent.erase(), &[]),
                     &mut frames,
                 ))
                 .err()
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..5a3d67f5a 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},
     },
 };
 
@@ -82,11 +79,11 @@ 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,
         )
         .await
@@ -134,11 +131,11 @@ 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)),
         )
         .try_collect()
diff --git a/src/tree/mirror/streaming/remote/adapter/tests/malformed.rs b/src/tree/mirror/streaming/remote/adapter/tests/malformed.rs
index cc213e333..54cd4c4f4 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,
 };
@@ -44,7 +44,7 @@ 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,
         )
         .await
@@ -66,7 +66,7 @@ fn stream_exhaustion_before_a_boundary_is_truncation() {
             Local,
             u64::MAX,
             unbounded(),
-            Scope::new(parent, &[(0, hash(0))]),
+            Scope::new(parent.erase(), &[(0, hash(0))]),
             &mut frames,
         )
         .await
@@ -95,11 +95,11 @@ fn an_unpositioned_match_is_rejected_in_both_directions() {
 
     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,
         )
         .await
@@ -111,14 +111,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::>()
@@ -145,11 +145,11 @@ fn an_unpositioned_query_is_rejected_in_both_directions() {
 
     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,
         )
         .await
@@ -161,15 +161,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,
@@ -207,14 +212,14 @@ fn leaf_query_matrix_is_exhaustive() {
             let expected_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 +237,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,7 +248,7 @@ fn leaf_query_matrix_is_exhaustive() {
                     Local,
                     u64::MAX,
                     unbounded(),
-                    Scope::new(parent, &scope_listing),
+                    Scope::new(parent.erase(), &scope_listing),
                     &mut frames,
                 )
                 .await
@@ -255,7 +260,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")
                     };
@@ -280,7 +288,7 @@ fn stream_end_is_not_a_protocol_reply() {
             Local,
             u64::MAX,
             unbounded(),
-            Scope::new(parent, &[]),
+            Scope::new(parent.erase(), &[]),
             &mut frames,
         ))
         .err()
@@ -333,26 +341,24 @@ 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::(
-            Local,
-            u64::MAX,
-            unbounded(),
-            scope.clone(),
-            &mut input,
-        )
-        .await
-        .expect("ascending in-scope leaves assemble");
+        let decoded =
+            decode_reply::(Local, u64::MAX, unbounded(), scope.clone(), &mut input)
+                .await
+                .expect("ascending in-scope leaves assemble");
         assert_eq!(decoded.reply.replies.len(), 1);
         let [Reaction::Supply(_, node)] = decoded.reply.replies.as_slice() else {
             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())
+            .leaves(
+                supplied_prefix,
+                >::assume::(node.clone()),
+            )
             .try_collect::>()
             .await
             .expect("the local backend is infallible");
@@ -395,7 +401,7 @@ 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(),
@@ -440,7 +446,7 @@ fn leaf_scope_is_enforced_within_one_run() {
             Local,
             u64::MAX,
             unbounded(),
-            Scope::new(parent, &[]),
+            Scope::new(parent.erase(), &[]),
             &mut input,
         )
         .await
@@ -472,7 +478,7 @@ fn a_zero_length_record_fails_as_a_version_decode_error() {
 
     let error = runtime().block_on(async {
         let mut input = stream::iter(frames);
-        decode_reply::(
+        decode_reply::(
             Local,
             u64::MAX,
             unbounded(),
@@ -513,7 +519,7 @@ fn a_version_over_the_declared_bound_is_rejected() {
             Local,
             declared,
             unbounded(),
-            Scope::new(parent, &[]),
+            Scope::new(parent.erase(), &[]),
             &mut input,
         )
         .await
@@ -526,7 +532,7 @@ fn a_version_over_the_declared_bound_is_rejected() {
             Local,
             declared - 1,
             unbounded(),
-            Scope::new(parent, &[]),
+            Scope::new(parent.erase(), &[]),
             &mut input,
         )
         .await
@@ -601,7 +607,7 @@ 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),
@@ -676,7 +682,7 @@ 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(),
diff --git a/src/tree/mirror/streaming/remote/adapter/tests/opening.rs b/src/tree/mirror/streaming/remote/adapter/tests/opening.rs
index db221aed8..5f9631b25 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,6 +25,11 @@ use crate::tree::{
     },
 };
 
+/// One erased opening node over the unit payload.
+fn erased(node: typed::Node<(), UnderRoot>) -> >::Erased {
+    >::erase(node)
+}
+
 use super::{
     super::{DecodeError, OpeningError, Scope, early_supplies, opening_parts, opening_reply},
     LeafCase, hash, leaf_run, runtime, unbounded,
@@ -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,8 @@ 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 +81,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 +105,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")
     };
@@ -146,11 +152,11 @@ 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),
             )
             .try_collect(),
@@ -193,11 +199,11 @@ fn opening_supplies_past_the_declared_set_len_are_rejected() {
 
     let error = runtime()
         .block_on(async {
-            early_supplies::(
+            early_supplies::(
                 Local,
                 u64::MAX,
                 SupplyLedger::new(1),
-                Prefix::new(),
+                Prefix::new().erase(),
                 stream::iter(frames),
             )
             .try_collect::>()
@@ -220,11 +226,11 @@ fn empty_opening_supply_reply_decodes_to_nothing() {
     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),
             )
             .try_collect(),
@@ -240,11 +246,11 @@ fn second_opening_supply_reply_is_rejected() {
     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),
             )
             .try_collect::>()
@@ -261,11 +267,11 @@ fn positional_reaction_in_opening_supplies_is_rejected() {
     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),
             )
             .try_collect::>()
@@ -282,29 +288,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..798a6b5ee 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},
     },
 };
 
@@ -106,14 +103,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,7 +121,7 @@ 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(),
@@ -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,11 +208,11 @@ 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,
         ))
         .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..512034fad 100644
--- a/src/tree/mirror/streaming/remote/adapter/tests/properties.rs
+++ b/src/tree/mirror/streaming/remote/adapter/tests/properties.rs
@@ -9,7 +9,7 @@ use crate::tree::{
     mirror::streaming::{
         Backend, Local,
         convert::Convert,
-        message::{Reaction, Reply},
+        erased::{Reaction, Reply},
     },
     typed::{
         self, Hash, Prefix,
@@ -17,6 +17,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,
@@ -100,7 +104,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
@@ -132,8 +136,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 {
@@ -173,10 +177,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 +197,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 +209,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::>()
         };
@@ -249,11 +253,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)
@@ -289,7 +294,7 @@ impl AdapterHeight for Z {
             ))
             .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,7 +318,7 @@ impl AdapterHeight for Z {
                 Local,
                 u64::MAX,
                 unbounded(),
-                Scope::new(parent, &[]),
+                Scope::new(parent.erase(), &[]),
                 &mut frames,
             ))
             .err()
@@ -333,7 +338,7 @@ impl AdapterHeight for Z {
                 Local,
                 u64::MAX,
                 unbounded(),
-                Scope::new(foreign, &[]),
+                Scope::new(foreign.erase(), &[]),
                 &mut frames,
             ))
             .err()
@@ -358,11 +363,11 @@ 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(),
@@ -390,8 +395,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,7 +416,7 @@ where
                 .chain([sentinel.clone()]),
         );
         let decoded = runtime
-            .block_on(decode_reply::(
+            .block_on(decode_reply::(
                 Local,
                 u64::MAX,
                 unbounded(),
@@ -436,8 +441,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 +458,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 +469,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,7 +499,7 @@ where
 
         let mut frames = stream::iter(actual_frames);
         let decoded = runtime
-            .block_on(decode_reply::(
+            .block_on(decode_reply::(
                 Local,
                 u64::MAX,
                 unbounded(),
@@ -518,7 +523,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 +533,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,7 +566,7 @@ 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(),
@@ -575,7 +580,7 @@ where
             "height {}",
             Self::HEIGHT
         );
-        assert_mixed_reply(
+        assert_mixed_reply::(
             &decoded.reply,
             &case,
             &case.nested,
@@ -600,11 +605,11 @@ 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,
             ))
             .err()
@@ -626,11 +631,11 @@ 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,
             ))
             .err()
@@ -663,8 +668,8 @@ fn mixed_reply(
     supply_at: usize,
     supply_radix: u8,
     supply: typed::Node,
-) -> Reply {
-    let mut supply = Some(supply);
+) -> 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 {
@@ -742,7 +747,7 @@ fn mixed_publications(
 }
 
 fn assert_mixed_reply(
-    reply: &Reply,
+    reply: &Reply,
     case: &PositionalCase,
     query_listing: &[(u8, Hash)],
     supply_at: usize,
@@ -787,7 +792,7 @@ where
 }
 
 fn assert_decoded_supply(
-    reply: &Reply,
+    reply: &Reply,
     expected_radix: u8,
     expected_leaf: &LeafCase,
     runtime: &tokio::runtime::Runtime,
@@ -806,7 +811,7 @@ where
 }
 
 fn assert_node_leaf(
-    node: &typed::Node,
+    node: &ErasedU64,
     expected_leaf: &LeafCase,
     runtime: &tokio::runtime::Runtime,
 ) -> TestCaseResult
@@ -816,7 +821,7 @@ where
     let prefix = Prefix::::containing(&expected_leaf.path());
     let leaves = runtime.block_on(async {
         Local
-            .leaves(prefix, node.clone())
+            .leaves(prefix, >::assume::(node.clone()))
             .try_collect::>()
             .await
             .expect("the local backend is infallible")
@@ -866,11 +871,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
@@ -903,8 +904,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..f07359fdd 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},
     },
 };
 
@@ -111,11 +108,11 @@ 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,
         )
         .await
@@ -267,11 +264,11 @@ 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,
         )
         .await
@@ -284,7 +281,10 @@ 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())
+            .leaves(
+                prefix,
+                >::assume::(node.clone()),
+            )
             .try_collect::>()
             .await
             .expect("the local backend is infallible")
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/state.rs b/src/tree/mirror/streaming/remote/proxy/state.rs
index 04eab96dd..39554ef92 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,
@@ -180,13 +181,19 @@ where
     A: Acceptor,
 {
     session: Session,
-    scopes: Receiver>,
+    /// 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>,
+    /// 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.
@@ -197,7 +204,7 @@ where
     A: Acceptor,
 {
     session: Session,
-    scopes: Receiver>,
+    scopes: Receiver,
 }
 
 impl protocol::Protocol for Descending
@@ -275,6 +282,7 @@ where
             session,
             scopes,
             early: Some(early),
+            height: PhantomData,
         };
         (responses, next)
     }
@@ -288,7 +296,6 @@ where
     W: Send,
     C: Connector,
     A: Acceptor,
-    UnderRoot: crate::tree::mirror::streaming::convert::Convert,
 {
     type Next = Descending;
 
@@ -314,6 +321,7 @@ where
             session,
             scopes: next_scopes,
             early: None,
+            height: PhantomData,
         };
         (responses, next)
     }
@@ -328,8 +336,8 @@ where
     C: Connector,
     A: Acceptor,
     H: Height,
-    S: Convert,
-    S>: Convert,
+    S: Height,
+    S>: Height,
     S>>: Height,
 {
     type Next = Descending;
@@ -350,6 +358,7 @@ where
             session: self.session,
             scopes: next_scopes,
             early: None,
+            height: PhantomData,
         };
         (responses, next)
     }
diff --git a/src/tree/mirror/streaming/remote/proxy/work.rs b/src/tree/mirror/streaming/remote/proxy/work.rs
index 9b7e63ccb..fe9a35f78 100644
--- a/src/tree/mirror/streaming/remote/proxy/work.rs
+++ b/src/tree/mirror/streaming/remote/proxy/work.rs
@@ -5,16 +5,18 @@
 //! protocol operation concurrently drives the stored pumps, its own terminal
 //! work, the session's accept driver, and the incoming-stream error route.
 
-use std::pin::pin;
+use std::pin::{Pin, pin};
 
-use futures::{StreamExt, future::BoxFuture};
+use futures::{Stream, StreamExt, future::BoxFuture};
 
 use crate::link::Acceptor;
 use crate::tree::{
     mirror::streaming::{
         Backend, Leaf,
+        channel::{QueueKind, QueueRole, Sender},
+        erased,
         materialized::SupplyLedger,
-        protocol::{BoxResponses, Responses},
+        protocol::BoxResponses,
         remote::{
             adapter::{DecodeError, EncodeError},
             codec::{Origin, RunBudget, Speaker},
@@ -125,34 +127,26 @@ where
         self.tasks.push(Box::pin(task));
     }
 
-    /// Add a task which actively drives a response stream.
-    ///
-    /// One buffered response is sufficient: whenever the task 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 the proxy's decoded erased
+    /// replies re-tag at their stage's height.
     fn respond(
         &mut self,
-        messages: impl Responses>,
+        messages: impl Stream, Error>> + Send + 'static,
     ) -> BoxResponses>
     where
         H: Height,
     {
-        let (send, receive) = self::queues::responses::<_, H>();
-        self.spawn(async move {
-            let mut messages = pin!(messages);
-            while let Some(message) = messages.next().await {
-                let failed = message.is_err();
-                send_or_cancel(&send, message).await;
-                park_after_published_error(failed).await;
-            }
-            Ok(())
-        });
-        #[cfg(test)]
-        let responses = Box::pin(receive);
-        #[cfg(not(test))]
-        let responses = Box::pin(tokio_stream::wrappers::ReceiverStream::new(receive));
-        responses
+        // 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.
+        let (send, responses) = erased::reply_channel::>(
+            QueueRole::new(QueueKind::ProxyResponses, H::HEIGHT),
+            1,
+        );
+        self.spawn(pump(Box::pin(messages), send));
+        Box::pin(responses)
     }
 
     /// Drive all accumulated pumps, the terminal operation, and the session's
@@ -267,5 +261,18 @@ where
     }
 }
 
+/// Drive one decoded response stream into its outgoing relay edge.
+async fn pump(
+    mut messages: Pin, Error>> + Send>>,
+    send: Sender, Error>>,
+) -> Result<(), Error> {
+    while let Some(message) = messages.next().await {
+        let failed = message.is_err();
+        send_or_cancel(&send, message).await;
+        park_after_published_error(failed).await;
+    }
+    Ok(())
+}
+
 #[cfg(test)]
 mod tests;
diff --git a/src/tree/mirror/streaming/remote/proxy/work/encode.rs b/src/tree/mirror/streaming/remote/proxy/work/encode.rs
index 0f1f4f59a..2ad2ebf88 100644
--- a/src/tree/mirror/streaming/remote/proxy/work/encode.rs
+++ b/src/tree/mirror/streaming/remote/proxy/work/encode.rs
@@ -3,8 +3,13 @@
 //! Questions are retained until every frame of their containing reply has
 //! flushed. Publishing them any earlier could block the encoder before the
 //! reply end reaches the remote peer which must answer them.
+//!
+//! Every encoder here consumes the erased vocabulary — its typed request
+//! stream is erased and boxed by the proxy state that spawns it — so each
+//! encoder body instantiates once per backend; the stage's height arrives
+//! as the runtime label the progress trace records.
 
-use std::pin::{Pin, pin};
+use std::pin::Pin;
 
 use futures::{Stream, StreamExt};
 
@@ -13,9 +18,7 @@ use crate::tree::{
     mirror::streaming::{
         Backend, Leaf,
         channel::{Receiver, Sender},
-        convert::Convert,
-        message::Reply,
-        protocol::Requests,
+        erased::Reply,
         remote::{
             adapter::{self, Encoded, Scope, encode_reply, opening_parts},
             codec::RunBudget,
@@ -25,20 +28,23 @@ use crate::tree::{
     },
     typed::{
         Hash,
-        height::{Height, S, UnderRoot, UnderUnderRoot, Z},
+        height::{Height, UnderRoot, Z},
     },
 };
 
 use super::progress::Progress;
 
+/// A local reply stream already erased and boxed at the proxy boundary.
+pub type Replies = Pin> + Send>>;
+
 /// Encode local leaf replies, optionally publishing the leaf questions they ask.
 pub async fn terminal(
     backend: B,
     budget: RunBudget,
-    requests: impl Requests,
-    mut scopes: Receiver>,
+    requests: Replies,
+    mut scopes: Receiver,
     mut outgoing: StreamSender,
-    questions: Option>>,
+    questions: Option>,
     progress: Progress,
 ) -> Result<(), Error>
 where
@@ -46,7 +52,7 @@ where
     T: Send + Sync + 'static,
     C: Connector,
 {
-    let mut requests = pin!(requests);
+    let mut requests = requests;
     // Scope-first pairing: dequeuing the scope before awaiting the local
     // reply frees its channel slot one reply earlier, so a K-slot edge
     // admits K truly in-flight scopes (the walk's stage loops make the
@@ -55,9 +61,9 @@ where
         let request = requests.next().await.ok_or(Error::UnansweredRemoteQuery)?;
         let mut encoded = adapter::encode_leaf_reply(backend.clone(), budget, scope, request);
         let batch = write_reply(&mut outgoing, &mut encoded).await?;
-        progress.wire_reply::(batch.len());
+        progress.wire_reply(Z::HEIGHT, batch.len());
         if let Some(questions) = &questions {
-            publish::<_, Z>(questions, batch, progress).await;
+            publish(questions, batch, progress, Z::HEIGHT).await;
         } else if !batch.is_empty() {
             return Err(Error::TerminalQuery);
         }
@@ -66,30 +72,34 @@ where
 }
 
 /// Encode non-leaf replies and publish each complete question batch.
-pub async fn replies(
+///
+/// `question_height` is the derived questions' height, one under the
+/// replies this encoder renders.
+// 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)]
+pub async fn replies(
     backend: B,
     budget: RunBudget,
-    requests: impl Requests>,
-    mut scopes: Receiver>>,
+    requests: Replies,
+    mut scopes: Receiver,
     mut outgoing: StreamSender,
-    questions: Sender>,
+    questions: Sender,
     progress: Progress,
+    question_height: usize,
 ) -> Result<(), Error>
 where
     B: Backend: Leaf>,
     T: Send + Sync + 'static,
     C: Connector,
-    H: Height,
-    S: Convert,
-    S>: Height,
 {
-    let mut requests = pin!(requests);
+    let mut requests = requests;
     while let Some(scope) = scopes.recv().await {
         let request = requests.next().await.ok_or(Error::UnansweredRemoteQuery)?;
         let mut encoded = encode_reply(backend.clone(), budget, scope, request);
         let batch = write_reply(&mut outgoing, &mut encoded).await?;
-        progress.wire_reply::(batch.len());
-        publish::<_, H>(&questions, batch, progress).await;
+        progress.wire_reply(question_height, batch.len());
+        publish(&questions, batch, progress, question_height).await;
     }
     finish(requests, outgoing).await
 }
@@ -117,8 +127,8 @@ where
 pub async fn opening(
     backend: B,
     budget: RunBudget,
-    requests: impl Requests,
-    questions: Sender>,
+    requests: Replies,
+    questions: Sender,
     mut outgoing: StreamSender,
     peer_listing: Vec<(u8, Hash)>,
     progress: Progress,
@@ -128,12 +138,12 @@ where
     T: Send + Sync + 'static,
     C: Connector,
 {
-    let mut requests = pin!(requests);
+    let mut requests = requests;
     let request = requests.next().await.ok_or(Error::MissingOpening)?;
     let (listing, supplies) = opening_parts(request).map_err(Error::OpeningEncode)?;
     let question = Scope::opening(&listing);
-    progress.wire_reply::(1);
-    progress.local_question::();
+    progress.wire_reply(UnderRoot::HEIGHT, 1);
+    progress.local_question(UnderRoot::HEIGHT);
     send_or_cancel(&questions, question).await;
 
     let early = {
@@ -150,7 +160,7 @@ where
             Scope::opening(&[]),
             Reply { replies: supplies },
         );
-        let batch: Vec> = write_reply(&mut outgoing, &mut encoded).await?;
+        let batch: Vec = write_reply(&mut outgoing, &mut encoded).await?;
         debug_assert!(batch.is_empty(), "opening supplies ask no question");
     } else {
         debug_assert!(
@@ -183,16 +193,16 @@ where
 }
 
 /// Publish one complete reply's questions in their wire order.
-async fn publish(questions: &Sender, batch: Vec, progress: Progress) {
+async fn publish(questions: &Sender, batch: Vec, progress: Progress, height: usize) {
     for question in batch {
-        progress.local_question::();
+        progress.local_question(height);
         send_or_cancel(questions, question).await;
     }
 }
 
 /// Reject unclaimed local replies, then close the outgoing logical stream.
 async fn finish(
-    mut requests: Pin<&mut R>,
+    mut requests: Pin>,
     outgoing: StreamSender,
 ) -> Result<(), Error>
 where
diff --git a/src/tree/mirror/streaming/remote/proxy/work/progress.rs b/src/tree/mirror/streaming/remote/proxy/work/progress.rs
index 1d78d1d86..0f5325f85 100644
--- a/src/tree/mirror/streaming/remote/proxy/work/progress.rs
+++ b/src/tree/mirror/streaming/remote/proxy/work/progress.rs
@@ -1,7 +1,5 @@
 //! Ordering trace for the proxy's progress-critical publications.
 
-use crate::tree::typed::height::Height;
-
 /// One endpoint-local progress identity.
 #[derive(Clone, Copy)]
 pub struct Progress {
@@ -18,38 +16,39 @@ impl Progress {
         }
     }
 
-    /// Record one complete outgoing wire reply and its question count.
-    pub fn wire_reply(self, _questions: usize) {
+    /// Record one complete outgoing wire reply and its question count,
+    /// at the reply's height.
+    pub fn wire_reply(self, _height: usize, _questions: usize) {
         #[cfg(test)]
         trace::record(
             self.work,
             trace::Kind::WireReply {
                 questions: _questions,
             },
-            H::HEIGHT,
+            _height,
         );
     }
 
     /// Record one question published after its wire reply.
-    pub fn local_question(self) {
+    pub fn local_question(self, _height: usize) {
         #[cfg(test)]
-        trace::record(self.work, trace::Kind::LocalQuestion, H::HEIGHT);
+        trace::record(self.work, trace::Kind::LocalQuestion, _height);
     }
 
     /// Record one decoded reply and its dependent-scope count.
-    pub fn decoded_reply(self, _scopes: usize) {
+    pub fn decoded_reply(self, _height: usize, _scopes: usize) {
         #[cfg(test)]
         trace::record(
             self.work,
             trace::Kind::DecodedReply { scopes: _scopes },
-            H::HEIGHT,
+            _height,
         );
     }
 
     /// Record one dependent scope published after its decoded reply.
-    pub fn next_scope(self) {
+    pub fn next_scope(self, _height: usize) {
         #[cfg(test)]
-        trace::record(self.work, trace::Kind::NextScope, H::HEIGHT);
+        trace::record(self.work, trace::Kind::NextScope, _height);
     }
 }
 
diff --git a/src/tree/mirror/streaming/remote/proxy/work/pump.rs b/src/tree/mirror/streaming/remote/proxy/work/pump.rs
index 0e028cf5c..59d86bc68 100644
--- a/src/tree/mirror/streaming/remote/proxy/work/pump.rs
+++ b/src/tree/mirror/streaming/remote/proxy/work/pump.rs
@@ -5,6 +5,11 @@
 //! receiver-side stream or next-phase scope queue fed by that task. No state
 //! outside this module handles an internal sender.
 //!
+//! Like the walk, the decode loops run on the erased vocabulary — one
+//! instantiation per backend and transport — behind thin typed methods
+//! that erase the stage's local reply stream on the way in and re-tag the
+//! decoded responses at the typed exit (`Work::respond`).
+//!
 //! Three channels carry the dataflow:
 //!
 //! - flushed local questions flow into decoding, sized by the session
@@ -30,9 +35,8 @@ use crate::tree::{
     mirror::streaming::{
         Backend, Leaf,
         channel::Receiver,
-        convert::Convert,
-        materialized::{SupplyLedger, children_of},
-        message::{Reaction, Reply},
+        erased::{self, Reaction, Reply, ops},
+        materialized::SupplyLedger,
         protocol::{BoxResponses, Requests},
         remote::{
             adapter::{
@@ -44,7 +48,7 @@ use crate::tree::{
         },
     },
     typed::{
-        Prefix,
+        ErasedPrefix,
         height::{Height, S, UnderRoot, UnderUnderRoot, Z},
     },
 };
@@ -73,36 +77,37 @@ where
         &mut self,
     ) -> (
         BoxResponses>,
-        Receiver>,
+        Receiver,
     ) {
         let (next_scopes, scopes) =
-            queues::next_scopes::<_, UnderRoot>(self.window.capacity(UnderRoot::HEIGHT));
+            queues::next_scopes(UnderRoot::HEIGHT, self.window.capacity(UnderRoot::HEIGHT));
         let progress = self.progress;
         let listing = std::mem::take(&mut self.peer_listing);
         let responses = try_stream! {
-            let (reply, scope) = opening_reply(listing);
+            let (reply, scope) = opening_reply::(listing);
             yield_reply_scopes!(
-                progress, UnderRoot, 1;
+                progress, UnderRoot::HEIGHT, 1;
                 yield reply;
                 next_scopes => [scope];
             );
         };
-        (self.respond(responses), scopes)
+        (self.respond::(responses), scopes)
     }
 
     /// Proxy the responder opening and return its lower scope queue.
     pub fn opening_responder(
         &mut self,
         requests: impl Requests,
-        mut incoming: StreamReceiver,
+        incoming: StreamReceiver,
         outgoing: StreamSender,
     ) -> (
         BoxResponses>,
-        Receiver>,
+        Receiver,
     ) {
-        let progress = self.progress;
-        let (local_questions, mut questions) =
-            queues::local_questions::<_, UnderRoot>(self.window.capacity(UnderRoot::HEIGHT));
+        let requests: encode::Replies =
+            Box::pin(requests.map(erased::erase_reply::));
+        let (local_questions, questions) =
+            queues::local_questions(UnderRoot::HEIGHT, self.window.capacity(UnderRoot::HEIGHT));
         let peer_listing = std::mem::take(&mut self.peer_listing);
         self.spawn(encode::opening(
             self.backend(),
@@ -111,28 +116,20 @@ where
             local_questions,
             outgoing,
             peer_listing,
-            progress,
+            self.progress,
         ));
-        let (next_scopes, scopes) =
-            queues::next_scopes::<_, UnderUnderRoot>(self.window.capacity(UnderUnderRoot::HEIGHT));
-        let backend = self.backend();
-        let version_bytes = self.peer_version_bytes;
-        let ledger = self.peer_supplies.clone();
-        let responses = try_stream! {
-            while let Some(scope) = questions.recv().await {
-                let Decoded { reply, questions } =
-                    decode_reply::(
-                        backend.clone(), version_bytes, ledger.clone(), scope, &mut incoming,
-                    ).await?;
-                yield_reply_scopes!(
-                    progress, UnderUnderRoot, questions.len();
-                    yield reply;
-                    next_scopes => questions;
-                );
-            }
-            reject_extra(&mut incoming).await?;
-        };
-        (self.respond(responses), scopes)
+        let (next_scopes, scopes) = queues::next_scopes(
+            UnderUnderRoot::HEIGHT,
+            self.window.capacity(UnderUnderRoot::HEIGHT),
+        );
+        let responses = self.decode_pump(
+            questions,
+            incoming,
+            next_scopes,
+            None,
+            UnderUnderRoot::HEIGHT,
+        );
+        (self.respond::(responses), scopes)
     }
 
     /// Proxy one ordinary two-height transition and return its lower scopes.
@@ -146,23 +143,20 @@ where
     pub fn internal_replies(
         &mut self,
         requests: impl Requests>>,
-        scopes: Receiver>>>,
-        mut incoming: StreamReceiver,
+        scopes: Receiver,
+        incoming: StreamReceiver,
         outgoing: StreamSender,
         early: Option>,
-    ) -> (
-        BoxResponses, Error>,
-        Receiver>,
-    )
+    ) -> (BoxResponses, Error>, Receiver)
     where
         H: Height,
-        S: Convert,
-        S>: Convert,
-        S>>: Height,
+        S: Height,
+        S>: Height,
     {
-        let progress = self.progress;
-        let (local_questions, mut questions) =
-            queues::local_questions::<_, S>(self.window.capacity(>::HEIGHT));
+        let requests: encode::Replies =
+            Box::pin(requests.map(erased::erase_reply::>>));
+        let (local_questions, questions) =
+            queues::local_questions(>::HEIGHT, self.window.capacity(>::HEIGHT));
         self.spawn(encode::replies(
             self.backend(),
             self.budget,
@@ -170,14 +164,39 @@ where
             scopes,
             outgoing,
             local_questions,
-            progress,
+            self.progress,
+            >::HEIGHT,
         ));
-        let (next_scopes, scopes) = queues::next_scopes::<_, H>(self.window.capacity(H::HEIGHT));
+        let (next_scopes, scopes) = queues::next_scopes(H::HEIGHT, self.window.capacity(H::HEIGHT));
+        let responses = self.decode_pump(questions, incoming, next_scopes, early, H::HEIGHT);
+        (self.respond::>(responses), scopes)
+    }
+
+    /// The decode loop shared by the responder opening and every internal
+    /// transition: pair each flushed local question with its decoded wire
+    /// reply, publishing the reply before the lower scopes derived from it.
+    ///
+    /// `height` is the derived scopes' height. `early` arms the
+    /// opening-supply pairing (see
+    /// [`internal_replies`](Self::internal_replies)); the opening
+    /// responder and every deeper stage pass `None`.
+    fn decode_pump(
+        &mut self,
+        mut questions: Receiver,
+        mut incoming: StreamReceiver,
+        next_scopes: crate::tree::mirror::streaming::channel::Sender,
+        early: Option>,
+        height: usize,
+    ) -> impl Stream, Error>>
+    + Send
+    + 'static
+    + use {
+        let progress = self.progress;
         let backend = self.backend();
         let version_bytes = self.peer_version_bytes;
         let ledger = self.peer_supplies.clone();
-        let responses = try_stream! {
-            let mut early = Early::>, A::Rx>::new(version_bytes, ledger.clone(), early);
+        try_stream! {
+            let mut early = Early::::new(version_bytes, ledger.clone(), early);
             while let Some(scope) = questions.recv().await {
                 if early.armed() && scope.is_request() {
                     // A root-level request: its content crossed at the
@@ -185,14 +204,14 @@ where
                     // the early stream carries the node — or neither does,
                     // when pruning removed the whole subtree.
                     let parent = scope.parent();
-                    let Decoded { reply, questions: asked } = decode_reply::(
+                    let Decoded { reply, questions: asked } = decode_reply::(
                         backend.clone(), version_bytes, ledger.clone(), scope, &mut incoming,
                     ).await?;
                     debug_assert!(asked.is_empty(), "an empty request opens no lower scope");
                     let (root, radix) = parent.pop();
                     let mut replies = reply.replies;
                     if let Some(node) = early.advance_to(&backend, root, radix).await? {
-                        let children = children_of(&backend, parent, node)
+                        let children = ops::children_of(&backend, parent, node)
                             .await
                             .map_err(|error| Error::Decode(DecodeError::Backend(error)))?;
                         replies.extend(
@@ -202,38 +221,38 @@ where
                         );
                     }
                     yield_reply_scopes!(
-                        progress, H, 0;
+                        progress, height, 0;
                         yield Reply { replies };
-                        next_scopes => Vec::>::new();
+                        next_scopes => Vec::::new();
                     );
                     continue;
                 }
-                let Decoded { reply, questions } = decode_reply::(
+                let Decoded { reply, questions } = decode_reply::(
                     backend.clone(), version_bytes, ledger.clone(), scope, &mut incoming,
                 ).await?;
                 yield_reply_scopes!(
-                    progress, H, questions.len();
+                    progress, height, questions.len();
                     yield reply;
                     next_scopes => questions;
                 );
             }
             early.finish().await?;
             reject_extra(&mut incoming).await?;
-        };
-        (self.respond(responses), scopes)
+        }
     }
 
     /// Proxy the leaf-parent transition and return its terminal leaf scopes.
     pub fn leaf_replies(
         &mut self,
         requests: impl Requests>,
-        scopes: Receiver>>,
-        mut incoming: StreamReceiver,
+        scopes: Receiver,
+        incoming: StreamReceiver,
         outgoing: StreamSender,
-    ) -> (BoxResponses>, Receiver>) {
-        let progress = self.progress;
-        let (local_questions, mut questions) =
-            queues::local_questions::<_, Z>(self.window.capacity(Z::HEIGHT));
+    ) -> (BoxResponses>, Receiver) {
+        let requests: encode::Replies =
+            Box::pin(requests.map(erased::erase_reply::>));
+        let (local_questions, questions) =
+            queues::local_questions(Z::HEIGHT, self.window.capacity(Z::HEIGHT));
         self.spawn(encode::replies(
             self.backend(),
             self.budget,
@@ -241,36 +260,53 @@ where
             scopes,
             outgoing,
             local_questions,
-            progress,
+            self.progress,
+            Z::HEIGHT,
         ));
-        let (next_scopes, scopes) = queues::next_scopes::<_, Z>(self.window.capacity(Z::HEIGHT));
+        let (next_scopes, scopes) = queues::next_scopes(Z::HEIGHT, self.window.capacity(Z::HEIGHT));
+        let responses = self.leaf_decode_pump(questions, incoming, next_scopes);
+        (self.respond::(responses), scopes)
+    }
+
+    /// The leaf-height decode loop: like [`decode_pump`](Self::decode_pump),
+    /// but every question is a terminal leaf request.
+    fn leaf_decode_pump(
+        &mut self,
+        mut questions: Receiver,
+        mut incoming: StreamReceiver,
+        next_scopes: crate::tree::mirror::streaming::channel::Sender,
+    ) -> impl Stream, Error>>
+    + Send
+    + 'static
+    + use {
+        let progress = self.progress;
         let backend = self.backend();
         let version_bytes = self.peer_version_bytes;
         let ledger = self.peer_supplies.clone();
-        let responses = try_stream! {
+        try_stream! {
             while let Some(scope) = questions.recv().await {
                 let Decoded { reply, questions } = decode_leaf_reply(
                     backend.clone(), version_bytes, ledger.clone(), scope, &mut incoming,
                 ).await?;
                 yield_reply_scopes!(
-                    progress, Z, questions.len();
+                    progress, Z::HEIGHT, questions.len();
                     yield reply;
                     next_scopes => questions;
                 );
             }
             reject_extra(&mut incoming).await?;
-        };
-        (self.respond(responses), scopes)
+        }
     }
 
     /// Drive the final local answers for a remote initiator to completion.
     pub async fn complete_initiator(
         self,
         requests: impl Requests,
-        scopes: Receiver>,
+        scopes: Receiver,
         outgoing: StreamSender,
     ) -> Result<(R, W), Error> {
-        let progress = self.progress;
+        let requests: encode::Replies =
+            Box::pin(requests.map(erased::erase_reply::));
         let finish = encode::terminal(
             self.backend(),
             self.budget,
@@ -278,7 +314,7 @@ where
             scopes,
             outgoing,
             None,
-            progress,
+            self.progress,
         );
         let ((), read, write) = self.execute(finish).await?;
         Ok((read, write))
@@ -288,8 +324,8 @@ where
     pub fn complete_responder(
         mut self,
         requests: impl Requests,
-        scopes: Receiver>,
-        mut incoming: StreamReceiver,
+        scopes: Receiver,
+        incoming: StreamReceiver,
         outgoing: StreamSender,
     ) -> (
         BoxResponses>,
@@ -300,9 +336,10 @@ where
         W: Send,
         A: Send,
     {
-        let progress = self.progress;
-        let (local_questions, mut questions) =
-            queues::local_questions::<_, Z>(self.window.capacity(Z::HEIGHT));
+        let requests: encode::Replies =
+            Box::pin(requests.map(erased::erase_reply::));
+        let (local_questions, questions) =
+            queues::local_questions(Z::HEIGHT, self.window.capacity(Z::HEIGHT));
         self.spawn(encode::terminal(
             self.backend(),
             self.budget,
@@ -310,12 +347,32 @@ where
             scopes,
             outgoing,
             Some(local_questions),
-            progress,
+            self.progress,
         ));
+        let responses = self.terminal_decode_pump(questions, incoming);
+        let responses = self.respond::(responses);
+        let completion = async move {
+            let ((), read, write) = self.execute(async { Ok(()) }).await?;
+            Ok((read, write))
+        };
+        (responses, completion)
+    }
+
+    /// The responder terminal's decode loop: leaf replies that may open no
+    /// further scope.
+    fn terminal_decode_pump(
+        &mut self,
+        mut questions: Receiver,
+        mut incoming: StreamReceiver,
+    ) -> impl Stream, Error>>
+    + Send
+    + 'static
+    + use {
+        let progress = self.progress;
         let backend = self.backend();
         let version_bytes = self.peer_version_bytes;
         let ledger = self.peer_supplies.clone();
-        let responses = try_stream! {
+        try_stream! {
             while let Some(scope) = questions.recv().await {
                 let Decoded { reply, questions } = decode_leaf_reply(
                     backend.clone(), version_bytes, ledger.clone(), scope, &mut incoming,
@@ -323,17 +380,11 @@ where
                 if !questions.is_empty() {
                     Err(Error::TerminalQuery)?;
                 }
-                progress.decoded_reply::(0);
+                progress.decoded_reply(Z::HEIGHT, 0);
                 yield reply;
             }
             reject_extra(&mut incoming).await?;
-        };
-        let responses = self.respond(responses);
-        let completion = async move {
-            let ((), read, write) = self.execute(async { Ok(()) }).await?;
-            Ok((read, write))
-        };
-        (responses, completion)
+        }
     }
 }
 
@@ -346,11 +397,10 @@ where
 /// session. An armed cursor whose stage sees no request never polls the
 /// receiver, so the transport stream is never claimed — the lazy-claim
 /// discipline every level follows.
-struct Early
+struct Early
 where
     B: Backend: Leaf>,
     T: Send + Sync + 'static,
-    G: Height,
 {
     /// The peer's greeting-declared `max_version_bytes`, enforced on
     /// every supplied version the opening stream decodes.
@@ -360,17 +410,15 @@ where
     ledger: SupplyLedger,
     receiver: Option>,
     supplies:
-        Option), DecodeError>> + Send>>>,
-    lookahead: Option<(u8, B::Node)>,
+        Option>> + Send>>>,
+    lookahead: Option<(u8, B::Erased)>,
     exhausted: bool,
 }
 
-impl Early
+impl Early
 where
     B: Backend: Leaf>,
     T: DeserializeOwned + Send + Sync + 'static,
-    G: Convert,
-    S: Height,
     Rx: tokio::io::AsyncRead + Unpin + Send + 'static,
 {
     /// Arm the cursor with the opening-supply stream's receiver, if this
@@ -400,9 +448,9 @@ where
     async fn advance_to(
         &mut self,
         backend: &B,
-        root: Prefix>,
+        root: ErasedPrefix,
         radix: u8,
-    ) -> Result>, Error> {
+    ) -> Result, Error> {
         loop {
             if let Some((next, node)) = self.lookahead.take() {
                 if next == radix {
@@ -427,7 +475,7 @@ where
                         .take()
                         .expect("an unarmed cursor resolves no request");
                     self.supplies
-                        .get_or_insert(Box::pin(early_supplies::(
+                        .get_or_insert(Box::pin(early_supplies::(
                             backend.clone(),
                             self.version_bytes,
                             self.ledger.clone(),
diff --git a/src/tree/mirror/streaming/remote/proxy/work/queues.rs b/src/tree/mirror/streaming/remote/proxy/work/queues.rs
index 640a167f1..58927b71d 100644
--- a/src/tree/mirror/streaming/remote/proxy/work/queues.rs
+++ b/src/tree/mirror/streaming/remote/proxy/work/queues.rs
@@ -1,8 +1,13 @@
-//! Typed channel constructors for the remote proxy's three dataflow edges.
+//! Channel constructors for the remote proxy's scope-carrying edges.
 //!
 //! A response is published before the scopes it releases, and a complete
 //! outgoing wire reply is flushed before its local question scopes are
 //! published. Those orderings make one slot per edge the liveness floor.
+//! Both edges carry the erased [`Scope`] — one channel-machinery
+//! instantiation for the whole proxy — with each edge's height kept as
+//! its runtime [`QueueRole`] label. (The third proxy edge, the one-slot
+//! decoded-response relay, is minted by the response pump itself: see
+//! `Work::respond`.)
 //!
 //! - [`local_questions`] is the wire-facing question window itself, sized
 //!   by the session
@@ -10,22 +15,15 @@
 //!   there re-serializes the descent no matter how wide the walk's own
 //!   channels are;
 //! - [`next_scopes`] is the decode-side register, also window-sized, whose
-//!   items are small enough to widen defensively;
-//! - [`responses`] stays at one slot: it is an in-order relay pump, so a
-//!   full slot only stalls when its consumer is itself stalled, and the
-//!   single slot is what bounds decoded replies in flight per stage.
+//!   items are small enough to widen defensively.
 
-use crate::tree::{
-    mirror::streaming::channel::{QueueKind, QueueRole, Receiver, Sender, channel},
-    typed::height::Height,
+use crate::tree::mirror::streaming::{
+    channel::{QueueKind, QueueRole, Receiver, Sender, channel},
+    remote::adapter::Scope,
 };
 
-/// Buffer one decoded response on its way to the local protocol participant.
-pub fn responses() -> (Sender, Receiver) {
-    channel(QueueRole::new(QueueKind::ProxyResponses, H::HEIGHT), 1)
-}
-
-/// Carry flushed-but-unanswered questions, window-wide.
+/// Carry flushed-but-unanswered questions, window-wide, labeled at the
+/// questions' height.
 ///
 /// This queue's occupancy tracks the questions in flight on the wire at
 /// this height: the encoder publishes each question once its complete
@@ -36,18 +34,15 @@ pub fn responses() -> (Sender, Receiver) {
 /// decodes). The canonical derivation — the occupancy bound, its
 /// reachability, and the slack — is in the
 /// [`window`](crate::tree::mirror::streaming::window) module docs.
-pub fn local_questions(capacity: usize) -> (Sender, Receiver) {
+pub fn local_questions(height: usize, capacity: usize) -> (Sender, Receiver) {
     channel(
-        QueueRole::new(QueueKind::ProxyLocalQuestions, H::HEIGHT),
+        QueueRole::new(QueueKind::ProxyLocalQuestions, height),
         capacity,
     )
 }
 
 /// Carry scopes derived from a response already published locally,
-/// window-wide.
-pub fn next_scopes(capacity: usize) -> (Sender, Receiver) {
-    channel(
-        QueueRole::new(QueueKind::ProxyNextScopes, H::HEIGHT),
-        capacity,
-    )
+/// window-wide, labeled at the scopes' height.
+pub fn next_scopes(height: usize, capacity: usize) -> (Sender, Receiver) {
+    channel(QueueRole::new(QueueKind::ProxyNextScopes, height), capacity)
 }
diff --git a/src/tree/typed/prefix.rs b/src/tree/typed/prefix.rs
index ff08226a9..e591af5cd 100644
--- a/src/tree/typed/prefix.rs
+++ b/src/tree/typed/prefix.rs
@@ -81,6 +81,20 @@ impl ErasedPrefix {
         self.hash.push(byte);
         self
     }
+
+    /// Pop one hash byte off the end of the prefix, ascending one height:
+    /// the remainder and the byte ([`Prefix::pop`]).
+    ///
+    /// # Panics
+    ///
+    /// If the prefix is empty (the root has no parent).
+    pub fn pop(mut self) -> (ErasedPrefix, u8) {
+        let byte = self
+            .hash
+            .pop()
+            .expect("a prefix below the root has at least one byte to pop");
+        (self, byte)
+    }
 }
 
 impl Debug for ErasedPrefix {

From d52a633ad20455250818e1f3985f8657ec08abb1 Mon Sep 17 00:00:00 2001
From: finch 
Date: Wed, 19 Aug 2026 18:45:57 -0400
Subject: [PATCH 05/18] height erasure: record the results; sketch item-type
 erasure

The design doc graduates to .agent-notes as the decision and measurement
record: per-step IR readings (2,884,887 -> 1,039,534 lines, -64%, on
--test pairwise), the runtime pin (gossip_fixed_bidir_insertions/V2/5000
on ox-east-1 reserved cores: 54.51 ms -> 54.10 ms, no movement), the
compile-cost A/B (non-incremental fleet rebuild: 7,819 -> 3,093
CPU-seconds, -60%), the declined step-5 Tagged collapse with its
rationale, and the resolutions of the sketch's open questions. The
streaming module's layer map gains the erased seam.

design/item-erasure.md is the phase-2 sketch (item-type erasure at the
leaf boundary), status sketch, awaiting review: the measured residue
shows the tree layers now dominate what every downstream binary re-buys,
so the sketch weighs erasing the session boundary alone against erasing
the tree's stored payload too.
---
 .../2026-08-19-height-erasure/README.md       | 132 ++++++++++
 design/height-erasure.md                      | 248 ------------------
 design/item-erasure.md                        | 111 ++++++++
 src/tree/mirror/streaming.rs                  |   3 +
 4 files changed, 246 insertions(+), 248 deletions(-)
 create mode 100644 .agent-notes/2026-08-19-height-erasure/README.md
 delete mode 100644 design/height-erasure.md
 create mode 100644 design/item-erasure.md

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..8438b1118
--- /dev/null
+++ b/.agent-notes/2026-08-19-height-erasure/README.md
@@ -0,0 +1,132 @@
+# 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 remains: the T axis
+
+Height erasure deliberately kept the payload axis. The follow-on design
+(`design/item-erasure.md`) sketches erasing `T` at the leaf conversion
+boundary so the subsystem compiles once into the rlib; height erasure
+shrank what that phase must move.
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/design/item-erasure.md b/design/item-erasure.md
new file mode 100644
index 000000000..a9615217a
--- /dev/null
+++ b/design/item-erasure.md
@@ -0,0 +1,111 @@
+# Item-type erasure at the leaf boundary (part II sketch)
+
+Status: sketch, not implemented — the "phase 2" the height-erasure work
+names. Written after height erasure landed; 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: erase the tree's storage too
+
+`untyped::Node` stores `Message` (canonical bytes); the typed
+facade decodes on read (`iter`/`get` return decoded values or a lazy
+view) and encodes on insert.
+
+- Buys: tree + session compile once; the whole gossip stack becomes
+  rlib code. The `T`-facade shrinks to (de)serialization at the public
+  API.
+- Costs: decode-on-read for iteration (each read pays a CBOR decode;
+  today reads are free); or cache decoded values (memory). Insertion
+  already pays one encode (for hashing) — verify: if hashing already
+  encodes, insertion cost is unchanged.
+- This is the full "compiles only once".
+
+### Option C: A now, B later
+
+A is strictly smaller and proves the seam; B builds on it. The
+measurement after A decides whether B's read-path trade is worth it.
+
+## 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.
+
+## Open questions for review
+
+1. Is `Message`'s canonical encoding stable enough to be the stored
+   representation (option B), or is decode-on-read unacceptable for the
+   read path's contract?
+2. Should the erased payload be a newtype (`Payload(Bytes)`) with the
+   canonicality invariant documented, or the existing
+   `Message`-style vehicle if one exists?
+3. Facade shape: seal the session core behind non-generic functions in
+   the rlib (taking `&mut dyn` link objects — the transport is already
+   dyn-erased), or keep generic entry points that immediately erase?
diff --git a/src/tree/mirror/streaming.rs b/src/tree/mirror/streaming.rs
index a55534ff9..275ca3e2e 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.

From b499ca9b2d810564a740cdf6f9232f3a5c1257a8 Mon Sep 17 00:00:00 2001
From: finch 
Date: Wed, 19 Aug 2026 18:58:16 -0400
Subject: [PATCH 06/18] item-erasure sketch: adopt the Arc payload
 shape
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Message already pairs its Arc'd value with the canonical serialized
bytes, and the tree and session consume only the bytes outside the
typed reads — so erasing the value is an unsizing coercion of the
existing Arc, read back by checked downcast, with a per-T deserialize
witness at wire ingress as the only other per-T residue. This replaces
the decode-on-read variant, which charged every read a CBOR decode to
save a fat pointer.
---
 design/item-erasure.md | 68 ++++++++++++++++++++++++++++++------------
 1 file changed, 49 insertions(+), 19 deletions(-)

diff --git a/design/item-erasure.md b/design/item-erasure.md
index a9615217a..f1685a85d 100644
--- a/design/item-erasure.md
+++ b/design/item-erasure.md
@@ -61,25 +61,56 @@ already does). The session core becomes generic over an opaque
   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: erase the tree's storage too
-
-`untyped::Node` stores `Message` (canonical bytes); the typed
-facade decodes on read (`iter`/`get` return decoded values or a lazy
-view) and encodes on insert.
-
-- Buys: tree + session compile once; the whole gossip stack becomes
-  rlib code. The `T`-facade shrinks to (de)serialization at the public
-  API.
-- Costs: decode-on-read for iteration (each read pays a CBOR decode;
-  today reads are free); or cache decoded values (memory). Insertion
-  already pays one encode (for hashing) — verify: if hashing already
-  encodes, insertion cost is unchanged.
-- This is the full "compiles only once".
+### 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 — stronger
+witnessing than the height seam's debug-only prefix asserts). The one
+per-`T` residue beyond the facade is a deserialize witness
+(`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 consequence, the part to shape deliberately: `iter`/`get`
+  and `Leaf::message` return `&Message` today, which cannot survive
+  as-is once the payload inside is erased — the API becomes a typed
+  accessor (`message.get::() -> &T`) or a `MessageView<'_, T>`.
+  `T: Serialize` migrates to the insert boundary and
+  `DeserializeOwned` to witness minting; nothing new is demanded of
+  `T`.
+
+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 seam; B builds on it. The
-measurement after A decides whether B's read-path trade is worth it.
+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
 
@@ -103,9 +134,8 @@ security-relevant: payload validation stays exactly where it is
 1. Is `Message`'s canonical encoding stable enough to be the stored
    representation (option B), or is decode-on-read unacceptable for the
    read path's contract?
-2. Should the erased payload be a newtype (`Payload(Bytes)`) with the
-   canonicality invariant documented, or the existing
-   `Message`-style vehicle if one exists?
+2. The `&Message` return type's replacement: typed accessor on an
+   erased `Message`, or a borrowed `MessageView<'_, T>`?
 3. Facade shape: seal the session core behind non-generic functions in
    the rlib (taking `&mut dyn` link objects — the transport is already
    dyn-erased), or keep generic entry points that immediately erase?

From b500889b1997679a036f8e9784c2088525313e0d Mon Sep 17 00:00:00 2001
From: finch 
Date: Wed, 19 Aug 2026 19:05:30 -0400
Subject: [PATCH 07/18] item-erasure sketch: Message is crate-internal; no
 public API movement

The public observers speak (Version, Arc) and nothing re-exports
Message (verified against the public rustdoc surface), so the erased
payload reshapes only crate internals; the observers' retained
most-recent-leaf slot becomes the per-yield downcast point. The open
questions shrink to witness minting, facade sealing, and whether the
tree erases in the same stroke.
---
 design/item-erasure.md | 33 ++++++++++++++++++++-------------
 1 file changed, 20 insertions(+), 13 deletions(-)

diff --git a/design/item-erasure.md b/design/item-erasure.md
index f1685a85d..6830bd4ad 100644
--- a/design/item-erasure.md
+++ b/design/item-erasure.md
@@ -94,13 +94,17 @@ payloads failing at ingress as `DecodeError::Record` exactly as today.
 - 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 consequence, the part to shape deliberately: `iter`/`get`
-  and `Leaf::message` return `&Message` today, which cannot survive
-  as-is once the payload inside is erased — the API becomes a typed
-  accessor (`message.get::() -> &T`) or a `MessageView<'_, T>`.
-  `T: Serialize` migrates to the insert boundary and
-  `DeserializeOwned` to witness minting; nothing new is demanded of
-  `T`.
+- No public API movement: `Message` is crate-internal (nothing
+  re-exports it; verified against the public rustdoc surface), and the
+  public observers already speak `(Version, Arc)`. The one
+  mechanical seam is the observers' lending contract —
+  `borrow_next() -> (&Version, &Arc)` lends from a retained
+  most-recent-leaf slot, so that slot becomes the downcast point: one
+  `Arc::downcast::()` (a refcount bump plus the `TypeId` check) per
+  yielded message, a cost the owned `(Version, Arc)` item already
+  pays. `T: Serialize` migrates to the insert boundary and
+  `DeserializeOwned` to witness minting; the public bounds stay
+  equivalent.
 
 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
@@ -131,11 +135,14 @@ security-relevant: payload validation stays exactly where it is
 
 ## Open questions for review
 
-1. Is `Message`'s canonical encoding stable enough to be the stored
-   representation (option B), or is decode-on-read unacceptable for the
-   read path's contract?
-2. The `&Message` return type's replacement: typed accessor on an
-   erased `Message`, or a borrowed `MessageView<'_, T>`?
-3. Facade shape: seal the session core behind non-generic functions in
+1. Witness minting site: at `Peer` construction (one witness for the
+   peer's lifetime, threaded through sessions), or per gossip call?
+   Construction seems right — it is also where `DeserializeOwned`
+   naturally lives.
+2. Facade shape: seal the session core behind non-generic functions in
    the rlib (taking `&mut dyn` link objects — the transport is already
    dyn-erased), or keep generic entry points that immediately erase?
+3. Does the tree erase in the same stroke (it must for the full win —
+   `untyped::Node`'s only `T` is the stored `Message`), or does a
+   first landing keep a typed tree and erase at the session boundary
+   (option A) to de-risk?

From cd7c09db3345535b4af29acaca8f27009bab89c7 Mon Sep 17 00:00:00 2001
From: finch 
Date: Wed, 19 Aug 2026 20:34:24 -0400
Subject: [PATCH 08/18] observers: dissolve the lending forms

borrow_next and the lending TryNext existed to avoid cloning a Version
whose clone once materialized ITC structure; with Version CoW over
shared bytes, a clone is a refcount bump and the lending forms' whole
value collapsed to two atomic increments per message. The Stream face
becomes the observers' one engine: try_next keeps its non-blocking
Message/Quiet/Ended trichotomy but yields the owned (Version, Arc)
pair (TryNext loses its lifetime parameter), and the retained
most-recent-leaf lending slots dissolve with the borrowing entry
points.

This also removes the one observer seam the item-erasure sketch had to
thread a downcast through (design/item-erasure.md).
---
 benches/in_memory.rs           |   6 +-
 examples/swarm.rs              |   4 +-
 src/rumors/causal.rs           | 107 ++++++--------------------------
 src/rumors/unordered.rs        | 109 ++++++++-------------------------
 src/tree/typed/untyped/iter.rs |   4 +-
 tests/api_send_bounds.rs       |  11 +---
 tests/causal.rs                |  22 +++----
 tests/common/sim.rs            |  10 +--
 tests/listen.rs                |  33 +++++-----
 9 files changed, 85 insertions(+), 221 deletions(-)

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/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/rumors/causal.rs b/src/rumors/causal.rs
index caac9c337..7a7cfe1be 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
@@ -56,9 +61,6 @@ pub struct CausalMessages {
     /// 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>,
 }
 
 impl CausalMessages {
@@ -68,7 +70,6 @@ impl CausalMessages {
             ingested: since.clone(),
             checkpoint: since,
             staged: BTreeMap::new(),
-            current: None,
         }
     }
 
@@ -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,10 +154,11 @@ 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())));
             }
diff --git a/src/rumors/unordered.rs b/src/rumors/unordered.rs
index e67b54a57..97ca874b2 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>,
 }
 
 /// 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.
@@ -91,7 +86,6 @@ impl UnorderedMessages {
             channel: Some(Channel::Ready(inner.subscribe())),
             checkpoint: since,
             pass: None,
-            current: None,
         }
     }
 
@@ -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.
diff --git a/src/tree/typed/untyped/iter.rs b/src/tree/typed/untyped/iter.rs
index 35e0e40c3..c5f57ddd2 100644
--- a/src/tree/typed/untyped/iter.rs
+++ b/src/tree/typed/untyped/iter.rs
@@ -286,8 +286,8 @@ impl<'a, T, P: Polarity> DoubleEndedIterator for Range<'a, T, P> {
 /// borrowing walk (see [`Range`]); forward-only, since its consumers are
 /// subscription drains.
 /// Yields each passing leaf as an owned [`Leaf`] handle alongside its
-/// reconstructed 32-byte path, which is what lets a caller
-/// lend `&Version` / `&Arc` out of a leaf it keeps.
+/// reconstructed 32-byte path; the version and value read out of the
+/// handle as shared, clone-cheap references into the tree's storage.
 pub struct RangeOwned {
     /// The not-yet-visited root, consumed by the first advance.
     start: Option>,
diff --git a/tests/api_send_bounds.rs b/tests/api_send_bounds.rs
index 78ee8cba5..f5910cfa2 100644
--- a/tests/api_send_bounds.rs
+++ b/tests/api_send_bounds.rs
@@ -78,18 +78,13 @@ fn try_into_peer_future_is_send() {
     drop(fut);
 }
 
-/// Both observer faces — `borrow_next`'s future and the `Stream`'s item
-/// future — are `Send`, for spawned and `select!`-driven consumers.
+/// The observer's item future is `Send`, for spawned and
+/// `select!`-driven consumers.
 #[test]
 fn observer_futures_are_send() {
     let alice = Peer::::seed().sync_window_floor().into_rumors();
     let mut messages = alice.unordered_messages();
-    {
-        let fut = messages.borrow_next();
-        require_send(&fut);
-        drop(fut);
-    }
-    // The `Stream` face's item future must be `Send` too, for
+    // The `Stream` face's item future must be `Send`, for
     // `tokio::spawn`d `select!` consumers.
     let fut = messages.next();
     require_send(&fut);
diff --git a/tests/causal.rs b/tests/causal.rs
index c7741c691..b68c9f566 100644
--- a/tests/causal.rs
+++ b/tests/causal.rs
@@ -15,7 +15,7 @@ mod common;
 
 use std::collections::{BTreeMap, BTreeSet};
 
-use futures::FutureExt;
+use futures::{FutureExt, StreamExt};
 use proptest::collection::vec;
 use proptest::prelude::*;
 use rumors::{CausalMessages, Peer, Rumors, Version};
@@ -35,12 +35,12 @@ enum Step {
     Ended,
 }
 
-/// Poll `borrow_next` exactly once without an executor.
+/// Poll the observer exactly once without an executor.
 fn step(obs: &mut CausalMessages) -> Step {
-    match obs.borrow_next().now_or_never() {
+    match obs.next().now_or_never() {
         None => Step::Quiet,
         Some(None) => Step::Ended,
-        Some(Some((v, m))) => Step::Item((v.clone(), **m)),
+        Some(Some((v, m))) => Step::Item((v, *m)),
     }
 }
 
@@ -92,9 +92,7 @@ fn drain_unordered(obs: &mut rumors::UnorderedMessages) -> Vec<(Version, u6
     let mut items = Vec::new();
     loop {
         match obs.try_next() {
-            rumors::TryNext::Message((version, message)) => {
-                items.push((version.clone(), **message))
-            }
+            rumors::TryNext::Message((version, message)) => items.push((version, *message)),
             rumors::TryNext::Quiet | rumors::TryNext::Ended => return items,
         }
     }
@@ -328,12 +326,10 @@ fn observer_drains_the_final_state_causally_then_ends() {
     assert_eq!(step(&mut obs), Step::Ended, "ended is terminal");
 }
 
-/// The owned-item face delivers the same causal order as `borrow_next` and
-/// terminates with `None` once the set closes.
+/// The `Stream` face delivers in causal order and terminates with `None`
+/// once the set closes.
 #[test]
 fn stream_face_is_causal_and_terminates() {
-    use futures::StreamExt;
-
     let known = Peer::::seed().sync_window_floor().into_rumors();
     for v in 0..6u64 {
         known.send(v);
@@ -576,7 +572,7 @@ proptest! {
         for _ in 0..taken {
             match unordered.try_next() {
                 rumors::TryNext::Message((version, message)) => {
-                    unordered_delivered.push((version.clone(), **message));
+                    unordered_delivered.push((version, *message));
                 }
                 other => panic!("the pass has more items, got {other:?}"),
             }
@@ -660,7 +656,7 @@ fn final_pop_checkpoint_still_replays_the_last_message() {
     // message, persist the checkpoint, crash, resume — replays the message.
     let mut unordered = known.unordered_messages();
     assert!(
-        unordered.borrow_next().now_or_never().flatten().is_some(),
+        unordered.next().now_or_never().flatten().is_some(),
         "a populated set delivers an item"
     );
     let persisted = unordered.checkpoint().clone();
diff --git a/tests/common/sim.rs b/tests/common/sim.rs
index 90a6cafb3..4ec6573fe 100644
--- a/tests/common/sim.rs
+++ b/tests/common/sim.rs
@@ -558,7 +558,7 @@ async fn run_activity(handle: Rumors, script: Vec) -> Vec<(Versio
 /// path (`send_if_modified` racing `borrow_and_update`) outside
 /// single-threaded tests.
 async fn run_observers(handle: Rumors, done: Arc) {
-    use futures::FutureExt;
+    use futures::{FutureExt, StreamExt};
 
     let mut plain = handle.unordered_messages();
     let mut causal = handle.causal_messages();
@@ -572,15 +572,15 @@ async fn run_observers(handle: Rumors, done: Arc) {
         // races nothing.
         let finished = done.load(Ordering::Acquire);
 
-        while let Some(Some((version, _))) = plain.borrow_next().now_or_never() {
+        while let Some(Some((version, _))) = plain.next().now_or_never() {
             assert!(
-                plain_seen.insert(version_key(version)),
+                plain_seen.insert(version_key(&version)),
                 "Messages delivered version {version:?} twice"
             );
         }
-        while let Some(Some((version, _))) = causal.borrow_next().now_or_never() {
+        while let Some(Some((version, _))) = causal.next().now_or_never() {
             assert!(
-                causal_seen.insert(version_key(version)),
+                causal_seen.insert(version_key(&version)),
                 "CausalMessages delivered version {version:?} twice"
             );
             // `Version` is a partial order: `!(version < earlier)` also
diff --git a/tests/listen.rs b/tests/listen.rs
index 9fc88636a..83286bd62 100644
--- a/tests/listen.rs
+++ b/tests/listen.rs
@@ -15,7 +15,7 @@ mod common;
 
 use std::collections::{BTreeMap, BTreeSet};
 
-use futures::FutureExt;
+use futures::{FutureExt, StreamExt};
 use proptest::collection::vec;
 use proptest::prelude::*;
 use rand::SeedableRng;
@@ -37,12 +37,12 @@ enum Step {
     Ended,
 }
 
-/// Poll `borrow_next` exactly once without an executor.
+/// Poll the observer exactly once without an executor.
 fn step(obs: &mut UnorderedMessages) -> Step {
-    match obs.borrow_next().now_or_never() {
+    match obs.next().now_or_never() {
         None => Step::Quiet,
         Some(None) => Step::Ended,
-        Some(Some((v, m))) => Step::Item((v.clone(), **m)),
+        Some(Some((v, m))) => Step::Item((v, *m)),
     }
 }
 
@@ -339,11 +339,11 @@ fn lent_borrows_do_not_block_senders() {
     rumors.batch().send(1).send(2);
 
     let mut obs = rumors.unordered_messages();
-    let lent = block_on(obs.borrow_next()).expect("first item of the pass");
-    let lent_value = *lent.1.clone();
+    let lent = block_on(obs.next()).expect("first item of the pass");
+    let lent_value = *lent.1;
 
-    // With the borrow conceptually outstanding (the observer is mid-pass),
-    // a send must not deadlock.
+    // With the yielded item outstanding (the observer is mid-pass), a
+    // send must not deadlock.
     rumors.send(3);
 
     let (rest, _) = drain(&mut obs);
@@ -405,9 +405,10 @@ fn checkpoint_is_portable_across_replicas() {
     assert_eq!(items[0].1, 2, "A-observed messages are skipped at B");
 }
 
-/// The observer's non-blocking step lends exactly as
-/// `borrow_next` does, and distinguishes a *quiet* observer (nothing new,
-/// actors live — where `borrow_next` would block) from an *ended* one.
+/// The observer's non-blocking step yields the same owned items as the
+/// `Stream` face, and distinguishes a *quiet* observer (nothing new,
+/// actors live — where an awaited `next` would block) from an *ended*
+/// one.
 #[test]
 fn try_next_distinguishes_quiet_from_ended() {
     use rumors::TryNext;
@@ -418,7 +419,7 @@ fn try_next_distinguishes_quiet_from_ended() {
     let mut obs = rumors.unordered_messages();
     let mut seen = BTreeSet::new();
     while let TryNext::Message((_, m)) = obs.try_next() {
-        seen.insert(**m);
+        seen.insert(*m);
     }
     assert_eq!(seen, BTreeSet::from([1, 2]), "the pending pass drains");
     assert!(
@@ -430,7 +431,7 @@ fn try_next_distinguishes_quiet_from_ended() {
     let TryNext::Message((_, m)) = obs.try_next() else {
         panic!("the new send is immediately available");
     };
-    assert_eq!(**m, 3);
+    assert_eq!(*m, 3);
 
     drop(rumors);
     assert!(matches!(obs.try_next(), TryNext::Ended));
@@ -440,12 +441,10 @@ fn try_next_distinguishes_quiet_from_ended() {
     );
 }
 
-/// The owned-item face: the `Stream` impl yields the same messages as
-/// `borrow_next`, owned, and terminates with `None` once the set closes.
+/// The `Stream` impl yields every message, owned, and terminates with
+/// `None` once the set closes.
 #[test]
 fn stream_face_matches_and_terminates() {
-    use futures::StreamExt;
-
     let rumors = Peer::::seed().sync_window_floor().into_rumors();
     rumors.batch().send(1).send(2);
     let expected = live_map(&rumors);

From ec67f1bf895a7501cbb84ba62a29d29abe95a7af Mon Sep 17 00:00:00 2001
From: finch 
Date: Wed, 19 Aug 2026 20:34:53 -0400
Subject: [PATCH 09/18] item-erasure sketch: the observers' typed boundary
 after lending dissolved

---
 design/item-erasure.md | 10 ++++------
 1 file changed, 4 insertions(+), 6 deletions(-)

diff --git a/design/item-erasure.md b/design/item-erasure.md
index 6830bd4ad..ad1c756d4 100644
--- a/design/item-erasure.md
+++ b/design/item-erasure.md
@@ -96,12 +96,10 @@ payloads failing at ingress as `DecodeError::Record` exactly as today.
   is nothing.
 - No public API movement: `Message` is crate-internal (nothing
   re-exports it; verified against the public rustdoc surface), and the
-  public observers already speak `(Version, Arc)`. The one
-  mechanical seam is the observers' lending contract —
-  `borrow_next() -> (&Version, &Arc)` lends from a retained
-  most-recent-leaf slot, so that slot becomes the downcast point: one
-  `Arc::downcast::()` (a refcount bump plus the `TypeId` check) per
-  yielded message, a cost the owned `(Version, Arc)` item already
+  public observers speak owned `(Version, Arc)` on every face (the
+  former lending forms dissolved when `Version` went CoW), so the
+  typed boundary is one `Arc::downcast::()` — a refcount bump plus
+  the `TypeId` check — folded into the clone each yielded item already
   pays. `T: Serialize` migrates to the insert boundary and
   `DeserializeOwned` to witness minting; the public bounds stay
   equivalent.

From ee1869574a0ba209a87d948626bd94218555ed0b Mon Sep 17 00:00:00 2001
From: finch 
Date: Wed, 19 Aug 2026 20:49:36 -0400
Subject: [PATCH 10/18] item-erasure: record the owner rulings and the staged
 plan

Witness at peer construction; non-generic core sealing with the public
API unchanged (fall back to erasing generic shells if it turns hairy;
legibility outranks purity); tree and session in one stroke; landing on
the height-erasure branch.
---
 design/item-erasure.md | 56 +++++++++++++++++++++++++++++-------------
 1 file changed, 39 insertions(+), 17 deletions(-)

diff --git a/design/item-erasure.md b/design/item-erasure.md
index ad1c756d4..9e910d924 100644
--- a/design/item-erasure.md
+++ b/design/item-erasure.md
@@ -1,9 +1,9 @@
 # Item-type erasure at the leaf boundary (part II sketch)
 
-Status: sketch, not implemented — the "phase 2" the height-erasure work
-names. Written after height erasure landed; numbers marked *measured*
-come from `cargo llvm-lines --test pairwise` (debug, default features)
-on the height-erased tree.
+Status: accepted (option B, with the rulings below); implementation in
+progress on the `height-erasure` branch. Numbers marked *measured* come
+from `cargo llvm-lines --test pairwise` (debug, default features) on
+the height-erased tree.
 
 ## The problem, precisely
 
@@ -131,16 +131,38 @@ security-relevant: payload validation stays exactly where it is
   the "what does the next consumer pay" meter.
 - The `gossip_fixed_bidir_insertions/5000` bench as the runtime pin.
 
-## Open questions for review
-
-1. Witness minting site: at `Peer` construction (one witness for the
-   peer's lifetime, threaded through sessions), or per gossip call?
-   Construction seems right — it is also where `DeserializeOwned`
-   naturally lives.
-2. Facade shape: seal the session core behind non-generic functions in
-   the rlib (taking `&mut dyn` link objects — the transport is already
-   dyn-erased), or keep generic entry points that immediately erase?
-3. Does the tree erase in the same stroke (it must for the full win —
-   `untyped::Node`'s only `T` is the stored `Message`), or does a
-   first landing keep a typed tree and erase at the session boundary
-   (option A) to de-risk?
+## Rulings (owner-resolved)
+
+1. **Witness minting**: at `Peer` construction — one witness per
+   peer lifetime, stored alongside the erased tree in the shared inner
+   state 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.
+
+## 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.
+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.
+3. **The erased session**: `Backend` drops `T`; the codec's record
+   decode keeps the wire bytes and builds payloads through the witness;
+   sessions receive the witness from the peer. Measure.
+4. **Sealing**: the session core's entry points go non-generic over
+   the erased tree, witness, 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.

From 00c007c706736f137232bafe02680fbac27ff337 Mon Sep 17 00:00:00 2001
From: finch 
Date: Wed, 19 Aug 2026 22:17:35 -0400
Subject: [PATCH 11/18] message: erase the payload type behind Arc
 (part II stage 1)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Message becomes non-generic: { message: Arc,
serialized: Bytes } — the caller's own Arc allocation, unsized in
place. Typed constructors sit at the insert boundary; the read boundary
downcasts (message::/arc::), and a payload-type mismatch panics as
a crate-bug tripwire (every message reachable from a typed facade was
constructed with that facade's type).

Owner-ruled public API movement (rulings 5 and 6 in
design/item-erasure.md): Snapshot::get returns Option> — the
echoed version was always the queried one, since a leaf's path derives
from its version — and iter/range/IntoIterator yield (&Version, Arc)
owned, because a coerced fat pointer has no Arc object to lend.
T: 'static joins the insert-path bounds (safe erasure is TypeId-based,
and Any requires it; gossip already demanded it).

Message equality and hashing now compare the cached serialization; the
Ord and serde-Deserialize forms dissolved (no consumers outside the
type's own tests), and the V1 wire decode routes through the typed
Message::from_reader. The tree stores erased Messages with T phantom on
NodeInner until stage 2 dissolves it; Action de-genericized; the Leaf
seam and the streaming codec's leaf records speak erased Message.

Measured (cargo llvm-lines --test pairwise, debug, default features):
1,039,827 lines / 41,186 copies vs 1,039,534 / 41,172 at the parent —
flat, as this stage predicts: the payload type left storage, but every
instantiation still exists while tree and session stay generic over the
phantom T. The dedup is stage 2's and 3's to collect.
---
 design/item-erasure.md                        |  39 ++-
 src/batch.rs                                  |   6 +-
 src/conformance/backend.rs                    |   4 +-
 src/conformance/backend/tests.rs              |   7 +-
 src/message.rs                                | 255 ++++++++----------
 src/message/tests.rs                          |  69 +++--
 src/peer.rs                                   |   2 +-
 src/rumors.rs                                 |   2 +-
 src/rumors/causal.rs                          |   2 +-
 src/rumors/unordered.rs                       |   2 +-
 src/snapshot.rs                               |  26 +-
 src/tree.rs                                   |  57 ++--
 src/tree/arb.rs                               |   2 +-
 src/tree/mirror/alternating/backend/remote.rs |  14 +-
 src/tree/mirror/alternating/message.rs        |  10 +-
 src/tree/mirror/alternating/tests.rs          |   6 +-
 src/tree/mirror/streaming/backend.rs          |   4 +-
 src/tree/mirror/streaming/backend/local.rs    |   4 +-
 .../streaming/materialized/unknown/tests.rs   |   2 +-
 src/tree/mirror/streaming/remote.rs           |   2 +-
 src/tree/mirror/streaming/remote/adapter.rs   |   2 +-
 .../mirror/streaming/remote/adapter/encode.rs |   2 +-
 .../mirror/streaming/remote/adapter/tests.rs  |   4 +-
 .../remote/adapter/tests/fan_occupancy.rs     |   8 +-
 .../remote/adapter/tests/malformed.rs         |   8 +-
 .../streaming/remote/adapter/tests/runs.rs    |   4 +-
 src/tree/mirror/streaming/remote/codec.rs     |   2 +-
 .../streaming/remote/codec/capture/tests.rs   |   2 +-
 .../streaming/remote/codec/decode/tests.rs    |   2 +-
 .../streaming/remote/codec/encode/tests.rs    |   2 +-
 .../mirror/streaming/remote/codec/frame.rs    |  16 +-
 .../streaming/remote/codec/frame/tests.rs     |   2 +-
 .../mirror/streaming/remote/codec/tests.rs    |   2 +-
 .../remote/codec/tests/error_atlas.rs         |   2 +-
 .../mirror/streaming/remote/proxy/tests.rs    |   8 +-
 src/tree/mirror/streaming/testing/failing.rs  |   4 +-
 src/tree/mirror/streaming/tests/fixtures.rs   |   4 +-
 src/tree/tests.rs                             |  74 ++---
 src/tree/traverse/act.rs                      |  12 +-
 src/tree/traverse/unknown/tests.rs            |   2 +-
 src/tree/typed/node.rs                        |  14 +-
 src/tree/typed/untyped.rs                     |  20 +-
 src/tree/typed/untyped/iter.rs                |  20 +-
 src/tree/typed/untyped/tests.rs               |   6 +-
 src/tree/wire.rs                              |  17 +-
 tests/bookmark_causality.rs                   |   4 +-
 tests/bookmark_transmit_window.rs             |   2 +-
 tests/bookmark_when.rs                        |   2 +-
 tests/bootstrap.rs                            |   6 +-
 tests/causal.rs                               |   2 +-
 tests/cbor_evolution.rs                       |   2 +-
 tests/changes.rs                              |   2 +-
 tests/common/action.rs                        |   2 +-
 tests/common/oracle.rs                        |   2 +-
 tests/common/peer.rs                          |   2 +-
 tests/common/shape.rs                         |   2 +-
 tests/common/sim.rs                           |   2 +-
 tests/gossip_snapshot.rs                      |  10 +-
 tests/gossip_when.rs                          |   6 +-
 tests/hop_trace.rs                            |   4 +-
 tests/listen.rs                               |   4 +-
 tests/opening_supply.rs                       |   2 +-
 tests/retire.rs                               |   8 +-
 tests/session_stats.rs                        |   2 +-
 tests/single_peer.rs                          |   2 +-
 tests/stale_floor.rs                          |   4 +-
 66 files changed, 424 insertions(+), 402 deletions(-)

diff --git a/design/item-erasure.md b/design/item-erasure.md
index 9e910d924..90b4a2370 100644
--- a/design/item-erasure.md
+++ b/design/item-erasure.md
@@ -94,15 +94,16 @@ payloads failing at ingress as `DecodeError::Record` exactly as today.
 - 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.
-- No public API movement: `Message` is crate-internal (nothing
-  re-exports it; verified against the public rustdoc surface), and the
-  public observers speak owned `(Version, Arc)` on every face (the
-  former lending forms dissolved when `Version` went CoW), so the
-  typed boundary is one `Arc::downcast::()` — a refcount bump plus
-  the `TypeId` check — folded into the clone each yielded item already
-  pays. `T: Serialize` migrates to the insert boundary and
-  `DeserializeOwned` to witness minting; the public bounds stay
-  equivalent.
+- 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
@@ -146,6 +147,21 @@ security-relevant: payload validation stays exactly where it is
    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)
 
@@ -153,6 +169,11 @@ security-relevant: payload validation stays exactly where it is
    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
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 8dae0603e..527fb9067 100644
--- a/src/conformance/backend.rs
+++ b/src/conformance/backend.rs
@@ -242,13 +242,13 @@ where
     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,
+        message: Message,
     ) -> Result>::Error> {
         let node = N::leaf(version, message).await?;
         let measured = >::measure::(&node);
diff --git a/src/conformance/backend/tests.rs b/src/conformance/backend/tests.rs
index a14550d62..3c4688bf8 100644
--- a/src/conformance/backend/tests.rs
+++ b/src/conformance/backend/tests.rs
@@ -246,11 +246,11 @@ impl Leaf for MaterializedNode>
 where
     T: Send + Sync + 'static,
 {
-    fn message(&self) -> &Message {
+    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
@@ -583,7 +583,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..a847abf79 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,49 +6,54 @@ 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 downcasts
+/// ([`message`](Self::message), [`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 reads panic on a payload type mismatch; see
+/// [`message`](Self::message).
+#[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(),
-        }
-    }
-}
-
 /// Map a ciborium deserialization failure into `io::Error`, keeping the
 /// truncation/corruption split callers classify by: a reader's own error
 /// passes through, everything else is invalid data.
@@ -73,16 +78,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 +96,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)?;
@@ -121,7 +126,10 @@ impl Message {
     /// 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 {
+    pub(crate) fn from_decoded(message: T, serialized: Bytes) -> Self
+    where
+        T: Send + Sync + 'static,
+    {
         Message {
             message: Arc::new(message),
             serialized,
@@ -131,11 +139,11 @@ impl Message {
     /// 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 +159,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,135 +175,105 @@ impl Message {
         }
     }
 
-    /// Returns a reference to the object represented by this message.
-    pub fn message(&self) -> &T {
-        &self.message
-    }
-
-    /// Returns a reference to the shared [`Arc`] holding this message's
-    /// object, without cloning it.
+    /// Reads one `Message` off a byte stream, consuming exactly its bytes.
     ///
-    /// Used by enumeration paths (e.g. [`Tree::iter`]) that hand out
-    /// borrowed `&Arc` exactly as the public observers do.
+    /// The shape is one CBOR byte string wrapping the payload's own CBOR
+    /// encoding (the same shape [`Serialize`] writes), decoded as a `T`
+    /// under [`from_slice`](Self::from_slice)'s exactly-one-value
+    /// contract.
     ///
-    /// [`Tree::iter`]: crate::tree::Tree::iter
-    pub fn as_arc(&self) -> &Arc {
-        &self.message
-    }
-
-    /// Returns the serialized bytes corresponding to this message.
-    pub fn as_slice(&self) -> &[u8] {
-        self.serialized.as_ref()
-    }
-
-    /// Returns a cheaply-clonable handle to the shared serialized bytes.
-    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
+    /// 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) -> io::Result
     where
-        T: Clone,
+        T: DeserializeOwned + Send + Sync + 'static,
+        R: io::Read,
     {
-        Arc::unwrap_or_clone(self.message)
+        let bytes: Vec = ciborium::de::from_reader(reader).map_err(de_error)?;
+        Self::from_slice::(&bytes)
     }
 
-    /// 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)
+    /// Borrows the payload as its concrete type.
+    ///
+    /// # Panics
+    ///
+    /// 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 message(&self) -> &T {
+        self.message
+            .downcast_ref::()
+            .expect("a message's payload type matches its tree's")
     }
-}
 
-impl From for Message {
-    /// Creates a `Message` pairing the given object with its cached
-    /// serialization.
+    /// Clones out an owned handle to the payload: a reference bump on the
+    /// same shared allocation.
     ///
     /// # Panics
     ///
-    /// If the message cannot be serialized (see [`Message`]).
-    fn from(message: T) -> Self {
-        Self::new(message)
+    /// If the payload is not a `T` (see [`message`](Self::message)).
+    pub fn arc(&self) -> Arc {
+        self.message
+            .clone()
+            .downcast::()
+            .unwrap_or_else(|_| panic!("a message's payload type matches its tree's"))
     }
-}
 
-impl AsRef for Message {
-    fn as_ref(&self) -> &T {
-        &self.message
+    /// Returns the serialized bytes corresponding to this message.
+    pub fn as_slice(&self) -> &[u8] {
+        self.serialized.as_ref()
     }
-}
 
-impl AsRef> for Message {
-    fn as_ref(&self) -> &Arc {
-        &self.message
+    /// Returns a cheaply-clonable handle to the shared serialized bytes.
+    pub fn bytes(&self) -> &Bytes {
+        &self.serialized
     }
 }
 
-// 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 {
-    fn eq(&self, other: &Self) -> bool {
-        self.message == other.message
-    }
-}
-
-impl Eq 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 PartialOrd for Message {
-    fn partial_cmp(&self, other: &Self) -> Option {
-        self.message.partial_cmp(&other.message)
+impl PartialEq for Message {
+    fn eq(&self, other: &Self) -> bool {
+        self.serialized == other.serialized
     }
 }
 
-impl Ord for Message {
-    fn cmp(&self, other: &Self) -> Ordering {
-        self.message.cmp(&other.message)
-    }
-}
+impl Eq for 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..146eee7bf 100644
--- a/src/message/tests.rs
+++ b/src/message/tests.rs
@@ -41,13 +41,14 @@ 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 reads recover 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.message::(), &p);
+        prop_assert_eq!(&*m.arc::(), &p);
     }
 
     /// `from_slice` reconstructs the inner value and stores exactly the input
@@ -55,8 +56,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.message::(), &p);
         prop_assert_eq!(m.bytes(), bytes.as_slice());
     }
 
@@ -65,8 +66,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 +78,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 +99,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()).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 +121,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).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 +137,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.message::();
+}
diff --git a/src/peer.rs b/src/peer.rs
index ed0e2b2ab..64b9728fe 100644
--- a/src/peer.rs
+++ b/src/peer.rs
@@ -544,7 +544,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/rumors.rs b/src/rumors.rs
index a2a4fabea..80d9a858e 100644
--- a/src/rumors.rs
+++ b/src/rumors.rs
@@ -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)
     }
diff --git a/src/rumors/causal.rs b/src/rumors/causal.rs
index 7a7cfe1be..a7bfbbf49 100644
--- a/src/rumors/causal.rs
+++ b/src/rumors/causal.rs
@@ -160,7 +160,7 @@ impl Stream for CausalMessages {
             // 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 97ca874b2..ef5267ff3 100644
--- a/src/rumors/unordered.rs
+++ b/src/rumors/unordered.rs
@@ -209,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/tree.rs b/src/tree.rs
index 4dacb3349..70490b0b2 100644
--- a/src/tree.rs
+++ b/src/tree.rs
@@ -81,7 +81,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
@@ -162,26 +162,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>);
 
-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 +192,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`].
@@ -277,13 +280,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 +314,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
@@ -347,15 +356,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 +416,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 +475,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
diff --git a/src/tree/arb.rs b/src/tree/arb.rs
index 0d15b0d19..1ff979a1a 100644
--- a/src/tree/arb.rs
+++ b/src/tree/arb.rs
@@ -534,7 +534,7 @@ fn root_with_ceiling(node: Option>, ceiling: Version) -> crate:
 pub fn poisoned_root(
     party: &Party,
     base: &Version,
-    message: Message,
+    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.
diff --git a/src/tree/mirror/alternating/backend/remote.rs b/src/tree/mirror/alternating/backend/remote.rs
index a54219cb2..4c604b3a6 100644
--- a/src/tree/mirror/alternating/backend/remote.rs
+++ b/src/tree/mirror/alternating/backend/remote.rs
@@ -172,7 +172,7 @@ impl protocol::Accept for Exchange
 where
     R: AsyncRead + Unpin + Send,
     W: AsyncWrite + Unpin + Send,
-    T: Serialize + DeserializeOwned + Send + Sync,
+    T: Serialize + DeserializeOwned + Send + Sync + 'static,
 {
     type Next = Exchange;
 
@@ -210,7 +210,7 @@ where
 
 impl protocol::Initiator for Exchange
 where
-    T: DeserializeOwned + Send + Sync,
+    T: DeserializeOwned + Send + Sync + 'static,
     R: AsyncRead + Unpin + Send,
     W: AsyncWrite + Unpin + Send,
     Node: wire::Decode,
@@ -232,7 +232,7 @@ where
 
 impl protocol::Responder for Exchange
 where
-    T: DeserializeOwned + Send + Sync,
+    T: DeserializeOwned + Send + Sync + 'static,
     R: AsyncRead + Unpin + Send,
     W: AsyncWrite + Unpin + Send,
     Node: wire::Decode,
@@ -260,7 +260,7 @@ where
 
 impl protocol::OpenInitiator for Exchange
 where
-    T: DeserializeOwned + Send + Sync,
+    T: DeserializeOwned + Send + Sync + 'static,
     R: AsyncRead + Unpin + Send,
     W: AsyncWrite + Unpin + Send,
     Node: wire::Decode,
@@ -294,7 +294,7 @@ where
 
 impl protocol::Exchange for Exchange>>
 where
-    T: DeserializeOwned + Send + Sync,
+    T: DeserializeOwned + Send + Sync + 'static,
     R: AsyncRead + Unpin + Send,
     W: AsyncWrite + Unpin + Send,
     H: Height,
@@ -343,7 +343,7 @@ where
 
 impl protocol::CloseResponder for Exchange>
 where
-    T: DeserializeOwned + Send + Sync,
+    T: DeserializeOwned + Send + Sync + 'static,
     R: AsyncRead + Unpin + Send,
     W: AsyncWrite + Unpin + Send,
 {
@@ -384,7 +384,7 @@ where
 
 impl protocol::CompleteInitiator for Exchange
 where
-    T: DeserializeOwned + Send + Sync,
+    T: DeserializeOwned + Send + Sync + 'static,
     R: AsyncRead + Unpin + Send,
     W: AsyncWrite + Unpin + Send,
 {
diff --git a/src/tree/mirror/alternating/message.rs b/src/tree/mirror/alternating/message.rs
index 8b05e3fe0..9ac65b0ae 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.
@@ -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]
 //! ```
 //!
@@ -226,7 +226,7 @@ where
 // discharges it.
 impl Decode for Exchange
 where
-    T: DeserializeOwned,
+    T: DeserializeOwned + Send + Sync + 'static,
     S: Height,
     H: Height,
     Node>: Decode,
@@ -310,7 +310,7 @@ impl Encode for Closing {
 
 impl Decode for Closing
 where
-    T: DeserializeOwned,
+    T: DeserializeOwned + Send + Sync + 'static,
 {
     fn read_wire(reader: &mut R) -> std::io::Result {
         let providing: Providing = Decode::read_wire(reader)?;
@@ -356,7 +356,7 @@ impl Encode for Complete {
 
 impl Decode for Complete
 where
-    T: DeserializeOwned,
+    T: DeserializeOwned + Send + Sync + 'static,
 {
     fn read_wire(reader: &mut R) -> std::io::Result {
         let providing: Providing = Decode::read_wire(reader)?;
diff --git a/src/tree/mirror/alternating/tests.rs b/src/tree/mirror/alternating/tests.rs
index bc4fe2cd9..d86e84883 100644
--- a/src/tree/mirror/alternating/tests.rs
+++ b/src/tree/mirror/alternating/tests.rs
@@ -84,7 +84,7 @@ fn mirror_via(
     scenario: Scenario,
 ) -> crate::tree::Root
 where
-    T: PartialEq + std::fmt::Debug + Serialize + DeserializeOwned + Send + Sync,
+    T: PartialEq + std::fmt::Debug + Serialize + DeserializeOwned + Send + Sync + 'static,
 {
     block_on(async move {
         match scenario {
@@ -233,7 +233,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,7 +257,7 @@ 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()),
diff --git a/src/tree/mirror/streaming/backend.rs b/src/tree/mirror/streaming/backend.rs
index d133b0d45..6e8e30926 100644
--- a/src/tree/mirror/streaming/backend.rs
+++ b/src/tree/mirror/streaming/backend.rs
@@ -306,7 +306,7 @@ pub trait ErasedNode {
 /// shape every backend must represent faithfully.
 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.
@@ -338,7 +338,7 @@ pub trait Leaf: Node {
     /// (the reclaimable-garbage case).
     fn leaf(
         version: Version,
-        message: Message,
+        message: Message,
     ) -> impl Future>::Error>> + Send
     where
         Self: Sized;
diff --git a/src/tree/mirror/streaming/backend/local.rs b/src/tree/mirror/streaming/backend/local.rs
index 557952fba..8bac7f470 100644
--- a/src/tree/mirror/streaming/backend/local.rs
+++ b/src/tree/mirror/streaming/backend/local.rs
@@ -73,13 +73,13 @@ impl ErasedNode for typed::untyped::Node {
 }
 
 impl Leaf for typed::Node {
-    fn message(&self) -> &Message {
+    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))
     }
 }
diff --git a/src/tree/mirror/streaming/materialized/unknown/tests.rs b/src/tree/mirror/streaming/materialized/unknown/tests.rs
index cc5151143..8d5857cf3 100644
--- a/src/tree/mirror/streaming/materialized/unknown/tests.rs
+++ b/src/tree/mirror/streaming/materialized/unknown/tests.rs
@@ -27,7 +27,7 @@ use crate::{
 /// 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();
+    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)] {
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..98297f47f 100644
--- a/src/tree/mirror/streaming/remote/adapter.rs
+++ b/src/tree/mirror/streaming/remote/adapter.rs
@@ -4,7 +4,7 @@
 //! 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:
 //!
diff --git a/src/tree/mirror/streaming/remote/adapter/encode.rs b/src/tree/mirror/streaming/remote/adapter/encode.rs
index a2231a9c6..f966c1520 100644
--- a/src/tree/mirror/streaming/remote/adapter/encode.rs
+++ b/src/tree/mirror/streaming/remote/adapter/encode.rs
@@ -210,7 +210,7 @@ where
                         let message = leaf.message();
                         if !run.is_empty()
                             && !budget
-                                .admits(run.encoded_len(), LeafRun::record_len(version, message))
+                                .admits(run.encoded_len(), LeafRun::::record_len(version, message))
                         {
                             let full = mem::take(&mut run);
                             if let Some((ready, question)) =
diff --git a/src/tree/mirror/streaming/remote/adapter/tests.rs b/src/tree/mirror/streaming/remote/adapter/tests.rs
index f94c3296b..0e9251e8b 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/fan_occupancy.rs b/src/tree/mirror/streaming/remote/adapter/tests/fan_occupancy.rs
index 5a3d67f5a..f641abad5 100644
--- a/src/tree/mirror/streaming/remote/adapter/tests/fan_occupancy.rs
+++ b/src/tree/mirror/streaming/remote/adapter/tests/fan_occupancy.rs
@@ -39,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))
@@ -51,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()
diff --git a/src/tree/mirror/streaming/remote/adapter/tests/malformed.rs b/src/tree/mirror/streaming/remote/adapter/tests/malformed.rs
index 54cd4c4f4..edd93886a 100644
--- a/src/tree/mirror/streaming/remote/adapter/tests/malformed.rs
+++ b/src/tree/mirror/streaming/remote/adapter/tests/malformed.rs
@@ -311,8 +311,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();
@@ -433,7 +433,7 @@ fn leaf_scope_is_enforced_within_one_run() {
         .find(|candidate| Prefix::::containing(&candidate.path()).pop().0 != parent)
         .expect("content paths do not all share one leaf parent");
     let frames = vec![Frame::Reaction(
-        WireReaction::Supply(leaf_run(&[
+        WireReaction::Supply(leaf_run::(&[
             (&inside.version, &inside.message),
             (&outside.version, &outside.message),
         ])),
@@ -508,7 +508,7 @@ fn a_version_over_the_declared_bound_is_rejected() {
     let declared = leaf.version.as_bytes().len() as u64;
     let frames = || {
         vec![Frame::Reaction(
-            WireReaction::Supply(leaf_run(&[(&leaf.version, &leaf.message)])),
+            WireReaction::Supply(leaf_run::(&[(&leaf.version, &leaf.message)])),
             Flow::End,
         )]
     };
diff --git a/src/tree/mirror/streaming/remote/adapter/tests/runs.rs b/src/tree/mirror/streaming/remote/adapter/tests/runs.rs
index f07359fdd..9c5bfb60d 100644
--- a/src/tree/mirror/streaming/remote/adapter/tests/runs.rs
+++ b/src/tree/mirror/streaming/remote/adapter/tests/runs.rs
@@ -137,7 +137,7 @@ 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()
@@ -197,7 +197,7 @@ proptest! {
                 prop_assert!(
                     SUPPLY_FRAME_OVERHEAD
                         + run.encoded_len()
-                        + LeafRun::record_len(&version, &message)
+                        + LeafRun::::record_len(&version, &message)
                         > budget,
                     "run {position} flushed although the next record fit"
                 );
diff --git a/src/tree/mirror/streaming/remote/codec.rs b/src/tree/mirror/streaming/remote/codec.rs
index 154c2f4db..0ebd53460 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
diff --git a/src/tree/mirror/streaming/remote/codec/capture/tests.rs b/src/tree/mirror/streaming/remote/codec/capture/tests.rs
index 35b2fb32b..2cf597d2c 100644
--- a/src/tree/mirror/streaming/remote/codec/capture/tests.rs
+++ b/src/tree/mirror/streaming/remote/codec/capture/tests.rs
@@ -38,7 +38,7 @@ fn supply_decode_names_the_field_that_moved() {
     high.tick(&party);
 
     let render = |version: &Version| {
-        let mut run = LeafRun::new();
+        let mut run = LeafRun::::new();
         run.push(version, &Message::new(7_u64))
             .expect("one small record fits any run");
         supply_lines(run.as_bytes().to_vec())
diff --git a/src/tree/mirror/streaming/remote/codec/decode/tests.rs b/src/tree/mirror/streaming/remote/codec/decode/tests.rs
index 72289053f..b2bdb3e52 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());
diff --git a/src/tree/mirror/streaming/remote/codec/encode/tests.rs b/src/tree/mirror/streaming/remote/codec/encode/tests.rs
index 8a3bd85cc..848f62baa 100644
--- a/src/tree/mirror/streaming/remote/codec/encode/tests.rs
+++ b/src/tree/mirror/streaming/remote/codec/encode/tests.rs
@@ -118,7 +118,7 @@ proptest! {
         records in proptest::collection::vec((arb_version(), any::()), 1..=4),
     ) {
         let stream = stream(index);
-        let mut run = super::LeafRun::new();
+        let mut run = super::LeafRun::::new();
         let mut body = Vec::new();
         for (version, value) in &records {
             let message = Message::new(*value);
diff --git a/src/tree/mirror/streaming/remote/codec/frame.rs b/src/tree/mirror/streaming/remote/codec/frame.rs
index 712f6c46c..dcbb3fc2f 100644
--- a/src/tree/mirror/streaming/remote/codec/frame.rs
+++ b/src/tree/mirror/streaming/remote/codec/frame.rs
@@ -52,7 +52,7 @@ 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 —
@@ -142,7 +142,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 +157,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())
@@ -206,11 +206,11 @@ impl LeafRun {
     }
 
     /// Iterate the run's records, decoding each into its canonical pair.
-    pub fn records(&self) -> impl Iterator), DecodeLeafError>>
+    pub fn records(&self) -> impl Iterator>
     where
-        T: DeserializeOwned,
+        T: DeserializeOwned + Send + Sync + 'static,
     {
-        self.record_slices().map(parse_record)
+        self.record_slices().map(parse_record::)
     }
 
     /// Split the validated run back into its exact record slices.
@@ -263,9 +263,9 @@ 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> {
+) -> 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.
diff --git a/src/tree/mirror/streaming/remote/codec/frame/tests.rs b/src/tree/mirror/streaming/remote/codec/frame/tests.rs
index 3f2acdb0c..4394f4900 100644
--- a/src/tree/mirror/streaming/remote/codec/frame/tests.rs
+++ b/src/tree/mirror/streaming/remote/codec/frame/tests.rs
@@ -63,7 +63,7 @@ fn record_len_matches_an_actual_push() {
             run.push(&version, &message).expect("test records fit");
             assert_eq!(
                 run.encoded_len(),
-                LeafRun::record_len(&version, &message),
+                LeafRun::::record_len(&version, &message),
                 "record_len must price exactly one pushed record",
             );
             // The version atom `push` writes is byte-identical to the
diff --git a/src/tree/mirror/streaming/remote/codec/tests.rs b/src/tree/mirror/streaming/remote/codec/tests.rs
index 8b7890835..7adb6b03f 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()))
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..a8b261f60 100644
--- a/src/tree/mirror/streaming/remote/codec/tests/error_atlas.rs
+++ b/src/tree/mirror/streaming/remote/codec/tests/error_atlas.rs
@@ -82,7 +82,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");
diff --git a/src/tree/mirror/streaming/remote/proxy/tests.rs b/src/tree/mirror/streaming/remote/proxy/tests.rs
index 9c02da448..a845eb1b8 100644
--- a/src/tree/mirror/streaming/remote/proxy/tests.rs
+++ b/src/tree/mirror/streaming/remote/proxy/tests.rs
@@ -290,9 +290,9 @@ 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))
@@ -306,9 +306,9 @@ 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))
diff --git a/src/tree/mirror/streaming/testing/failing.rs b/src/tree/mirror/streaming/testing/failing.rs
index 1ab69ff99..36a6f5dec 100644
--- a/src/tree/mirror/streaming/testing/failing.rs
+++ b/src/tree/mirror/streaming/testing/failing.rs
@@ -180,7 +180,7 @@ where
     T: Send + Sync + 'static,
     N: Leaf + Clone + Send + 'static,
 {
-    fn message(&self) -> &Message {
+    fn message(&self) -> &Message {
         self.0.message()
     }
 
@@ -188,7 +188,7 @@ where
     // traversal operations, not construction.
     async fn leaf(
         version: Version,
-        message: Message,
+        message: Message,
     ) -> Result>::Error>> {
         N::leaf(version, message)
             .await
diff --git a/src/tree/mirror/streaming/tests/fixtures.rs b/src/tree/mirror/streaming/tests/fixtures.rs
index d81c6ef31..aec6f689e 100644
--- a/src/tree/mirror/streaming/tests/fixtures.rs
+++ b/src/tree/mirror/streaming/tests/fixtures.rs
@@ -39,7 +39,7 @@ pub(super) fn grown(
     paths: &[Path],
 ) -> Option>
 where
-    T: Serialize + Clone + Send + Sync,
+    T: Serialize + Clone + Send + Sync + 'static,
 {
     assert!(stride > 0, "each leaf needs a fresh version");
     let party = nth_party(party);
@@ -238,7 +238,7 @@ impl Divergence {
     /// in both.
     pub fn trees(&self, value: &T) -> (Root, Root, Root)
     where
-        T: Serialize + Clone + Send + Sync,
+        T: Serialize + Clone + Send + Sync + 'static,
     {
         let as_paths =
             |bytes: Vec<[u8; 32]>| -> Vec { bytes.into_iter().map(Path::from).collect() };
diff --git a/src/tree/tests.rs b/src/tree/tests.rs
index 60b7491ac..1e0cc7e2b 100644
--- a/src/tree/tests.rs
+++ b/src/tree/tests.rs
@@ -13,16 +13,16 @@ fn arb_path() -> impl Strategy {
     any::<[u8; 32]>().prop_map(Path::from)
 }
 
-/// Wrap a `Bytes` value as a `Message` with its cached serialization.
+/// Wrap a `Bytes` value as a `Message` with its cached serialization.
 /// Tests speak in terms of raw `Bytes`, but the tree's API takes
-/// `Message`, so every insert goes through this one-liner.
-fn msg(b: Bytes) -> Message {
+/// `Message`, so every insert goes through this one-liner.
+fn msg(b: Bytes) -> Message {
     Message::new(b)
 }
 
 /// Wrap a value as the insert action the tree accepts, with its cached
 /// serialization.
-fn insert_action(b: Bytes) -> Action {
+fn insert_action(b: Bytes) -> Action {
     Action::Insert(msg(b))
 }
 
@@ -100,7 +100,7 @@ fn insert_at(
     party: impl AsRef<[u8]>,
     scalar: u64,
     value: Bytes,
-) -> (Path, Version, Message) {
+) -> (Path, Version, Message) {
     (leaf_path(party, scalar), version, msg(value))
 }
 
@@ -218,7 +218,7 @@ proptest! {
         values in proptest::collection::vec(any::>(), 0..16)
             .prop_map(|v| v.into_iter().map(Bytes::from).collect::>()),
     ) {
-        let mut tree = Tree::new();
+        let mut tree = Tree::::new();
         tree.act(&party_of("P"), values.iter().cloned().map(insert_action));
         let reference_input: Vec<_> = values
             .into_iter()
@@ -254,7 +254,7 @@ proptest! {
     ) {
         // One tick of the leaf's own disjoint party; kept leaf indices come
         // from base order, extras continue the numbering beyond them.
-        let event = |index: usize, b: &Bytes| -> (Path, Version, Message) {
+        let event = |index: usize, b: &Bytes| -> (Path, Version, Message) {
             let mut version = Version::new();
             version.tick(&crate::tree::arb::nth_party(index));
             let message = msg(b.clone());
@@ -276,7 +276,7 @@ proptest! {
 
         // Route B: shuffled order, split into two batches, with the extra
         // leaves inserted in between and redacted again afterwards.
-        let extra_events: Vec<(Path, Version, Message)> = extras
+        let extra_events: Vec<(Path, Version, Message)> = extras
             .iter()
             .enumerate()
             .map(|(i, b)| event(kept.len() + i, b))
@@ -317,7 +317,7 @@ proptest! {
                     <[u8; 32]>::from(Path::for_leaf(version)),
                     Some(untyped::Node::leaf(
                         version.clone(),
-                        Message::new((**value).clone()),
+                        Message::new((*value).clone()),
                     )),
                 )
             })
@@ -356,11 +356,11 @@ proptest! {
             insert_at(version_for(&party, scalar), &party, scalar, b)
         };
 
-        let mut all_in_one = Tree::new();
+        let mut all_in_one = Tree::::new();
         all_in_one
             .react(bytes.iter().cloned().enumerate().map(event));
 
-        let mut partitioned = Tree::new();
+        let mut partitioned = Tree::::new();
         let mut chunk: Vec<(usize, Bytes)> = Vec::new();
         for (i, b) in bytes.iter().cloned().enumerate() {
             chunk.push((i, b));
@@ -389,7 +389,7 @@ proptest! {
     fn act_sequence_equals_react_with_explicit_versions(
         bytes in distinct_bytes(16),
     ) {
-        let mut t_act = Tree::new();
+        let mut t_act = Tree::::new();
         for b in &bytes {
             t_act.act(&party_of("P"), [insert_action(b.clone())]);
         }
@@ -399,7 +399,7 @@ proptest! {
             .map(|i| version_for(&party, i as u64))
             .collect();
 
-        let mut t_react = Tree::new();
+        let mut t_react = Tree::::new();
         t_react.react(
             versions
                 .into_iter()
@@ -514,7 +514,7 @@ proptest! {
         let value = Bytes::from(value);
         let path = leaf_path(&party, 1);
 
-        let mut tree = Tree::new();
+        let mut tree = Tree::::new();
         tree.act(&party_of("P"), [insert_action(value)]);
         tree.act(&party_of("P"), [Action::Forget(path)]);
 
@@ -532,7 +532,7 @@ proptest! {
         let value = Bytes::from(value);
         let path = leaf_path(&party, 1);
 
-        let mut tree = Tree::new();
+        let mut tree = Tree::::new();
         tree.act(&party_of("P"), [insert_action(value), Action::Forget(path)]);
 
         prop_assert_eq!(tree.hash(), *reference_hash(&[]).as_bytes());
@@ -553,7 +553,7 @@ proptest! {
             .collect();
         prop_assume!(!present.contains(&nuke));
 
-        let mut t_before = Tree::new();
+        let mut t_before = Tree::::new();
         t_before.act(&party_of("P"), bytes.into_iter().map(insert_action));
         let mut t_after = t_before.clone();
         t_after.act(&party_of("P"), [Action::Forget(nuke)]);
@@ -577,14 +577,14 @@ proptest! {
         batch_size in 1usize..8,
     ) {
         let party = "P".to_string();
-        let mut tree = Tree::new();
+        let mut tree = Tree::::new();
         for i in 0..prior_inserts {
             tree.act(&party_of(&party), [insert_action(Bytes::from(
                 format!("prior-{i}").into_bytes(),
             ))]);
         }
 
-        let actions: Vec> = (0..batch_size)
+        let actions: Vec = (0..batch_size)
             .map(|i| {
                 insert_action(Bytes::from(format!("batch-{i}").into_bytes()))
             })
@@ -602,14 +602,14 @@ proptest! {
     /// An empty `act` batch leaves the version vector completely unchanged.
     #[test]
     fn empty_act_is_a_version_noop(prior_batches in 0usize..4) {
-        let mut tree = Tree::new();
+        let mut tree = Tree::::new();
         for i in 0..prior_batches {
             tree.act(&party_of("P"), [insert_action(Bytes::from(
                 format!("prior-{i}").into_bytes(),
             ))]);
         }
         let before = tree.latest().clone();
-        tree.act(&party_of("P"), std::iter::empty::>());
+        tree.act(&party_of("P"), std::iter::empty::());
         prop_assert_eq!(tree.latest(), before);
     }
 
@@ -644,7 +644,7 @@ proptest! {
             })
             .collect();
 
-        let mut t_ab = Tree::new();
+        let mut t_ab = Tree::::new();
         t_ab.react(batch_a.clone());
         t_ab.react(batch_b.clone());
 
@@ -675,7 +675,7 @@ proptest! {
             })
             .collect();
 
-        let mut t_once = Tree::new();
+        let mut t_once = Tree::::new();
         t_once.react(batch.clone());
 
         let mut t_twice = Tree::new();
@@ -709,7 +709,7 @@ proptest! {
             .zip(versions.iter().cloned().enumerate().map(|(i, v)| (v, (i + 1) as u64)))
             .collect();
 
-        let mut t_base = Tree::new();
+        let mut t_base = Tree::::new();
         t_base.react(base.iter().cloned().map(|b| {
             let (v, scalar) = meta_by_value.get(&b).unwrap();
             insert_at(v.clone(), &party, *scalar, b)
@@ -742,7 +742,7 @@ proptest! {
         // replay the event. This is the information a real synchronization
         // protocol would put on the wire.
         let mut tree_a: Tree = Tree::new();
-        let mut a_events: Vec<(Path, Version, Message)> = Vec::new();
+        let mut a_events: Vec<(Path, Version, Message)> = Vec::new();
         for (i, value) in a_inserts.iter().enumerate() {
             let scalar = (i + 1) as u64;
             let mut recorded = tree_a.latest().clone();
@@ -752,7 +752,7 @@ proptest! {
         }
 
         let mut tree_b: Tree = Tree::new();
-        let mut b_events: Vec<(Path, Version, Message)> = Vec::new();
+        let mut b_events: Vec<(Path, Version, Message)> = Vec::new();
         for (i, value) in b_inserts.iter().enumerate() {
             let scalar = (i + 1) as u64;
             let mut recorded = tree_b.latest().clone();
@@ -773,7 +773,7 @@ proptest! {
     /// semantic operation.
     #[test]
     fn clone_preserves_all_observables(acts in distinct_bytes(8)) {
-        let mut tree = Tree::new();
+        let mut tree = Tree::::new();
         tree.act(&party_of("P"), acts.into_iter().map(insert_action));
         let cloned = tree.clone();
 
@@ -791,7 +791,7 @@ proptest! {
     /// equal, so the implication is exercised on its non-vacuous branch.
     #[test]
     fn eq_implies_same_hash(acts in distinct_bytes(8)) {
-        let mut t1 = Tree::new();
+        let mut t1 = Tree::::new();
         t1.act(&party_of("P"), acts.iter().cloned().map(insert_action));
         let mut t2 = Tree::new();
         t2.act(&party_of("P"), acts.into_iter().map(insert_action));
@@ -810,8 +810,8 @@ proptest! {
     #[test]
     fn same_value_different_parties_differ(value in any::>()) {
         let value = Bytes::from(value);
-        let mut t_a = Tree::new();
-        let mut t_b = Tree::new();
+        let mut t_a = Tree::::new();
+        let mut t_b = Tree::::new();
         t_a.act(&party_of("A"), [insert_action(value.clone())]);
         t_b.act(&party_of("B"), [insert_action(value)]);
 
@@ -836,11 +836,11 @@ proptest! {
         let path_v2 = leaf_path(&party, 2);
 
         prop_assert_ne!(path_v1, path_v2);
-        let got = [
+        let got: [Arc; 2] = [
             tree.get(&version_for(&party, 1)).unwrap(),
             tree.get(&version_for(&party, 2)).unwrap(),
         ];
-        prop_assert!(got.iter().all(|b| b.1[..] == *value));
+        prop_assert!(got.iter().all(|b| **b == value));
     }
 }
 
@@ -856,8 +856,8 @@ fn delete_nonexistent_key() {
 
 /// Project a borrowed leaf pair to an owned one, for collecting and
 /// comparing walk outputs.
-fn owned((version, value): (&Version, &Arc)) -> (Version, Arc) {
-    (version.clone(), value.clone())
+fn owned((version, value): (&Version, Arc)) -> (Version, Arc) {
+    (version.clone(), value)
 }
 
 proptest! {
@@ -975,7 +975,7 @@ proptest! {
         for (version, value) in &forward {
             prop_assert_eq!(
                 tree.get(version),
-                Some((version, value)),
+                Some(value.clone()),
                 "get resolves every iterated version",
             );
         }
@@ -1141,7 +1141,7 @@ proptest! {
         );
         let live: Vec = tree.iter().map(|(v, _)| Path::for_leaf(v)).collect();
 
-        let mut actions: Vec> =
+        let mut actions: Vec =
             batch_values.iter().cloned().map(insert_action).collect();
         for index in forget_live {
             if !live.is_empty() {
@@ -1509,7 +1509,7 @@ fn act_unwind_leaves_tree_byte_identical() {
     // section touches the tree.
     let panicking_actions = [insert_action(Bytes::from_static(b"casualty"))]
         .into_iter()
-        .chain(std::iter::once_with(|| -> Action {
+        .chain(std::iter::once_with(|| -> Action {
             panic!("injected: actions iterator panics mid-drain")
         }));
     let unwound = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
@@ -1588,7 +1588,7 @@ fn join_unwind_leaves_tree_byte_identical() {
 
 /// A test payload whose destructor panics while armed.
 ///
-/// [`Message`] clones share one `Arc`, so `T`'s destructor runs only
+/// [`Message`] clones share one payload allocation, so `T`'s destructor runs only
 /// when the *last* handle drops: arming the flag turns that drop —
 /// wherever the tree performs it — into the commit sections' one
 /// caller-reachable mid-walk unwind source. Construction and serialization
diff --git a/src/tree/traverse/act.rs b/src/tree/traverse/act.rs
index bfa665770..af8af9692 100644
--- a/src/tree/traverse/act.rs
+++ b/src/tree/traverse/act.rs
@@ -7,9 +7,9 @@ use height::{Height, Root, S, Z};
 
 /// An action to perform at a particular [`Path`].
 #[derive(Debug, Clone)]
-pub enum Action {
+pub enum Action {
     /// Insert a value tagged by a version at a party.
-    Insert(Message),
+    Insert(Message),
     /// Delete a value at this path.
     Forget,
 }
@@ -41,7 +41,7 @@ pub fn act(
 where
     T: Send + Sync,
     F: FnMut(&Version),
-    I: IntoIterator)>,
+    I: IntoIterator,
 {
     // Test-only unwind source for the panic-atomicity pins: this walk is
     // the unwind-source region of `Tree::react`'s commit section, and its entry
@@ -69,7 +69,7 @@ pub trait Act: Height {
     where
         T: Send + Sync,
         F: FnMut(&Version),
-        I: IntoIterator, Version, Action)>;
+        I: IntoIterator, Version, Action)>;
 }
 
 impl Act for S
@@ -84,7 +84,7 @@ where
     where
         T: Send + Sync,
         F: FnMut(&Version),
-        I: IntoIterator, Version, Action)>,
+        I: IntoIterator, Version, Action)>,
     {
         // Test-only unwind source, continued: every branch-level apply step
         // burns one fuse step, so a fuse armed past the entry unwinds only
@@ -156,7 +156,7 @@ impl Act for Z {
     where
         T: Send + Sync,
         F: FnMut(&Version),
-        I: IntoIterator, Version, Action)>,
+        I: IntoIterator, Version, Action)>,
     {
         let existed_before = node.is_some();
         let mut greatest_version = Version::default();
diff --git a/src/tree/traverse/unknown/tests.rs b/src/tree/traverse/unknown/tests.rs
index 476d466bf..1f816e8d6 100644
--- a/src/tree/traverse/unknown/tests.rs
+++ b/src/tree/traverse/unknown/tests.rs
@@ -94,7 +94,7 @@ fn wide_divergence(
     known_leaves: usize,
     divergent_leaves: usize,
 ) -> (Option>, Version) {
-    let mut actions: Vec<(Path, Version, Action<()>)> = Vec::new();
+    let mut actions: Vec<(Path, Version, Action)> = Vec::new();
     let mut known = Version::new();
 
     for (party_index, count, flagged) in [(0, known_leaves, true), (1, divergent_leaves, false)] {
diff --git a/src/tree/typed/node.rs b/src/tree/typed/node.rs
index d76d88a51..4cfdbe470 100644
--- a/src/tree/typed/node.rs
+++ b/src/tree/typed/node.rs
@@ -363,7 +363,7 @@ where
 
 impl Node {
     /// Construct a new leaf node from a versioned message.
-    pub fn leaf(version: Version, message: Message) -> Self {
+    pub fn leaf(version: Version, message: Message) -> Self {
         Self {
             height: PhantomData,
             inner: untyped::Node::leaf(version, message),
@@ -371,7 +371,7 @@ impl Node {
     }
 
     /// Get a reference to the message at this leaf node.
-    pub fn message(&self) -> &Message {
+    pub fn message(&self) -> &Message {
         self.inner
             .as_leaf()
             .expect("typed leaf failed to be a leaf")
@@ -389,12 +389,12 @@ impl Node {
 
     /// Look up the live leaf whose full 32-byte path is `path`, by a single
     /// `O(depth)` descent.
-    pub fn get(&self, path: &[u8]) -> Option<(&Version, &Message)> {
+    pub fn get(&self, path: &[u8]) -> Option<(&Version, &Message)> {
         self.inner.get(path)
     }
 
     /// Lazily iterate every live leaf in this root subtree as
-    /// `([u8; 32], &Version, &Message)`.
+    /// `([u8; 32], &Version, &Message)`.
     ///
     /// Delegates to the height-agnostic untyped walk; because this is a
     /// height-32 root, every yielded path is a full 32-byte array.
@@ -484,7 +484,7 @@ where
 #[cfg(any(test, feature = "protocol-v1"))]
 impl wire::Decode for Node
 where
-    T: DeserializeOwned,
+    T: DeserializeOwned + Send + Sync + 'static,
 {
     fn read_wire(reader: &mut R) -> std::io::Result {
         let prefix_len = u8::read_wire(reader)?;
@@ -492,7 +492,7 @@ where
             return Err(wire::invalid("leaf height cannot carry a prefix"));
         }
         let version = Version::read_wire(reader)?;
-        let message = Message::::read_wire(reader)?;
+        let message = Message::from_reader::(reader)?;
         Ok(Node::leaf(version, message))
     }
 }
@@ -500,7 +500,7 @@ where
 #[cfg(any(test, feature = "protocol-v1"))]
 impl wire::Decode for Node>
 where
-    T: DeserializeOwned,
+    T: DeserializeOwned + Send + Sync + 'static,
     H: Height,
     S: Height,
     Node: wire::Decode,
diff --git a/src/tree/typed/untyped.rs b/src/tree/typed/untyped.rs
index 1d7b62ed6..e224d4d16 100644
--- a/src/tree/typed/untyped.rs
+++ b/src/tree/typed/untyped.rs
@@ -7,6 +7,7 @@ use tinyvec::ArrayVec;
 use before::{Dominance, Span};
 
 use crate::{Version, message::Message, tree::typed::Hash};
+use std::marker::PhantomData;
 
 pub mod fan;
 mod iter;
@@ -77,6 +78,13 @@ pub(crate) mod census {
 }
 
 struct NodeInner {
+    /// The payload type the containing tree's facade reads its leaves at.
+    ///
+    /// Stored messages are type-erased ([`Message`] holds `dyn Any`), so
+    /// nothing structural consumes `T` here: the marker keeps the veneer's
+    /// parameter anchored (as `fn() -> T`, so auto-traits never descend
+    /// into `T`) until the typed boundary downcasts at the crate's API.
+    marker: PhantomData T>,
     /// Compressed path above this node's own branching level, stored with the
     /// deepest byte at index 0 and the shallowest byte at the last index. An
     /// empty prefix means the node is not path-compressed above its level.
@@ -104,6 +112,7 @@ struct NodeInner {
 impl Clone for NodeInner {
     fn clone(&self) -> Self {
         Self {
+            marker: PhantomData,
             prefix: self.prefix.clone(),
             hash: self.hash.clone(),
             children: self.children.clone(),
@@ -128,7 +137,7 @@ enum Children {
         /// The version of this leaf.
         version: Version,
         /// The payload of this leaf.
-        message: Message,
+        message: Message,
     },
     /// A materialized branch point, with the invariant that there are always >=
     /// 2 branches (or else they should be path-compressed away).
@@ -211,6 +220,7 @@ impl Node {
                 Some(node.beneath(index))
             }
             _ => Some(Node::from_inner(Arc::new(NodeInner {
+                marker: PhantomData,
                 prefix: Vec::new(),
                 hash: OnceLock::new(),
                 children: Children::Branch {
@@ -329,6 +339,7 @@ impl Node {
         debug_assert!(children.len() >= 2, "a branch point separates >= 2 runs");
 
         Node::from_inner(Arc::new(NodeInner {
+            marker: PhantomData,
             prefix: first[depth..branch_at].iter().rev().copied().collect(),
             hash: OnceLock::new(),
             children: Children::Branch {
@@ -341,8 +352,9 @@ impl Node {
     }
 
     /// Construct a new leaf node.
-    pub fn leaf(version: Version, value: Message) -> Self {
+    pub fn leaf(version: Version, value: Message) -> Self {
         Node::from_inner(Arc::new(NodeInner {
+            marker: PhantomData,
             prefix: Vec::new(),
             hash: OnceLock::new(),
             children: Children::Leaf {
@@ -353,7 +365,7 @@ impl Node {
     }
 
     /// Get a reference to the leaf at this node, if it is a leaf.
-    pub fn as_leaf(&self) -> Option<&Message> {
+    pub fn as_leaf(&self) -> Option<&Message> {
         match &self.inner.children {
             Children::Leaf { message, .. } => Some(message),
             _ => None,
@@ -363,7 +375,7 @@ impl Node {
     /// Look up the leaf at `path` beneath this node: a single root-to-leaf
     /// descent costing `O(depth)`, never a scan. `None` when no live leaf
     /// sits at that path.
-    pub fn get(&self, mut path: &[u8]) -> Option<(&Version, &Message)> {
+    pub fn get(&self, mut path: &[u8]) -> Option<(&Version, &Message)> {
         let mut node = self;
         loop {
             // Consume the compressed prefix, shallowest byte first (it is
diff --git a/src/tree/typed/untyped/iter.rs b/src/tree/typed/untyped/iter.rs
index c5f57ddd2..ed01632a9 100644
--- a/src/tree/typed/untyped/iter.rs
+++ b/src/tree/typed/untyped/iter.rs
@@ -100,7 +100,7 @@ impl<'a, T, P: Polarity> Walk<'a, T, P> {
     /// ordered so the frontier stays ascending front-to-back; the two ends
     /// therefore never yield the same leaf and meet cleanly when the frontier
     /// empties.
-    fn step(&mut self, back: bool) -> Option<(&'a Version, &'a Message)> {
+    fn step(&mut self, back: bool) -> Option<(&'a Version, &'a Message)> {
         'frontier: while let Some(Frame { node, passes }) = if back {
             self.frames.pop_back()
         } else {
@@ -159,8 +159,8 @@ impl<'a, T, P: Polarity> Walk<'a, T, P> {
 /// For the same walk filtered to a causal range, see [`Range`].
 ///
 /// The [`Message`] is the richest leaf payload (it carries the cached
-/// serialization alongside the `Arc`); callers that only want the value
-/// project it cheaply with [`Message::as_arc`].
+/// serialization alongside the shared payload handle); callers that only
+/// want the value project it with [`Message::arc`].
 ///
 /// [`next`](Iterator::next) yields leaves in ascending order of their
 /// version-derived paths; the iterator is also a [`DoubleEndedIterator`],
@@ -196,7 +196,7 @@ impl<'a, T> Iter<'a, T> {
 }
 
 impl<'a, T> Iterator for Iter<'a, T> {
-    type Item = (&'a Version, &'a Message);
+    type Item = (&'a Version, &'a Message);
 
     fn next(&mut self) -> Option {
         self.walk.step(false)
@@ -248,7 +248,7 @@ impl<'a, T, P: Polarity> Range<'a, T, P> {
 }
 
 impl<'a, T, P: Polarity> Iterator for Range<'a, T, P> {
-    type Item = (&'a Version, &'a Message);
+    type Item = (&'a Version, &'a Message);
 
     fn next(&mut self) -> Option {
         self.walk.step(false)
@@ -329,12 +329,16 @@ impl Leaf {
         self.0.ceiling()
     }
 
-    /// The message's value.
-    pub fn value(&self) -> &std::sync::Arc {
+    /// The message's value: an owned handle, one reference bump on the
+    /// shared allocation.
+    pub fn value(&self) -> std::sync::Arc
+    where
+        T: Send + Sync + 'static,
+    {
         self.0
             .as_leaf()
             .expect("a Leaf wraps a leaf node, by construction")
-            .as_arc()
+            .arc::()
     }
 
     /// Unwrap into a bare height-zero leaf node.
diff --git a/src/tree/typed/untyped/tests.rs b/src/tree/typed/untyped/tests.rs
index 0afdc8571..352ad4d6e 100644
--- a/src/tree/typed/untyped/tests.rs
+++ b/src/tree/typed/untyped/tests.rs
@@ -94,7 +94,7 @@ fn arb_tree(depth: usize, budget: usize) -> BoxedStrategy> {
 /// yields them. The version is the leaf's own version as recorded by
 /// `Node::leaf`, and is preserved across path compression because
 /// `into_children` never mutates `version` — only `prefix`.
-fn enumerate_leaves(node: Node<()>, path: Vec) -> Vec<(Vec, Version, Message<()>)> {
+fn enumerate_leaves(node: Node<()>, path: Vec) -> Vec<(Vec, Version, Message)> {
     match node.into_children() {
         Ok(children) => children
             .into_iter()
@@ -127,7 +127,7 @@ fn enumerate_leaves(node: Node<()>, path: Vec) -> Vec<(Vec, Version, Mes
 /// versions we started with.
 fn rebuild_with(node: Node<()>, f: &F) -> Node<()>
 where
-    F: Fn(&Message<()>) -> Message<()>,
+    F: Fn(&Message) -> Message,
 {
     let version = node.ceiling().clone();
     match node.into_children() {
@@ -593,7 +593,7 @@ fn check_virtual_levels(
 #[test]
 fn node_hash_preimage_is_in_path_order() {
     const LEAF_TAG: u8 = 0;
-    let leaf = Node::leaf(Version::new(), Message::new(()));
+    let leaf = Node::<()>::leaf(Version::new(), Message::new(()));
     let wrapped = leaf.beneath(0xAA).beneath(0xBB);
     assert_eq!(wrapped.hash(), super::Hash::of(&[LEAF_TAG, 2, 0xBB, 0xAA]));
 }
diff --git a/src/tree/wire.rs b/src/tree/wire.rs
index 4a420690f..5fedbe644 100644
--- a/src/tree/wire.rs
+++ b/src/tree/wire.rs
@@ -8,7 +8,7 @@
 //! Container shapes (counts, radix records, fixed-width prefixes and
 //! hashes) are protocol framing, written and validated by hand exactly
 //! like the streaming codec's signal and length headers. The two atoms a
-//! frame cannot delimit itself — a [`Version`] and a [`Message`] — ride
+//! frame cannot delimit itself — a [`Version`] and a [`Message`] — ride
 //! as single CBOR values, self-delimiting by CBOR's own length headers:
 //! the version as a byte string wrapping its canonical encoding (the
 //! `before` serde form), the message as a byte string wrapping its cached
@@ -21,7 +21,6 @@ use crate::Version;
 use crate::message::Message;
 use crate::tree::typed::Hash;
 
-use serde::de::DeserializeOwned;
 /// Encode `self` onto a byte stream.
 ///
 /// The method is `write_wire`, not `encode_to`: `before`'s types carry
@@ -207,19 +206,15 @@ impl Decode for Version {
     }
 }
 
-/// One CBOR value: a byte string wrapping the cached CBOR payload.
-impl Encode for Message {
+/// One CBOR value: a byte string wrapping the cached CBOR payload. The
+/// decode direction is typed ([`Message::from_reader`]): the payload's
+/// type is erased in storage, so decoding names it explicitly.
+impl Encode for Message {
     fn write_wire(&self, writer: &mut W) -> std::io::Result<()> {
         ciborium::ser::into_writer(self, writer).map_err(ser_error)
     }
 }
 
-impl Decode for Message {
-    fn read_wire(reader: &mut R) -> std::io::Result {
-        ciborium::de::from_reader(reader).map_err(de_error)
-    }
-}
-
 #[cfg(test)]
 mod tests {
     use super::*;
@@ -247,7 +242,7 @@ mod tests {
 
         let m = Message::new(());
         let enc = to_vec(&m).unwrap();
-        let back: Message<()> = from_slice(&enc).unwrap();
+        let back = Message::from_reader::<(), _>(&mut enc.as_slice()).unwrap();
         assert_eq!(back.as_slice(), m.as_slice());
 
         let leaf: Node<(), Z> = Node::leaf(version.clone(), Message::new(()));
diff --git a/tests/bookmark_causality.rs b/tests/bookmark_causality.rs
index 036155ebc..9f5608ca3 100644
--- a/tests/bookmark_causality.rs
+++ b/tests/bookmark_causality.rs
@@ -395,7 +395,7 @@ impl World {
         let snapshot = rumors.snapshot();
         let mut version = None;
         for (leaf_version, value) in snapshot.iter() {
-            if **value == id {
+            if *value == id {
                 version = Some(leaf_version.clone());
                 break;
             }
@@ -872,7 +872,7 @@ impl World {
             let snapshot = rumors.snapshot();
             for (leaf_version, value) in snapshot.iter() {
                 live_leaves += 1;
-                let seq = **value;
+                let seq = *value;
                 assert!(
                     self.emissions.contains_exact(network, seq, leaf_version),
                     "live message #{seq} at version {leaf_version:?} in network {network:?} \
diff --git a/tests/bookmark_transmit_window.rs b/tests/bookmark_transmit_window.rs
index e7b59f397..798c8dccf 100644
--- a/tests/bookmark_transmit_window.rs
+++ b/tests/bookmark_transmit_window.rs
@@ -193,7 +193,7 @@ fn leaf_version(rumors: &Rumors, payload: Msg) -> Option) -> BTreeMap, u64> {
     rumors
         .snapshot()
         .iter()
-        .map(|(v, m)| (v.as_bytes().to_vec(), **m))
+        .map(|(v, m)| (v.as_bytes().to_vec(), *m))
         .collect()
 }
 
diff --git a/tests/cbor_evolution.rs b/tests/cbor_evolution.rs
index 90a4daa28..82cbadf42 100644
--- a/tests/cbor_evolution.rs
+++ b/tests/cbor_evolution.rs
@@ -75,7 +75,7 @@ where
 
     let snapshot = receiver.snapshot();
     let (_, message) = snapshot.iter().next().expect("one live message");
-    (**message).clone()
+    (*message).clone()
 }
 
 /// Struct fields decode by name: a payload encoded with one field order
diff --git a/tests/changes.rs b/tests/changes.rs
index 603c46c7c..1c982b5c5 100644
--- a/tests/changes.rs
+++ b/tests/changes.rs
@@ -46,7 +46,7 @@ async fn one_tick_per_observed_commit() {
     let version = rumors
         .snapshot()
         .iter()
-        .find_map(|(v, m)| (**m == 1).then_some(v.clone()))
+        .find_map(|(v, m)| (*m == 1).then_some(v.clone()))
         .expect("message 1 is live");
     rumors.redact(&version);
     assert_eq!(changes.next().now_or_never(), Some(Some(())));
diff --git a/tests/common/action.rs b/tests/common/action.rs
index 63cabf7aa..88fd9f559 100644
--- a/tests/common/action.rs
+++ b/tests/common/action.rs
@@ -53,7 +53,7 @@ pub fn arb_string_actions() -> impl Strategy>> {
 /// # Panics
 ///
 /// Panics unless exactly one leaf qualifies.
-pub fn minted_version(snapshot: &Snapshot, pre: &Version) -> Version {
+pub fn minted_version(snapshot: &Snapshot, pre: &Version) -> Version {
     let mut fresh = snapshot.range(causally::since(pre)).map(|(v, _)| v);
     let version = fresh.next().expect("a send mints exactly one live leaf");
     assert!(
diff --git a/tests/common/oracle.rs b/tests/common/oracle.rs
index 507032e56..22e7c7ece 100644
--- a/tests/common/oracle.rs
+++ b/tests/common/oracle.rs
@@ -83,7 +83,7 @@ where
 {
     snapshot
         .iter()
-        .map(|(v, m)| (version_key(v), (**m).clone()))
+        .map(|(v, m)| (version_key(v), (*m).clone()))
         .collect()
 }
 
diff --git a/tests/common/peer.rs b/tests/common/peer.rs
index 47517c7a7..f265e51be 100644
--- a/tests/common/peer.rs
+++ b/tests/common/peer.rs
@@ -67,7 +67,7 @@ impl Peer {
         let mut new = 0;
         for (version, message) in snapshot.range(causally::since(&self.checkpoint)) {
             self.observations
-                .push((version.clone(), (**message).clone()));
+                .push((version.clone(), (*message).clone()));
             new += 1;
         }
         self.checkpoint |= snapshot.latest();
diff --git a/tests/common/shape.rs b/tests/common/shape.rs
index a2dffafb5..de4e84437 100644
--- a/tests/common/shape.rs
+++ b/tests/common/shape.rs
@@ -39,7 +39,7 @@ pub fn pool(rumors: &Rumors, from: u64, count: u64) -> Vec<(u64, Version)>
         .snapshot()
         .iter()
         .filter(|(_, m)| (from..from + count).contains(m))
-        .map(|(v, m)| (**m, v.clone()))
+        .map(|(v, m)| (*m, v.clone()))
         .collect();
     pool.sort_by_key(|(value, _)| *value);
     pool
diff --git a/tests/common/sim.rs b/tests/common/sim.rs
index 4ec6573fe..7a3c8c036 100644
--- a/tests/common/sim.rs
+++ b/tests/common/sim.rs
@@ -529,7 +529,7 @@ async fn run_activity(handle: Rumors, script: Vec) -> Vec<(Versio
                 let live: Vec<(Version, u64)> = handle
                     .snapshot()
                     .iter()
-                    .map(|(v, m)| (v.clone(), **m))
+                    .map(|(v, m)| (v.clone(), *m))
                     .collect();
                 if !live.is_empty() {
                     let (version, value) = live[index % live.len()].clone();
diff --git a/tests/gossip_snapshot.rs b/tests/gossip_snapshot.rs
index a0422dbb7..8ae731f0a 100644
--- a/tests/gossip_snapshot.rs
+++ b/tests/gossip_snapshot.rs
@@ -48,7 +48,7 @@ fn version_for(rumors: &Rumors, value: u64) -> Version {
     rumors
         .snapshot()
         .iter()
-        .find_map(|(v, m)| (**m == value).then_some(v.clone()))
+        .find_map(|(v, m)| (*m == value).then_some(v.clone()))
         .unwrap_or_else(|| panic!("no live message holds {value}"))
 }
 
@@ -349,19 +349,19 @@ fn early_supplies_honor_redactions() {
         "one pruned Supply run: the survivor, not the full subtree"
     );
     assert!(
-        !a.snapshot().iter().any(|(_, m)| **m == 1),
+        !a.snapshot().iter().any(|(_, m)| *m == 1),
         "the redaction is contagious: the initiator drops the message"
     );
     assert!(
-        !b.snapshot().iter().any(|(_, m)| **m == 1),
+        !b.snapshot().iter().any(|(_, m)| *m == 1),
         "the redacted message must not resurrect at the responder"
     );
     assert!(
-        a.snapshot().iter().any(|(_, m)| **m == sibling),
+        a.snapshot().iter().any(|(_, m)| *m == sibling),
         "the survivor converges to the initiator"
     );
     assert!(
-        b.snapshot().iter().any(|(_, m)| **m == sibling),
+        b.snapshot().iter().any(|(_, m)| *m == sibling),
         "the survivor converges to the responder"
     );
     insta::assert_snapshot!(capture);
diff --git a/tests/gossip_when.rs b/tests/gossip_when.rs
index 62689f566..f01f0b8a7 100644
--- a/tests/gossip_when.rs
+++ b/tests/gossip_when.rs
@@ -273,7 +273,7 @@ async fn changes_propagate_transitively_through_a_chain() {
         loop {
             c_changes.next().await.expect("set still open");
             let snapshot = c.snapshot();
-            if snapshot.iter().any(|(_, m)| **m == 42) {
+            if snapshot.iter().any(|(_, m)| *m == 42) {
                 return;
             }
         }
@@ -682,8 +682,8 @@ proptest! {
             // Atomicity: whatever happened, each side holds its own send,
             // nothing beyond the union, and never a torn intermediate.
             let (a_snapshot, b_snapshot) = (a.snapshot(), b.snapshot());
-            assert!(a_snapshot.iter().any(|(_, m)| **m == 1));
-            assert!(b_snapshot.iter().any(|(_, m)| **m == 2));
+            assert!(a_snapshot.iter().any(|(_, m)| *m == 1));
+            assert!(b_snapshot.iter().any(|(_, m)| *m == 2));
             assert!(a_snapshot.len() <= 2);
             assert!(b_snapshot.len() <= 2);
 
diff --git a/tests/hop_trace.rs b/tests/hop_trace.rs
index 6b498541d..4f25c3ef2 100644
--- a/tests/hop_trace.rs
+++ b/tests/hop_trace.rs
@@ -570,7 +570,7 @@ fn transfer_pair() -> (Rumors, Rumors) {
     let mut pool: Vec<(u64, u8)> = left
         .snapshot()
         .iter()
-        .map(|(v, m)| (**m, path_radix(v)))
+        .map(|(v, m)| (*m, path_radix(v)))
         .collect();
     pool.sort_unstable();
     let (first, second) = pool
@@ -597,7 +597,7 @@ fn transfer_pair() -> (Rumors, Rumors) {
         .iter()
         .filter(|(v, _)| path_radix(v) != radix)
         .take(3)
-        .map(|(_, m)| **m)
+        .map(|(_, m)| *m)
         .collect();
     assert_eq!(ballast.len(), 3, "the ballast pool cannot fill its quota");
     keep_only(&right, &ballast);
diff --git a/tests/listen.rs b/tests/listen.rs
index 83286bd62..e62af9eb2 100644
--- a/tests/listen.rs
+++ b/tests/listen.rs
@@ -66,7 +66,7 @@ fn live_map(rumors: &Rumors) -> BTreeMap, u64> {
     rumors
         .snapshot()
         .iter()
-        .map(|(v, m)| (v.as_bytes().to_vec(), **m))
+        .map(|(v, m)| (v.as_bytes().to_vec(), *m))
         .collect()
 }
 
@@ -491,7 +491,7 @@ fn folding_delivered_versions_can_lose_a_message() {
             rumors.send(later_value);
             let snapshot = rumors.snapshot();
             let first_yielded = snapshot.iter().next().expect("two live messages");
-            let later_first = **first_yielded.1 == later_value;
+            let later_first = *first_yielded.1 == later_value;
             drop(snapshot);
             later_first.then_some(rumors)
         })
diff --git a/tests/opening_supply.rs b/tests/opening_supply.rs
index 90b81d0c5..ac8b4261e 100644
--- a/tests/opening_supply.rs
+++ b/tests/opening_supply.rs
@@ -75,7 +75,7 @@ fn divergent_root_child_has_one_question_owner() {
     let radix = path_radix(
         &a.snapshot()
             .iter()
-            .find_map(|(v, m)| (**m == 1).then_some(v.clone()))
+            .find_map(|(v, m)| (*m == 1).then_some(v.clone()))
             .expect("message 1 is live"),
     );
     send_pool(&a, 2, RADIX_POOL);
diff --git a/tests/retire.rs b/tests/retire.rs
index 85a17698c..5d5775d73 100644
--- a/tests/retire.rs
+++ b/tests/retire.rs
@@ -172,7 +172,7 @@ fn divergent_retiree_reconciles_then_retires() {
         matches!(outcome, Retire::Retired),
         "the in-session gossip round brings the peer to dominance, got {outcome:?}"
     );
-    let mut live: Vec = b.snapshot().iter().map(|(_, m)| **m).collect();
+    let mut live: Vec = b.snapshot().iter().map(|(_, m)| *m).collect();
     live.sort_unstable();
     assert_eq!(
         live,
@@ -194,7 +194,7 @@ fn retiree_redaction_propagates_through_retire() {
     let version_of_1 = seed
         .snapshot()
         .iter()
-        .find_map(|(v, m)| (**m == 1).then_some(v.clone()))
+        .find_map(|(v, m)| (*m == 1).then_some(v.clone()))
         .expect("version recorded for 1");
 
     let a = bootstrap_fork(&seed);
@@ -207,7 +207,7 @@ fn retiree_redaction_propagates_through_retire() {
         matches!(outcome, Retire::Retired),
         "the reconciled peer absorbs the retiree, got {outcome:?}"
     );
-    let mut live: Vec = b.snapshot().iter().map(|(_, m)| **m).collect();
+    let mut live: Vec = b.snapshot().iter().map(|(_, m)| *m).collect();
     live.sort_unstable();
     assert_eq!(
         live,
@@ -288,7 +288,7 @@ fn retire_into_bootstrapper_hands_off_the_identity() {
     successor.send(99);
     wire_gossip(&successor, &seed);
     assert!(
-        seed.snapshot().iter().any(|(_, m)| **m == 99),
+        seed.snapshot().iter().any(|(_, m)| *m == 99),
         "the successor's origination survives gossip"
     );
 }
diff --git a/tests/session_stats.rs b/tests/session_stats.rs
index d7b7530cd..d9e0d721f 100644
--- a/tests/session_stats.rs
+++ b/tests/session_stats.rs
@@ -100,7 +100,7 @@ fn honored_redaction_counts_as_shed() {
         let version = a
             .snapshot()
             .iter()
-            .find(|(_, value)| ***value == 10)
+            .find(|(_, value)| **value == 10)
             .map(|(version, _)| version.clone())
             .expect("the sent message is live");
         a.redact(&version);
diff --git a/tests/single_peer.rs b/tests/single_peer.rs
index cee4af3a4..45220466d 100644
--- a/tests/single_peer.rs
+++ b/tests/single_peer.rs
@@ -138,7 +138,7 @@ proptest! {
             batch_send(&peer, values);
             let mut out = BTreeMap::new();
             for (_, v) in peer.snapshot().iter() {
-                *out.entry(**v).or_insert(0) += 1;
+                *out.entry(*v).or_insert(0) += 1;
             }
             out
         };
diff --git a/tests/stale_floor.rs b/tests/stale_floor.rs
index d896e8cc4..55e982bc6 100644
--- a/tests/stale_floor.rs
+++ b/tests/stale_floor.rs
@@ -49,8 +49,8 @@ fn message_minted_after_bootstrap_survives_gossip() {
         // forgotten" and evict the fresh message from both sides.
         wire_gossip_async(&f, &b).await;
 
-        let f_has = f.snapshot().iter().any(|(_, m)| **m == 100);
-        let b_has = b.snapshot().iter().any(|(_, m)| **m == 100);
+        let f_has = f.snapshot().iter().any(|(_, m)| *m == 100);
+        let b_has = b.snapshot().iter().any(|(_, m)| *m == 100);
         assert!(
             f_has && b_has,
             "message 100 must survive the sync: f_has={f_has} b_has={b_has}"

From b524e40613b516b6cf26cb6827a14041e3aae347 Mon Sep 17 00:00:00 2001
From: finch 
Date: Wed, 19 Aug 2026 23:27:36 -0400
Subject: [PATCH 12/18] tree: erase the payload type from storage; seal the
 walk towers (part II stage 2)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

The tree's layers drop their payload parameter: the untyped storage
node, the radix fan, the borrowing and owned walks, the typed height
veneer, the traversal trio, and the V1 zipper are all payload-free —
the stored Message was the parameter's only remaining occurrence.
Tree stays as the phantom-typed facade whose faces downcast (stage
1's boundary), and tree::Root goes bare. The V1 protocol messages keep
T as decode context only (a phantom field); their node decode reworks
from a wire::Decode impl tower into the DecodeNode height trait
(read_node::), the typed analog of the streaming codec's
parse_record::.

Sealing, and the measured argument for it: dropping T alone moved
little. cargo llvm-lines counts the named target's crate, so a
binary's total IS its marginal cost, and it fell only 1,039,827 →
971,148 until the batch-apply entry went monomorphic (a Vec of
actions in, a &mut dyn FnMut observer): the generic entry had been
re-instantiating the entire per-height apply tower — radix grouping
included, ~155k lines — in every consumer crate. With act sealed the
way join's shape already was (both towers verify as present in the
lib's codegen and absent from the binary's):

  pairwise: 1,039,827 lines / 41,186 copies -> 719,899 / 30,285
            (-30.8% per consumer binary, from stage 1)
  lib:      32,262 / 1,691 -> ~102k / ~4.5k (paid once, in the rlib)

The residue is the streaming session, still generic over (B, T):
stage 3's scope, and where the erasure's cost/benefit verdict lands.
---
 design/item-erasure.md                        |  16 ++
 src/conformance/backend/tests.rs              |  34 ++---
 src/peer/gossip.rs                            |  21 +--
 src/peer/gossip/tests.rs                      |  23 ++-
 src/rumors/causal.rs                          |   6 +-
 src/rumors/unordered.rs                       |  10 +-
 src/tests.rs                                  |   8 +-
 src/tree.rs                                   |  52 ++++---
 src/tree/arb.rs                               |  66 ++++----
 src/tree/mirror/alternating/backend/local.rs  |  41 +++--
 .../alternating/backend/local/partition.rs    |  66 +++-----
 src/tree/mirror/alternating/backend/remote.rs |  10 +-
 src/tree/mirror/alternating/message.rs        |  67 +++++---
 src/tree/mirror/alternating/message/tests.rs  |  38 +++--
 src/tree/mirror/alternating/tests.rs          |  39 ++---
 src/tree/mirror/alternating/wire_snapshot.rs  |  45 +++---
 src/tree/mirror/streaming/backend.rs          |   3 +-
 src/tree/mirror/streaming/backend/local.rs    |  23 ++-
 .../mirror/streaming/backend/local/tests.rs   |  14 +-
 src/tree/mirror/streaming/convert/tests.rs    |  28 ++--
 .../mirror/streaming/materialized/tests.rs    |   2 +-
 .../streaming/materialized/unknown/tests.rs   |   9 +-
 .../streaming/materialized/work/tests.rs      |  20 ++-
 .../materialized/work/tests/violations.rs     |   6 +-
 .../remote/adapter/tests/backend_errors.rs    |   8 +-
 .../remote/adapter/tests/malformed.rs         |  24 +--
 .../streaming/remote/adapter/tests/opening.rs |   8 +-
 .../streaming/remote/adapter/tests/parking.rs |   7 +-
 .../remote/adapter/tests/properties.rs        |  21 +--
 .../streaming/remote/adapter/tests/runs.rs    |  16 +-
 .../mirror/streaming/remote/proxy/tests.rs    |  75 ++++-----
 .../remote/proxy/tests/containment.rs         |  13 +-
 .../remote/proxy/tests/declarations.rs        |  28 ++--
 .../streaming/remote/proxy/tests/failures.rs  |   6 +-
 .../streaming/remote/proxy/tests/greeting.rs  |  48 +++---
 .../streaming/remote/proxy/tests/harness.rs   |  43 +++---
 .../streaming/remote/proxy/tests/malformed.rs |   6 +-
 .../streaming/remote/proxy/tests/transport.rs |   4 +-
 src/tree/mirror/streaming/testing/faulting.rs |   8 +-
 src/tree/mirror/streaming/tests.rs            |  38 +++--
 src/tree/mirror/streaming/tests/announced.rs  |   2 +-
 src/tree/mirror/streaming/tests/capacity.rs   |   8 +-
 src/tree/mirror/streaming/tests/faults.rs     |   4 +-
 src/tree/mirror/streaming/tests/fixtures.rs   |  40 +++--
 src/tree/mirror/streaming/tests/local_eq.rs   |  18 +--
 src/tree/mirror/streaming/tests/skeleton.rs   |   4 +-
 src/tree/mirror/streaming/tests/stats.rs      |  18 +--
 src/tree/mirror/streaming/tests/wedge.rs      |   4 +-
 src/tree/mirror/streaming/window.rs           |   8 +-
 src/tree/tests.rs                             |  38 ++---
 src/tree/traverse/act.rs                      |  58 +++----
 src/tree/traverse/join.rs                     |  43 ++----
 src/tree/traverse/join/tests.rs               |  10 +-
 src/tree/traverse/unknown.rs                  |  14 +-
 src/tree/traverse/unknown/tests.rs            |  18 +--
 src/tree/typed.rs                             |   2 +-
 src/tree/typed/levels.rs                      |  67 +++-----
 src/tree/typed/levels/level.rs                |  32 ++--
 src/tree/typed/levels/tests.rs                |  13 +-
 src/tree/typed/node.rs                        | 143 ++++++++++--------
 src/tree/typed/untyped.rs                     |  51 +++----
 src/tree/typed/untyped/fan.rs                 |  69 +++++----
 src/tree/typed/untyped/fan/tests.rs           |  22 +--
 src/tree/typed/untyped/iter.rs                |  79 +++++-----
 src/tree/typed/untyped/tests.rs               |  44 +++---
 src/tree/wire.rs                              |  23 ++-
 66 files changed, 879 insertions(+), 953 deletions(-)

diff --git a/design/item-erasure.md b/design/item-erasure.md
index 90b4a2370..646072b77 100644
--- a/design/item-erasure.md
+++ b/design/item-erasure.md
@@ -179,6 +179,22 @@ security-relevant: payload validation stays exactly where it is
    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 witness;
    sessions receive the witness from the peer. Measure.
diff --git a/src/conformance/backend/tests.rs b/src/conformance/backend/tests.rs
index 3c4688bf8..3fbe9c975 100644
--- a/src/conformance/backend/tests.rs
+++ b/src/conformance/backend/tests.rs
@@ -206,7 +206,7 @@ impl MaterializedNode {
     }
 }
 
-impl Node for MaterializedNode>
+impl Node for MaterializedNode>
 where
     T: Send + Sync + 'static,
     H: Height,
@@ -242,7 +242,7 @@ where
     }
 }
 
-impl Leaf for MaterializedNode>
+impl Leaf for MaterializedNode>
 where
     T: Send + Sync + 'static,
 {
@@ -262,7 +262,7 @@ 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()
 }
 
@@ -286,8 +286,8 @@ impl Backend for Materializing
 where
     T: Send + Sync + 'static,
 {
-    type Node = MaterializedNode>;
-    type Erased = MaterializedNode>;
+    type Node = MaterializedNode>;
+    type Erased = MaterializedNode;
     type Error = Infallible;
 
     // Erasure re-tags the store's handle; the resident row rides along
@@ -310,7 +310,7 @@ where
     }
 
     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;
@@ -337,7 +337,7 @@ 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)
@@ -354,7 +354,7 @@ where
         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
@@ -375,7 +375,7 @@ where
         // 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)) })),
         )
@@ -539,7 +539,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),
         ),
@@ -566,14 +566,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(),
@@ -583,7 +581,7 @@ fn ledger_settles_over_clone_and_drop() {
     drop(leaf);
     drop(clone);
 
-    let node: typed::Node =
+    let node: typed::Node =
         typed::Node::leaf(Version::new(), Message::new(7));
     let charged = Charged::::new(Local);
     let _ = &charged;
diff --git a/src/peer/gossip.rs b/src/peer/gossip.rs
index 6ec7c3c98..e20f57af2 100644
--- a/src/peer/gossip.rs
+++ b/src/peer/gossip.rs
@@ -269,7 +269,7 @@ impl Peer {
             #[allow(clippy::type_complexity)]
             let reconcile: BoxFuture<
                 '_,
-                Result, DynRead<'a>, DynWrite<'a>)>, Error>,
+                Result, DynWrite<'a>)>, Error>,
             > = match config.protocol {
                 Protocol::V2 => Box::pin(async move {
                     let local_root: streaming::Root = tree::Root::default().into();
@@ -312,7 +312,7 @@ impl Peer {
                 #[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(
+                    let proxy = alternating_remote::Exchange::::start(
                         FrameRead::new(read),
                         FrameWrite::new(write),
                     );
@@ -351,7 +351,7 @@ 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))),
             };
@@ -741,13 +741,14 @@ impl Peer {
         #[allow(clippy::type_complexity)]
         let reconcile: BoxFuture<
             '_,
-            Result<(tree::Root, DynRead<'a>, DynWrite<'a>), Error>,
+            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 local =
+                    materialized::Handshaking::<_, T, _>::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)
@@ -771,7 +772,7 @@ impl Peer {
             #[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(
+                let proxy = alternating_remote::Exchange::::start(
                     FrameRead::new(read),
                     FrameWrite::new(write),
                 );
@@ -861,7 +862,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| {
diff --git a/src/peer/gossip/tests.rs b/src/peer/gossip/tests.rs
index 9d9b6dc08..95814157c 100644
--- a/src/peer/gossip/tests.rs
+++ b/src/peer/gossip/tests.rs
@@ -143,7 +143,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 +180,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();
@@ -205,7 +205,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 +215,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 +230,10 @@ 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),
+    );
     let handshaken = alternating::handshake(local, proxy)
         .await
         .map_err(alternating_error)?;
@@ -238,7 +241,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 +285,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 +348,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/causal.rs b/src/rumors/causal.rs
index a7bfbbf49..bc2942947 100644
--- a/src/rumors/causal.rs
+++ b/src/rumors/causal.rs
@@ -60,7 +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>,
+    staged: BTreeMap<(Rank, Vec), Leaf>,
 }
 
 impl CausalMessages {
@@ -83,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
@@ -160,7 +160,7 @@ impl Stream for CausalMessages {
             // 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())));
+                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 ef5267ff3..9cab13eb3 100644
--- a/src/rumors/unordered.rs
+++ b/src/rumors/unordered.rs
@@ -39,7 +39,7 @@ pub struct UnorderedMessages {
     /// wakeup), so the wait is materialized.
     channel: Option>,
     checkpoint: Version,
-    pass: Option>,
+    pass: Option,
 }
 
 /// The outcome of [`UnorderedMessages::try_next`] or [`CausalMessages::try_next`].
@@ -75,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,
 }
 
@@ -93,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
@@ -209,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())));
+                        return Poll::Ready(Some((leaf.version().clone(), leaf.value::())));
                     }
 
                     // The pass drained: absorb its ceiling, then enter the
diff --git a/src/tests.rs b/src/tests.rs
index 1ba9f5ac2..1b622354a 100644
--- a/src/tests.rs
+++ b/src/tests.rs
@@ -101,9 +101,7 @@ fn overlapping_retiree_party_is_rejected() {
         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))),
     };
@@ -268,7 +266,7 @@ 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 fan = pollster::block_on(materialized::greeting_fan(&Local, root.root))
+    let fan = pollster::block_on(materialized::greeting_fan::<_, u64>(&Local, root.root))
         .unwrap_or_else(|never| match never {});
     // The listing frame is raw radix-hash records: one byte plus a Merkle
     // hash per child, the frame length carrying the count.
@@ -564,7 +562,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 70490b0b2..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;
@@ -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,
         }
     }
 }
@@ -178,7 +177,7 @@ pub enum Action {
 ///
 /// 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: Send + Sync + 'static> Iterator for Iter<'a, T> {
     type Item = (&'a Version, Arc);
@@ -209,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,
         }
     }
 
@@ -325,6 +328,7 @@ impl Tree {
                 .as_ref()
                 .map(typed::node::Root::iter)
                 .unwrap_or_else(typed::Iter::empty),
+            PhantomData,
         )
     }
 
@@ -339,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()) } @@ -521,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 1ff979a1a..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) { +) -> (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/backend/local.rs b/src/tree/mirror/alternating/backend/local.rs index 0095ce493..9edfd6afa 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,19 @@ 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> +impl protocol::Connect for Exchange where T: Send + Sync, { - type Next = Exchange>; + type Next = Exchange; async fn connect( self, @@ -229,11 +225,11 @@ where } } -impl protocol::CompleteConnect for Exchange> +impl protocol::CompleteConnect for Exchange where T: Send + Sync, { - type Next = Exchange>; + type Next = Exchange; async fn complete_connect( self, @@ -266,11 +262,11 @@ where } } -impl protocol::Accept for Exchange> +impl protocol::Accept for Exchange where T: Send + Sync, { - type Next = Exchange>; + type Next = Exchange; async fn accept( self, @@ -311,11 +307,11 @@ where } } -impl protocol::Initiator for Exchange> +impl protocol::Initiator for Exchange where T: Send + Sync, { - type Next = Exchange>; + type Next = Exchange; async fn initiator( self, @@ -333,11 +329,11 @@ where } } -impl protocol::Responder for Exchange> +impl protocol::Responder for Exchange where T: Send + Sync, { - type Next = Exchange>>; + type Next = Exchange>; async fn responder( mut self, @@ -389,7 +385,7 @@ where impl protocol::OpenInitiator for Exchange where T: Send + Sync, - L: Levels + Send, + L: Levels + Send, { type Next = Exchange>>; @@ -407,7 +403,7 @@ where impl protocol::Exchange for Exchange where T: Send + Sync, - L: Levels>> + Send, + L: Levels>> + Send, S>: Height, S: Height, H: Height + Unknown, @@ -430,7 +426,7 @@ where impl protocol::CloseResponder for Exchange where T: Send + Sync, - L: Levels> + Send, + L: Levels> + Send, { type Next = Exchange>; @@ -445,7 +441,7 @@ where impl protocol::CompleteInitiator for Exchange where T: Send + Sync, - L: Levels + Send, + L: Levels + Send, { async fn complete_initiator( mut self, @@ -456,6 +452,7 @@ where Ok(protocol::Step::Done { msg: message::Complete { providing: providing.into_iter().collect(), + payload: std::marker::PhantomData, }, output: tree::Root { ceiling: self.versions.our_version | self.versions.their_version, @@ -468,7 +465,7 @@ where impl protocol::CompleteResponder for Exchange where T: Send + Sync, - L: Levels + Send, + L: Levels + Send, { async fn complete_responder( mut self, diff --git a/src/tree/mirror/alternating/backend/local/partition.rs b/src/tree/mirror/alternating/backend/local/partition.rs index 4b85680ba..cb423a113 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>, { @@ -501,15 +487,11 @@ where /// [`Step::Done`](protocol::Step::Done) when nothing was requested: /// the counterparty's [`message::Complete`] would carry nothing. #[allow(clippy::type_complexity)] - pub(super) fn close( + pub(super) fn close( mut self, - request: message::Exchange, + request: message::Exchange, ) -> Result< - protocol::Step< - message::Closing, - Exchange>, - tree::Root, - >, + protocol::Step, Exchange>, tree::Root>, Violation, > where @@ -519,6 +501,7 @@ where providing, requested, uncertain, + payload: _, } = request; self.absorb_providing(providing)?; @@ -538,6 +521,7 @@ where let response = message::Closing { providing: providing.into_iter().collect(), requested: partition.requested, + payload: std::marker::PhantomData, }; // The counterparty may only answer the leaves we just requested. @@ -585,20 +569,16 @@ where /// [`close_responder`](protocol::CloseResponder::close_responder); they /// differ only in how they assemble the outgoing message. #[allow(clippy::type_complexity)] - pub(super) fn reply( + pub(super) fn reply( 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, @@ -608,6 +588,7 @@ where providing, requested, uncertain, + payload: _, } = request.into(); // Phase 1: absorb the counterparty's `providing` into our frontier. @@ -652,6 +633,7 @@ where providing: providing.into_iter().collect(), requested: partition.requested, uncertain, + payload: std::marker::PhantomData, }; // Record which parents the counterparty may `provide` against in its diff --git a/src/tree/mirror/alternating/backend/remote.rs b/src/tree/mirror/alternating/backend/remote.rs index 4c604b3a6..92fddcfc7 100644 --- a/src/tree/mirror/alternating/backend/remote.rs +++ b/src/tree/mirror/alternating/backend/remote.rs @@ -55,8 +55,8 @@ 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::{ @@ -213,7 +213,7 @@ where T: DeserializeOwned + Send + Sync + 'static, R: AsyncRead + Unpin + Send, W: AsyncWrite + Unpin + Send, - Node: wire::Decode, + UnderRoot: DecodeNode, { type Next = Exchange; @@ -235,7 +235,7 @@ where T: DeserializeOwned + Send + Sync + 'static, R: AsyncRead + Unpin + Send, W: AsyncWrite + Unpin + Send, - Node: wire::Decode, + UnderRoot: DecodeNode, { type Next = Exchange; @@ -263,7 +263,7 @@ where T: DeserializeOwned + Send + Sync + 'static, R: AsyncRead + Unpin + Send, W: AsyncWrite + Unpin + Send, - Node: wire::Decode, + UnderRoot: DecodeNode, { type Next = Exchange; @@ -300,7 +300,7 @@ where 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. diff --git a/src/tree/mirror/alternating/message.rs b/src/tree/mirror/alternating/message.rs index 9ac65b0ae..c30a44763 100644 --- a/src/tree/mirror/alternating/message.rs +++ b/src/tree/mirror/alternating/message.rs @@ -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 @@ -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 @@ -78,16 +78,39 @@ use crate::Version; use crate::tree::typed::{ Hash, Node, Prefix, height::{Height, Root, S, UnderRoot, Z}, + node::DecodeNode, }; use serde::de::DeserializeOwned; +use std::marker::PhantomData; #[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 as `T` (the protocol's typed +/// ingress; see [`DecodeNode`]). +fn read_providing(reader: &mut R) -> std::io::Result> +where + T: DeserializeOwned + Send + Sync + 'static, + 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)?; + 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`]. @@ -193,7 +216,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. @@ -205,6 +228,10 @@ where /// [`super::local`] module docs) on the receiving side. Strictly ascending /// by prefix. pub uncertain: Vec<(Prefix, Hash)>, + /// The payload type this message's leaves decode as: the nodes are + /// erased in memory, so the type rides the message as decode context + /// only (`fn() -> T` keeps auto-traits off the payload type). + pub payload: PhantomData T>, } impl Encode for Exchange @@ -219,20 +246,14 @@ 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 where T: DeserializeOwned + Send + Sync + 'static, - S: Height, + S: DecodeNode, H: Height, - Node>: Decode, { fn read_wire(reader: &mut R) -> std::io::Result { - let providing: Providing> = Decode::read_wire(reader)?; + let providing: Providing> = read_providing::, R>(reader)?; verify_pairs_canonical(&providing, "Exchange.providing")?; let requested: Vec>> = Decode::read_wire(reader)?; verify_keys_canonical(&requested, "Exchange.requested")?; @@ -242,6 +263,7 @@ where providing, requested, uncertain, + payload: PhantomData, }) } } @@ -265,6 +287,7 @@ where providing: Default::default(), requested: Default::default(), uncertain: Default::default(), + payload: PhantomData, } } } @@ -291,7 +314,7 @@ 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. /// @@ -299,6 +322,8 @@ pub struct Closing { /// leaf at or before the responder's version was deleted there, and /// drops on both sides instead of shipping. pub requested: Vec>, + /// The leaves' payload type, as decode context (see [`Exchange::payload`]). + pub payload: PhantomData T>, } impl Encode for Closing { @@ -313,13 +338,14 @@ where T: DeserializeOwned + Send + Sync + 'static, { fn read_wire(reader: &mut R) -> std::io::Result { - let providing: Providing = Decode::read_wire(reader)?; + let providing: Providing = read_providing::(reader)?; verify_pairs_canonical(&providing, "Closing.providing")?; let requested: Vec> = Decode::read_wire(reader)?; verify_keys_canonical(&requested, "Closing.requested")?; Ok(Self { providing, requested, + payload: PhantomData, }) } } @@ -329,6 +355,7 @@ impl Default for Closing { Self { providing: Default::default(), requested: Default::default(), + payload: PhantomData, } } } @@ -345,7 +372,9 @@ impl Default for Closing { /// (vacuous at leaf height, same reasoning as [`Closing`]). #[derive(Clone)] pub struct Complete { - pub providing: Providing, + pub providing: Providing, + /// The leaves' payload type, as decode context (see [`Exchange::payload`]). + pub payload: PhantomData T>, } impl Encode for Complete { @@ -359,9 +388,12 @@ where T: DeserializeOwned + Send + Sync + 'static, { fn read_wire(reader: &mut R) -> std::io::Result { - let providing: Providing = Decode::read_wire(reader)?; + let providing: Providing = read_providing::(reader)?; verify_pairs_canonical(&providing, "Complete.providing")?; - Ok(Self { providing }) + Ok(Self { + providing, + payload: PhantomData, + }) } } @@ -369,6 +401,7 @@ impl Default for Complete { fn default() -> Self { Self { providing: Default::default(), + payload: PhantomData, } } } diff --git a/src/tree/mirror/alternating/message/tests.rs b/src/tree/mirror/alternating/message/tests.rs index fe214dba3..492904f0b 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::>() @@ -124,6 +122,7 @@ proptest! { providing: providing.clone(), requested: requested.clone(), uncertain: uncertain.clone(), + payload: std::marker::PhantomData, }; let bytes = wire::to_vec(&m).unwrap(); let decoded = @@ -145,6 +144,7 @@ proptest! { let m: message::Closing<()> = message::Closing { providing: providing.clone(), requested: requested.clone(), + payload: std::marker::PhantomData, }; let bytes = wire::to_vec(&m).unwrap(); let decoded = wire::from_slice::>(&bytes).unwrap(); @@ -159,7 +159,7 @@ 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(), payload: std::marker::PhantomData }; let bytes = wire::to_vec(&m).unwrap(); let decoded = wire::from_slice::>(&bytes).unwrap(); prop_assert_eq!(decoded.providing, providing); @@ -180,12 +180,26 @@ 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, payload: std::marker::PhantomData }; let bytes = wire::to_vec(&m).unwrap(); prop_assert!(wire::from_slice::>(&bytes).is_err()); } } +/// 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)?; + 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); @@ -202,6 +216,7 @@ fn providing_rejects_duplicate_prefix() { let leaf = Node::leaf(one_version(), Message::new(())); let m = message::Complete::<()> { providing: vec![(prefix, leaf.clone()), (prefix, leaf)], + payload: std::marker::PhantomData, }; let bytes = wire::to_vec(&m).unwrap(); assert!(wire::from_slice::>(&bytes).is_err()); @@ -216,6 +231,7 @@ fn requested_rejects_descending_order() { prefix_from_bytes::(&[2u8; 32]), prefix_from_bytes::(&[1u8; 32]), ], + payload: std::marker::PhantomData, }; let bytes = wire::to_vec(&m).unwrap(); assert!(wire::from_slice::>(&bytes).is_err()); @@ -248,7 +264,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 +276,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 +287,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 +295,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/tests.rs b/src/tree/mirror/alternating/tests.rs index d86e84883..eb27d3a35 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,20 +76,13 @@ 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 + 'static, -{ +fn mirror_via(a: crate::tree::Root, b: crate::tree::Root, scenario: Scenario) -> crate::tree::Root { block_on(async move { match scenario { Scenario::LocalLocal => { let local_a = local::Exchange::start(a); let local_b = local::Exchange::start(b); - match mirror(local_a, local_b).await { + match mirror::<_, _, ()>(local_a, local_b).await { Err(e) => panic!("honest endpoints speak no violations: {e}"), Ok((ours, theirs)) => { assert_eq!(ours, theirs, "local-local endpoints should converge"); @@ -110,11 +101,17 @@ 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), + ); 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), + ); let server = mirror(local_b, remote_a); // Both sides poll on the same current-thread task; no @@ -257,11 +254,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); @@ -297,7 +294,7 @@ fn uncontained_supply_is_rejected() { // violation. { let (receiver, poisoned, _, _) = uncontained_supply_pair(); - let result = block_on(mirror( + let result = block_on(mirror::<_, _, ()>( local::Exchange::start(receiver), local::Exchange::start(poisoned), )); @@ -317,11 +314,17 @@ 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), + ); 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), + ); 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..30d209144 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)); } @@ -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); @@ -296,6 +296,7 @@ fn message_exchange_populated() { providing, requested, uncertain, + payload: std::marker::PhantomData, }; insta::assert_snapshot!(snap(&m)); } @@ -315,6 +316,7 @@ fn message_closing_populated() { let m: message::Closing<()> = message::Closing { providing, requested, + payload: std::marker::PhantomData, }; insta::assert_snapshot!(snap(&m)); } @@ -330,6 +332,9 @@ 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, + payload: std::marker::PhantomData, + }; insta::assert_snapshot!(snap(&m)); } diff --git a/src/tree/mirror/streaming/backend.rs b/src/tree/mirror/streaming/backend.rs index 6e8e30926..0f0505f78 100644 --- a/src/tree/mirror/streaming/backend.rs +++ b/src/tree/mirror/streaming/backend.rs @@ -47,7 +47,8 @@ pub trait Backend: Clone + Send + Sync + 'static where Self::Node: Leaf, { - /// The type of nodes carrying messages of type `T`, indexed by height `H`. + /// 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 diff --git a/src/tree/mirror/streaming/backend/local.rs b/src/tree/mirror/streaming/backend/local.rs index 8bac7f470..0ced7ea05 100644 --- a/src/tree/mirror/streaming/backend/local.rs +++ b/src/tree/mirror/streaming/backend/local.rs @@ -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; @@ -58,7 +58,7 @@ impl Node for typed::Node { // 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 { +impl ErasedNode for typed::untyped::Node { fn span(&self) -> Span<'_> { self.span() } @@ -72,7 +72,7 @@ impl ErasedNode for typed::untyped::Node { } } -impl Leaf for typed::Node { +impl Leaf for typed::Node { fn message(&self) -> &Message { self.message() } @@ -101,20 +101,19 @@ 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; + 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 Erased = typed::untyped::Node; type Error = Infallible; fn erase(node: Self::Node) -> Self::Erased { @@ -206,7 +205,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)); @@ -234,14 +233,14 @@ 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 { +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..54608d7cf 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 { @@ -49,21 +49,21 @@ fn boxed(run: LeafRun) -> BoxNodeStream<'static, Local, (), Z> { } /// 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/tests.rs b/src/tree/mirror/streaming/convert/tests.rs index 5a6b9b7f3..e1d401fbe 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,25 +42,21 @@ 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::( Local, @@ -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/materialized/tests.rs b/src/tree/mirror/streaming/materialized/tests.rs index 18c45f43f..6779f3a76 100644 --- a/src/tree/mirror/streaming/materialized/tests.rs +++ b/src/tree/mirror/streaming/materialized/tests.rs @@ -52,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. diff --git a/src/tree/mirror/streaming/materialized/unknown/tests.rs b/src/tree/mirror/streaming/materialized/unknown/tests.rs index 8d5857cf3..cbef059d2 100644 --- a/src/tree/mirror/streaming/materialized/unknown/tests.rs +++ b/src/tree/mirror/streaming/materialized/unknown/tests.rs @@ -26,7 +26,7 @@ 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) { +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(); @@ -44,15 +44,12 @@ 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::( &Local, diff --git a/src/tree/mirror/streaming/materialized/work/tests.rs b/src/tree/mirror/streaming/materialized/work/tests.rs index cc255ff81..2b426af86 100644 --- a/src/tree/mirror/streaming/materialized/work/tests.rs +++ b/src/tree/mirror/streaming/materialized/work/tests.rs @@ -38,12 +38,12 @@ use crate::{ type Erased = >::Erased; /// Erase one typed leaf the way the walk's payloads carry it. -fn erased(node: typed::Node<(), Z>) -> Erased { +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(())) } @@ -65,9 +65,10 @@ 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>; @@ -224,9 +225,12 @@ fn chains_two_instances() { 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() diff --git a/src/tree/mirror/streaming/materialized/work/tests/violations.rs b/src/tree/mirror/streaming/materialized/work/tests/violations.rs index b52e40df5..ff7d5d2ac 100644 --- a/src/tree/mirror/streaming/materialized/work/tests/violations.rs +++ b/src/tree/mirror/streaming/materialized/work/tests/violations.rs @@ -73,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) } } @@ -86,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) } } 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 0eb0dff9e..3cae2c57d 100644 --- a/src/tree/mirror/streaming/remote/adapter/tests/backend_errors.rs +++ b/src/tree/mirror/streaming/remote/adapter/tests/backend_errors.rs @@ -26,11 +26,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 +40,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]) } @@ -81,7 +81,7 @@ where Reaction::Supply(supply_radix, supply), ] }; - let mut encoded = encode_reply( + let mut encoded = encode_reply::<_, u64>( backend.clone(), RunBudget::default(), Scope::new(parent.erase(), &listing), diff --git a/src/tree/mirror/streaming/remote/adapter/tests/malformed.rs b/src/tree/mirror/streaming/remote/adapter/tests/malformed.rs index edd93886a..d8f5841e0 100644 --- a/src/tree/mirror/streaming/remote/adapter/tests/malformed.rs +++ b/src/tree/mirror/streaming/remote/adapter/tests/malformed.rs @@ -115,7 +115,7 @@ fn an_unpositioned_match_is_rejected_in_both_directions() { replies: vec![Reaction::Match, Reaction::Match], }; let encode_error = runtime().block_on(async { - encode_reply( + encode_reply::<_, u64>( Local, RunBudget::default(), Scope::new(parent.erase(), &[(1, hash(1))]), @@ -165,7 +165,7 @@ fn an_unpositioned_query_is_rejected_in_both_directions() { replies: vec![Reaction::Query(listing)], }; let encode_error = runtime().block_on(async { - encode_reply( + encode_reply::<_, u64>( Local, RunBudget::default(), Scope::new(parent.erase(), &[]), @@ -209,7 +209,7 @@ 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::<>::Erased> { @@ -354,17 +354,17 @@ 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, - >::assume::(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) + encode_reply::<_, u64>(Local, RunBudget::default(), scope, decoded.reply) .map_ok(|encoded| encoded.into_parts().0) .try_collect::>() .await diff --git a/src/tree/mirror/streaming/remote/adapter/tests/opening.rs b/src/tree/mirror/streaming/remote/adapter/tests/opening.rs index 5f9631b25..f7004d7bf 100644 --- a/src/tree/mirror/streaming/remote/adapter/tests/opening.rs +++ b/src/tree/mirror/streaming/remote/adapter/tests/opening.rs @@ -26,7 +26,7 @@ use crate::tree::{ }; /// One erased opening node over the unit payload. -fn erased(node: typed::Node<(), UnderRoot>) -> >::Erased { +fn erased(node: typed::Node) -> >::Erased { >::erase(node) } @@ -36,11 +36,11 @@ use super::{ }; 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(())) } } @@ -49,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) } } diff --git a/src/tree/mirror/streaming/remote/adapter/tests/parking.rs b/src/tree/mirror/streaming/remote/adapter/tests/parking.rs index 798a6b5ee..93207530f 100644 --- a/src/tree/mirror/streaming/remote/adapter/tests/parking.rs +++ b/src/tree/mirror/streaming/remote/adapter/tests/parking.rs @@ -67,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 @@ -83,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())) diff --git a/src/tree/mirror/streaming/remote/adapter/tests/properties.rs b/src/tree/mirror/streaming/remote/adapter/tests/properties.rs index 512034fad..3ad4eaba1 100644 --- a/src/tree/mirror/streaming/remote/adapter/tests/properties.rs +++ b/src/tree/mirror/streaming/remote/adapter/tests/properties.rs @@ -58,7 +58,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, @@ -95,7 +95,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()) } @@ -353,7 +353,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]) } @@ -667,7 +667,7 @@ fn mixed_reply( query_listing: &[(u8, Hash)], supply_at: usize, supply_radix: u8, - supply: typed::Node, + supply: typed::Node, ) -> Reply { let mut supply = Some(>::erase(supply)); let mut replies = Vec::with_capacity(case.radixes.len() + 1); @@ -820,11 +820,14 @@ where { let prefix = Prefix::::containing(&expected_leaf.path()); let leaves = runtime.block_on(async { - Local - .leaves(prefix, >::assume::(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") }); prop_assert_eq!(leaves.len(), 1, "height {}", H::HEIGHT); let (actual_prefix, actual_leaf) = &leaves[0]; diff --git a/src/tree/mirror/streaming/remote/adapter/tests/runs.rs b/src/tree/mirror/streaming/remote/adapter/tests/runs.rs index 9c5bfb60d..07885eaed 100644 --- a/src/tree/mirror/streaming/remote/adapter/tests/runs.rs +++ b/src/tree/mirror/streaming/remote/adapter/tests/runs.rs @@ -280,14 +280,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, - >::assume::(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/proxy/tests.rs b/src/tree/mirror/streaming/remote/proxy/tests.rs index a845eb1b8..ea56d609c 100644 --- a/src/tree/mirror/streaming/remote/proxy/tests.rs +++ b/src/tree/mirror/streaming/remote/proxy/tests.rs @@ -51,9 +51,9 @@ 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); @@ -68,15 +68,15 @@ 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); @@ -98,16 +98,16 @@ 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); @@ -122,12 +122,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(); @@ -162,9 +162,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 +172,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 +181,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 +193,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, @@ -274,7 +274,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(()))]); @@ -295,7 +295,7 @@ fn symmetric_accept_handshakes_are_live() { 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); } @@ -311,7 +311,7 @@ fn symmetric_accepts_with_distinct_payloads_are_live() { 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 +324,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 +381,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 +485,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 +527,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 +554,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..c1788708d 100644 --- a/src/tree/mirror/streaming/remote/proxy/tests/containment.rs +++ b/src/tree/mirror/streaming/remote/proxy/tests/containment.rs @@ -25,14 +25,11 @@ 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); 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..19576413b 100644 --- a/src/tree/mirror/streaming/remote/proxy/tests/harness.rs +++ b/src/tree/mirror/streaming/remote/proxy/tests/harness.rs @@ -53,9 +53,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 +344,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 +369,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 +415,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 +432,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