Skip to main content

heddle_object_model/object/
entry_visibility.rs

1// SPDX-License-Identifier: Apache-2.0
2//! EntryVisibility — a per-entry visibility sidecar for v4 salted trees.
3//!
4//! Where [`StateVisibility`](crate::object::StateVisibility) declares one tier
5//! for a whole commit, `EntryVisibility` declares tiers for individual tree
6//! *entries* so a v4 (salted-Merkle) tree can be served with some entries
7//! redacted to opaque leaf hashes (design v4-redactable-tree §8; Fable C2).
8//!
9//! **Keyed by the state's [`ChangeId`].** One sidecar covers a state's nested
10//! trees; each record names the enclosing `tree_id`, the entry's `leaf_hash`
11//! (the salted per-entry commitment — the only stable, name-free handle for a
12//! redacted entry), and the [`VisibilityTier`] the entry is served at. Leaf
13//! hashes are stable across states that share a subtree (sticky salts), so a
14//! record keyed by leaf hash survives rebasing/lineage the way a `ChangeId`
15//! does.
16//!
17//! This is the heddle-side *produce + stage* type. The weft-side
18//! accept/persist/serve seam (composing the state baseline with these
19//! downward-only overrides) is a later leg; here the sidecar is only built at
20//! capture and staged in the snapshot's oplog batch.
21
22use serde::{Deserialize, Serialize};
23
24use crate::object::{ChangeId, ContentHash, VisibilityTier};
25
26/// Current on-disk format version for an [`EntryVisibility`] blob.
27pub const ENTRY_VISIBILITY_FORMAT_VERSION: u8 = 1;
28
29/// One per-entry visibility override within a state's trees.
30#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
31pub struct EntryVisibilityEntry {
32    /// The id of the (v4 salted) tree that directly contains the entry.
33    pub tree_id: ContentHash,
34    /// The entry's salted per-entry leaf commitment — the name-free handle a
35    /// redacted serve projection is keyed by.
36    pub leaf_hash: ContentHash,
37    /// The tier this entry is served at. Composed downward-only with the
38    /// state baseline at serve time (a later leg).
39    pub tier: VisibilityTier,
40}
41
42/// The per-state entry-visibility sidecar: the set of per-entry tier overrides
43/// covering the trees reachable from one state, keyed by that state's
44/// [`ChangeId`].
45#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
46pub struct EntryVisibility {
47    /// Format version; [`Self::decode`] rejects anything but the current one.
48    pub format_version: u8,
49    /// The state (by rewrite-stable change id) these overrides apply to.
50    pub change_id: ChangeId,
51    /// The state's root tree id — the anchor the entry records hang beneath.
52    pub tree_root: ContentHash,
53    /// The per-entry overrides. Order is normalized (by `(tree_id, leaf_hash)`)
54    /// so the encoded bytes are canonical for a given override set.
55    pub entries: Vec<EntryVisibilityEntry>,
56}
57
58impl EntryVisibility {
59    /// Build a sidecar from its overrides, normalizing entry order so equal
60    /// override sets encode to identical bytes (and hash identically).
61    ///
62    /// Fails loud on a **conflicting duplicate**: two overrides on the same
63    /// `(tree_id, leaf_hash)` with *different* tiers. Leg 3 keys a serve-time
64    /// map by leaf hash, so a duplicate leaf would be silent first/last-wins
65    /// ambiguity. An exact duplicate (same leaf, same tier) is harmless and is
66    /// de-duplicated.
67    pub fn new(
68        change_id: ChangeId,
69        tree_root: ContentHash,
70        mut entries: Vec<EntryVisibilityEntry>,
71    ) -> Result<Self, EntryVisibilityError> {
72        entries.sort_by(|a, b| {
73            a.tree_id
74                .as_bytes()
75                .cmp(b.tree_id.as_bytes())
76                .then_with(|| a.leaf_hash.as_bytes().cmp(b.leaf_hash.as_bytes()))
77        });
78        // Adjacent equal `(tree_id, leaf_hash)` after the sort: reject a tier
79        // conflict, collapse an exact duplicate.
80        let mut deduped: Vec<EntryVisibilityEntry> = Vec::with_capacity(entries.len());
81        for entry in entries {
82            if let Some(last) = deduped.last()
83                && last.tree_id == entry.tree_id
84                && last.leaf_hash == entry.leaf_hash
85            {
86                if last.tier != entry.tier {
87                    return Err(EntryVisibilityError::ConflictingDuplicate {
88                        tree_id: entry.tree_id.to_hex(),
89                        leaf_hash: entry.leaf_hash.to_hex(),
90                    });
91                }
92                continue;
93            }
94            deduped.push(entry);
95        }
96        Ok(Self {
97            format_version: ENTRY_VISIBILITY_FORMAT_VERSION,
98            change_id,
99            tree_root,
100            entries: deduped,
101        })
102    }
103
104    /// `true` iff this sidecar carries at least one override. An empty sidecar
105    /// is never persisted (absence ≡ "every entry at the state baseline").
106    pub fn has_records(&self) -> bool {
107        !self.entries.is_empty()
108    }
109
110    /// Encode to canonical msgpack bytes.
111    pub fn encode(&self) -> Result<Vec<u8>, EntryVisibilityError> {
112        rmp_serde::to_vec_named(self).map_err(|e| EntryVisibilityError::Codec(e.to_string()))
113    }
114
115    /// Decode msgpack bytes, rejecting an unsupported format version.
116    pub fn decode(bytes: &[u8]) -> Result<Self, EntryVisibilityError> {
117        let value: Self =
118            rmp_serde::from_slice(bytes).map_err(|e| EntryVisibilityError::Codec(e.to_string()))?;
119        if value.format_version != ENTRY_VISIBILITY_FORMAT_VERSION {
120            return Err(EntryVisibilityError::UnsupportedVersion(
121                value.format_version,
122            ));
123        }
124        Ok(value)
125    }
126
127    /// Content-addressed id of this sidecar — `blake3` over its canonical
128    /// encoded bytes. Named in the oplog record so undo/redo can correlate.
129    pub fn content_hash(&self) -> Result<ContentHash, EntryVisibilityError> {
130        let bytes = self.encode()?;
131        Ok(ContentHash::from_bytes(*blake3::hash(&bytes).as_bytes()))
132    }
133}
134
135/// Errors produced while encoding/decoding an [`EntryVisibility`] sidecar.
136#[derive(Debug, thiserror::Error)]
137pub enum EntryVisibilityError {
138    #[error("unsupported entry-visibility format version {0}")]
139    UnsupportedVersion(u8),
140    #[error("entry-visibility codec error: {0}")]
141    Codec(String),
142    #[error(
143        "conflicting entry-visibility overrides for the same entry (tree {tree_id}, leaf {leaf_hash})"
144    )]
145    ConflictingDuplicate { tree_id: String, leaf_hash: String },
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151
152    fn hash(seed: &str) -> ContentHash {
153        ContentHash::compute_typed("test", seed.as_bytes())
154    }
155
156    #[test]
157    fn round_trips_and_normalizes_order() {
158        let change = ChangeId::generate();
159        let root = hash("root");
160        let unordered = vec![
161            EntryVisibilityEntry {
162                tree_id: hash("t2"),
163                leaf_hash: hash("l2"),
164                tier: VisibilityTier::Internal,
165            },
166            EntryVisibilityEntry {
167                tree_id: hash("t1"),
168                leaf_hash: hash("l1"),
169                tier: VisibilityTier::Private {
170                    scope_label: "secret".into(),
171                },
172            },
173        ];
174        let a = EntryVisibility::new(change, root, unordered.clone()).unwrap();
175        let mut reversed = unordered;
176        reversed.reverse();
177        let b = EntryVisibility::new(change, root, reversed).unwrap();
178        assert_eq!(
179            a.encode().unwrap(),
180            b.encode().unwrap(),
181            "order must normalize"
182        );
183
184        let decoded = EntryVisibility::decode(&a.encode().unwrap()).unwrap();
185        assert_eq!(decoded, a);
186        assert_eq!(decoded.content_hash().unwrap(), a.content_hash().unwrap());
187    }
188
189    #[test]
190    fn rejects_unsupported_version() {
191        let change = ChangeId::generate();
192        let mut sidecar = EntryVisibility::new(change, hash("root"), Vec::new()).unwrap();
193        sidecar.format_version = 99;
194        let bytes = rmp_serde::to_vec_named(&sidecar).unwrap();
195        assert!(matches!(
196            EntryVisibility::decode(&bytes),
197            Err(EntryVisibilityError::UnsupportedVersion(99))
198        ));
199    }
200
201    #[test]
202    fn rejects_conflicting_duplicate_leaf() {
203        let change = ChangeId::generate();
204        let root = hash("root");
205        let conflicting = vec![
206            EntryVisibilityEntry {
207                tree_id: root,
208                leaf_hash: hash("leaf"),
209                tier: VisibilityTier::Internal,
210            },
211            EntryVisibilityEntry {
212                tree_id: root,
213                leaf_hash: hash("leaf"),
214                tier: VisibilityTier::Private {
215                    scope_label: "secret".into(),
216                },
217            },
218        ];
219        assert!(matches!(
220            EntryVisibility::new(change, root, conflicting),
221            Err(EntryVisibilityError::ConflictingDuplicate { .. })
222        ));
223
224        // An exact duplicate (same leaf, same tier) is collapsed, not rejected.
225        let exact = vec![
226            EntryVisibilityEntry {
227                tree_id: root,
228                leaf_hash: hash("leaf"),
229                tier: VisibilityTier::Internal,
230            },
231            EntryVisibilityEntry {
232                tree_id: root,
233                leaf_hash: hash("leaf"),
234                tier: VisibilityTier::Internal,
235            },
236        ];
237        let sidecar = EntryVisibility::new(change, root, exact).unwrap();
238        assert_eq!(sidecar.entries.len(), 1);
239    }
240}