Skip to main content

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}