spine/seal.rs
1//! `Seal` — the general structural commitment lattice.
2//!
3//! Every freeze of a tree — mutable or append-only — produces a `Seal`. There
4//! is exactly one structural commitment type, and it carries the **resumable
5//! frontier**: per algorithm, the digests of the perfect k-ary subtrees that
6//! [`frontier_for_size`](crate::topology::frontier_for_size) names at the sealed
7//! size (the "peaks", in MMR terms). The frontier is the complete continuation
8//! state of an append-only log, so a log can be *resumed* from any `Seal`
9//! regardless of which kind sealed it.
10//!
11//! # Structural only — no epoch facet (D13)
12//!
13//! `Seal` is the **structural facet** of a snapshot: frontier peaks, the member
14//! roots folded from them, the canonicalization run-extents, and an opaque
15//! metadata channel. It carries **no committed epoch timeline and no binding
16//! root** — those are the *epoch facet*, added by the `polydigest` combinator as a
17//! wrapper over this general `Seal`, never baked in. Keeping the epoch logic out
18//! is what lets the structural facet stay invariant when algorithms are added or
19//! retired (the `Seal` interface does not move under algorithm churn).
20//!
21//! # One commitment, derived views
22//!
23//! The frontier is the stored content; everything else a consumer asks of a
24//! `Seal` is a **derived view**, computed on demand and never stored (this
25//! metadata is provably derivable from the tree, not a parallel committed
26//! channel):
27//!
28//! - each algorithm's **member root** is the consumer's *bag* of its frontier peaks
29//! ([`Seal::member_root`]) — the raw per-algorithm root the leaves authenticate against. The
30//! `Seal` stores peaks only and is topology-agnostic: how peaks bag into one root (an append-only
31//! log's mountain backward-bag, a mutable tree's rebalanced fold) is supplied by the consumer,
32//! not owned here;
33//! - the **canonicalization run-extents** are the height `>= 1` frontier nodes, derived from
34//! `(tree_size, arity)` alone ([`Seal::run_extents`]).
35//!
36//! Member roots are bags, so they need the algorithm's own hasher *and* the
37//! consumer's bagging function; the run-extents are pure geometry and need
38//! nothing. The **binding root** — the combined root over the member roots and
39//! the committed timeline — is the `polydigest` combinator's derived view, not the
40//! structural `Seal`'s.
41//!
42//! # One-way
43//!
44//! `Seal` makes the seal **one-way**: its fields are private, the only ingress
45//! is [`Seal::new`], and the only egress is a read borrow or a derived view.
46//! There is no `unseal` and no field-level mutator, so a value cannot be walked
47//! back to the construction it came from.
48//!
49//! # Metadata channel
50//!
51//! `Seal` carries an optional [`crate::Meta`] — an opaque, arbitrary byte
52//! payload the library never interprets (an out-of-band tree-head attestation
53//! may ride here). It is set via [`Seal::with_meta`] and read via [`Seal::meta`].
54//! The channel is additive: a `Seal` without metadata behaves identically to one
55//! with `None`.
56
57use crate::error::{Error, Result};
58use crate::hasher::Hasher;
59use crate::metadata::Meta;
60use crate::topology::{ARITY_RANGE, frontier_for_size};
61
62/// One committed canonicalization run-extent: a contiguous collapse of
63/// `arity^height` consecutive leaves into a single subtree, beginning at leaf
64/// index `left`.
65///
66/// A run-extent is emitted only for a *collapse* — a frontier node above leaf
67/// level (`height >= 1`). A promoted singleton leaf (`height == 0`) is
68/// structurally deterministic and commits no run-extent (promotion commits
69/// nothing, collapse commits its minimal run-extent), so it never appears here.
70///
71/// The extent is the minimal metadata the `fill` step unrolls from: it says how
72/// many real historical leaves a subtree root stands for, so a complete
73/// (gapless) history can be recomputed without inferring shape from the digest.
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75pub struct RunExtent {
76 /// Index of the first leaf the run collapses.
77 left: u64,
78 /// Height of the collapsed subtree; the run spans `arity.pow(height)`
79 /// leaves. Always `>= 1` — a height-0 node is a promotion, not a collapse.
80 height: u32,
81}
82
83impl RunExtent {
84 /// Index of the first leaf this run collapses.
85 #[must_use]
86 pub fn left(&self) -> u64 {
87 self.left
88 }
89
90 /// Height of the collapsed subtree. The run spans `arity.pow(height)`
91 /// leaves.
92 #[must_use]
93 pub fn height(&self) -> u32 {
94 self.height
95 }
96
97 /// Number of leaves this run collapses, given the arity `k`.
98 #[must_use]
99 pub fn span(&self, k: u64) -> u64 {
100 k.pow(self.height)
101 }
102}
103
104/// The general structural commitment lattice: a sealed, resumable frontier.
105///
106/// Carries, per algorithm with a frontier at `tree_size`, the digests of the
107/// perfect k-ary subtrees of the frontier (the resume state), and an optional
108/// opaque metadata channel ([`Meta`]). Member roots and run-extents are *derived
109/// views* — see the module docs. The committed epoch timeline and binding root
110/// are the `polydigest` combinator's facet, not stored here.
111#[derive(Debug, Clone, PartialEq, Eq)]
112pub struct Seal {
113 /// Tree size at which this frontier was sealed.
114 tree_size: u64,
115 /// Arity `k` (`2..=256`) the frontier was sealed under; fixes the frontier
116 /// geometry every derived view reads.
117 arity: u64,
118 /// Per-algorithm frontier peaks: `(alg_id, peaks)`, sorted by algorithm ID.
119 /// `peaks` are the digests of the perfect k-ary subtrees named by
120 /// [`frontier_for_size`]`(tree_size, arity)`, left to right — the complete
121 /// continuation state for an append-only resume. The member root is their
122 /// fold.
123 frontiers: Vec<(u64, Vec<Vec<u8>>)>,
124 /// Opaque metadata channel; library never inspects the contents.
125 meta: Option<Meta>,
126}
127
128impl Seal {
129 /// Seal a resumable frontier at `tree_size` with no metadata attached.
130 ///
131 /// `arity` is the spine arity `k` (`2..=256`); `frontiers` carries each
132 /// algorithm's frontier peaks (the digests of the perfect k-ary subtrees of
133 /// the frontier, left to right), sorted by algorithm ID.
134 ///
135 /// This is the only way to construct a `Seal`, so every value in
136 /// circulation carries a correctly-sized frontier. The committed epoch
137 /// timeline is **not** an input — it is the `polydigest` combinator's concern.
138 ///
139 /// # Errors
140 ///
141 /// Returns [`Error::BadArity`] if `arity` is outside `2..=256`, or
142 /// [`Error::MalformedFrontier`] if any algorithm's peak count does not match
143 /// the canonical frontier length for `(tree_size, arity)`.
144 pub fn new(tree_size: u64, arity: u64, frontiers: Vec<(u64, Vec<Vec<u8>>)>) -> Result<Self> {
145 if !ARITY_RANGE.contains(&arity) {
146 return Err(Error::BadArity);
147 }
148 // Cross-check that every algorithm's peak count matches the canonical
149 // frontier geometry for (tree_size, arity). A mismatched count means
150 // a caller constructed a Seal with a wrong or truncated peaks slice —
151 // a malformed frontier that member_root would silently fold incorrectly.
152 let expected_peak_count = frontier_for_size(tree_size, arity).len();
153 for (_, peaks) in &frontiers {
154 if peaks.len() != expected_peak_count {
155 return Err(Error::MalformedFrontier);
156 }
157 }
158 Ok(Self {
159 tree_size,
160 arity,
161 frontiers,
162 meta: None,
163 })
164 }
165
166 /// Attach an opaque metadata payload, consuming and returning `self`.
167 ///
168 /// The library never reads or validates the payload; any byte sequence is
169 /// accepted. Calling this again replaces any previously attached payload.
170 #[must_use]
171 pub fn with_meta(mut self, meta: Meta) -> Self {
172 self.meta = Some(meta);
173 self
174 }
175
176 /// The tree size this frontier was sealed at.
177 #[must_use]
178 pub fn tree_size(&self) -> u64 {
179 self.tree_size
180 }
181
182 /// The spine arity `k` this frontier was sealed under.
183 #[must_use]
184 pub fn arity(&self) -> u64 {
185 self.arity
186 }
187
188 /// A read borrow of each algorithm's frontier peaks: `(alg_id, peaks)`,
189 /// sorted by algorithm ID. These are the resume state; the member root is
190 /// their fold ([`Self::member_root`]).
191 #[must_use]
192 pub fn frontiers(&self) -> &[(u64, Vec<Vec<u8>>)] {
193 &self.frontiers
194 }
195
196 /// The frontier peaks of a single algorithm, if it has a frontier in this
197 /// seal.
198 #[must_use]
199 pub fn peaks(&self, alg_id: u64) -> Option<&[Vec<u8>]> {
200 self.frontiers
201 .iter()
202 .find(|(id, _)| *id == alg_id)
203 .map(|(_, p)| p.as_slice())
204 }
205
206 /// A read borrow of the attached opaque metadata, if any.
207 ///
208 /// Returns `None` when no metadata was attached via [`Self::with_meta`].
209 /// The library never interprets the bytes; fidelity (round-trip) is the
210 /// only guarantee.
211 #[must_use]
212 pub fn meta(&self) -> Option<&Meta> {
213 self.meta.as_ref()
214 }
215
216 // --- derived views -------------------------------------------------------
217
218 /// **Derived view.** Each algorithm's member root: `(alg_id, member_root)`,
219 /// sorted by algorithm ID. A member root is the consumer's `bag` of that
220 /// algorithm's frontier peaks under its own hash — the raw per-algorithm root
221 /// the leaves authenticate against. Folded on demand, never stored.
222 ///
223 /// The `Seal` stores peaks only and is **topology-agnostic**: how the peaks
224 /// bag into one root is the consumer's choice, supplied as
225 /// `bag(hasher, peaks, arity)` — the append-only log passes its mountain
226 /// backward-bag, the mutable tree its rebalanced fold. The same `bag` applies
227 /// to every algorithm (one topology per structure).
228 ///
229 /// `hashers` resolves an algorithm's own hash; an algorithm with no hasher
230 /// in `hashers` is skipped (its member root cannot be folded).
231 #[must_use]
232 pub fn member_roots(
233 &self,
234 hashers: &[(u64, &dyn Hasher)],
235 bag: crate::topology::BagFn,
236 ) -> Vec<(u64, Vec<u8>)> {
237 self.frontiers
238 .iter()
239 .filter_map(|(id, peaks)| {
240 let hasher = hashers.iter().find(|(hid, _)| hid == id).map(|(_, h)| *h)?;
241 Some((*id, bag(hasher, peaks, self.arity)))
242 })
243 .collect()
244 }
245
246 /// **Derived view.** A single algorithm's member root — the consumer's `bag`
247 /// of its frontier peaks under `hasher`. Returns `None` if the algorithm has
248 /// no frontier in this seal. See [`Self::member_roots`] for the `bag`
249 /// contract.
250 #[must_use]
251 pub fn member_root(
252 &self,
253 alg_id: u64,
254 hasher: &dyn Hasher,
255 bag: crate::topology::BagFn,
256 ) -> Option<Vec<u8>> {
257 let peaks = self.peaks(alg_id)?;
258 Some(bag(hasher, peaks, self.arity))
259 }
260
261 /// **Derived view.** Every algorithm's member root, in sealed (sorted)
262 /// order, bagged under the supplied hashers — or [`Error::MissingHasher`]
263 /// naming the first algorithm with no hasher.
264 ///
265 /// Unlike [`Self::member_roots`], which is the *produce*-side view a caller
266 /// may legitimately take over a subset of hashers, this is the *complete*
267 /// member-root child set the `polydigest` binding-root fold commits. Folding over
268 /// a truncated child list would yield a combined root no algorithm
269 /// published, so a missing hasher is an error, never a silent skip. See
270 /// [`Self::member_roots`] for the `bag` contract.
271 ///
272 /// # Errors
273 ///
274 /// Returns [`Error::MissingHasher`] (naming the algorithm) if any algorithm
275 /// with a frontier in this seal has no hasher in `hashers`.
276 pub fn all_member_roots(
277 &self,
278 hashers: &[(u64, &dyn Hasher)],
279 bag: crate::topology::BagFn,
280 ) -> Result<Vec<(u64, Vec<u8>)>> {
281 self.frontiers
282 .iter()
283 .map(|(id, peaks)| {
284 let hasher = hashers
285 .iter()
286 .find(|(hid, _)| hid == id)
287 .map(|(_, h)| *h)
288 .ok_or(Error::MissingHasher { alg_id: *id })?;
289 Ok((*id, bag(hasher, peaks, self.arity)))
290 })
291 .collect()
292 }
293
294 /// **Derived view.** The committed canonicalization run-extents: the
295 /// height `>= 1` nodes of the frontier at the sealed size, in left-to-right
296 /// order. Derived from `(tree_size, arity)` alone — never inferred from a
297 /// digest. Promotions (height-0 frontier nodes) commit nothing and are
298 /// omitted.
299 #[must_use]
300 pub fn run_extents(&self) -> Vec<RunExtent> {
301 frontier_for_size(self.tree_size, self.arity)
302 .into_iter()
303 .filter(|&(_, height)| height >= 1)
304 .map(|(left, height)| RunExtent { left, height })
305 .collect()
306 }
307}
308
309#[cfg(test)]
310mod tests {
311 use sha2::{Digest, Sha256};
312
313 use super::*;
314
315 /// A fixed-width (32-byte) test hasher.
316 #[derive(Debug, Clone)]
317 struct H;
318 impl Hasher for H {
319 fn leaf(&self, data: &[u8]) -> Vec<u8> {
320 Sha256::digest(data).to_vec()
321 }
322
323 fn node(&self, children: &[&[u8]]) -> Vec<u8> {
324 let mut h = Sha256::new();
325 for c in children {
326 h.update(c);
327 }
328 h.finalize().to_vec()
329 }
330
331 fn empty(&self) -> Vec<u8> {
332 Sha256::digest(b"").to_vec()
333 }
334
335 fn hash(&self, data: &[u8]) -> Vec<u8> {
336 Sha256::digest(data).to_vec()
337 }
338
339 fn clone_box(&self) -> Box<dyn Hasher> {
340 Box::new(self.clone())
341 }
342 }
343
344 /// A generic rightmost-`k` grouping bag, used here only to exercise the
345 /// `Seal`'s topology-agnostic peak-storage mechanism. A real consumer
346 /// supplies its own (the log's mountain backward-bag, the tree's rebalanced
347 /// fold); the `Seal` itself owns no topology.
348 fn test_bag(hasher: &dyn Hasher, peaks: &[Vec<u8>], k: u64) -> Vec<u8> {
349 use crate::mr::nary_mr;
350 use crate::topology::fold_frontier;
351 if peaks.is_empty() {
352 return hasher.empty();
353 }
354 fold_frontier(peaks.to_vec(), k as usize, |chunk| {
355 let refs: Vec<&[u8]> = chunk.iter().map(|v| v.as_slice()).collect();
356 nary_mr(hasher, &refs)
357 })
358 }
359
360 #[test]
361 fn new_rejects_out_of_range_arity() {
362 assert_eq!(
363 Seal::new(1, 1, vec![(0, vec![vec![0xAA; 32]])]),
364 Err(Error::BadArity)
365 );
366 assert_eq!(
367 Seal::new(1, 257, vec![(0, vec![vec![0xAA; 32]])]),
368 Err(Error::BadArity)
369 );
370 }
371
372 #[test]
373 fn new_rejects_mismatched_peak_count() {
374 // A wrong peak count is a malformed *frontier*. Size 3, k=2 → frontier =
375 // [(0,1),(2,0)] → 2 peaks expected. Supplying 1 peak is malformed.
376 assert_eq!(
377 Seal::new(3, 2, vec![(0, vec![vec![0xAA; 32]])]),
378 Err(Error::MalformedFrontier)
379 );
380 // Supplying 3 peaks is also malformed.
381 assert_eq!(
382 Seal::new(
383 3,
384 2,
385 vec![(0, vec![vec![0xAA; 32], vec![0xBB; 32], vec![0xCC; 32]])]
386 ),
387 Err(Error::MalformedFrontier)
388 );
389 }
390
391 #[test]
392 fn new_accepts_well_formed_and_reads_back() {
393 let sealed = Seal::new(1, 2, vec![(0, vec![vec![0xAA; 32]])]).expect("well-formed");
394 assert_eq!(sealed.tree_size(), 1);
395 assert_eq!(sealed.arity(), 2);
396 assert_eq!(sealed.peaks(0), Some([vec![0xAA; 32]].as_slice()));
397 }
398
399 #[test]
400 fn member_root_folds_a_single_peak_to_itself() {
401 // A single frontier peak is the member root (promotion).
402 let peak = vec![0xCD; 32];
403 let sealed = Seal::new(1, 2, vec![(0, vec![peak.clone()])]).expect("well-formed");
404 assert_eq!(sealed.member_root(0, &H, test_bag), Some(peak));
405 }
406
407 #[test]
408 fn member_root_folds_two_peaks_with_the_hasher() {
409 use crate::mr::nary_mr;
410 // Two peaks bag to nary_mr(hasher, [p0, p1]) under the generic bag.
411 let p0 = vec![0x01; 32];
412 let p1 = vec![0x02; 32];
413 let expected = nary_mr(&H, &[p0.as_slice(), p1.as_slice()]);
414 let sealed = Seal::new(3, 2, vec![(0, vec![p0.clone(), p1.clone()])]).expect("well-formed");
415 assert_eq!(sealed.member_root(0, &H, test_bag), Some(expected));
416 }
417
418 #[test]
419 fn all_member_roots_errors_on_a_missing_hasher() {
420 // Two algorithms with frontiers, but a hasher only for alg 0. The
421 // complete member-root child set cannot be folded, so a missing hasher
422 // for alg 1 must surface as a clear error, not a silent skip.
423 let p0 = vec![0x11; 32];
424 let p1 = vec![0x22; 32];
425 let sealed = Seal::new(
426 3,
427 2,
428 vec![(0, vec![p0.clone(), p0.clone()]), (1, vec![p1.clone(), p1])],
429 )
430 .expect("well-formed");
431 let partial: [(u64, &dyn Hasher); 1] = [(0, &H)];
432 assert_eq!(
433 sealed.all_member_roots(&partial, test_bag),
434 Err(Error::MissingHasher { alg_id: 1 })
435 );
436 // With every hasher present the fold succeeds.
437 let full: [(u64, &dyn Hasher); 2] = [(0, &H), (1, &H)];
438 assert_eq!(sealed.all_member_roots(&full, test_bag).unwrap().len(), 2);
439 }
440
441 #[test]
442 fn run_extents_are_the_collapse_frontier_geometry() {
443 // Size 7, k=2: frontier = [(0,2),(4,1),(6,0)]; the height-0 entry is a
444 // promotion and is omitted.
445 let sealed = Seal::new(
446 7,
447 2,
448 vec![(0, vec![vec![0xAA; 32], vec![0xBB; 32], vec![0xCC; 32]])],
449 )
450 .expect("well-formed");
451 let extents = sealed.run_extents();
452 assert_eq!(extents.len(), 2);
453 assert_eq!((extents[0].left(), extents[0].height()), (0, 2));
454 assert_eq!((extents[1].left(), extents[1].height()), (4, 1));
455 assert_eq!(extents[0].span(2), 4);
456 assert_eq!(extents[1].span(2), 2);
457 }
458
459 #[test]
460 fn no_meta_by_default_and_with_meta_round_trips() {
461 let sealed = Seal::new(1, 2, vec![(0, vec![vec![0xBB; 32]])]).expect("well-formed");
462 assert_eq!(sealed.meta(), None);
463 let payload: Vec<u8> = (0u8..=255).collect();
464 let with = sealed.with_meta(Meta::new(payload.clone()));
465 assert_eq!(with.meta().map(Meta::as_bytes), Some(payload.as_slice()));
466 }
467}