Skip to main content

objects/object/
state_visibility.rs

1// SPDX-License-Identifier: Apache-2.0
2//! StateVisibility — a declaration that a state (commit) carries a
3//! non-public audience tier.
4//!
5//! Modeled on [`Redaction`](crate::object::Redaction): an *additive*
6//! sidecar record that lives outside the hashed `State` bytes, so changing a
7//! state's tier never mutates the state or invalidates its signature. The
8//! record is keyed by `ChangeId` (the state), not by a blob hash — commit
9//! visibility is a per-state property, where redaction is per-blob.
10//!
11//! **Absence ≡ public.** A public resolution stays record-free: the public
12//! tier is the default, and a state with no `StateVisibility` record is
13//! served to every audience. Only resolutions more restrictive than public
14//! are persisted, so the per-state sidecar is empty for the common case.
15
16use chrono::{DateTime, Utc};
17use serde::{Deserialize, Serialize};
18
19use crate::object::{ChangeId, ContentHash, Principal, StateSignature, VisibilityTier};
20
21/// Stable byte prefix the signing payload begins with. Bumping this versions
22/// the payload format itself; old signatures with the old prefix continue to
23/// verify exactly as they did when written.
24pub const STATE_VISIBILITY_SIGNING_PAYLOAD_VERSION_TAG: &[u8] = b"hd-statevis-v1\x00";
25
26/// A visibility-tier declaration on a single state.
27#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
28pub struct StateVisibility {
29    /// The state (commit) this tier applies to.
30    pub state: ChangeId,
31    /// The audience tier the state's content is served at.
32    pub tier: VisibilityTier,
33    /// When set, the host materializes a superseding public record at this
34    /// instant (auto-promote). Advisory schedule only — the *effective*
35    /// tier is always read from the persisted records, never recomputed
36    /// from wall-clock at read time.
37    #[serde(default)]
38    pub embargo_until: Option<DateTime<Utc>>,
39    /// Who declared the tier.
40    pub declarer: Principal,
41    /// When the tier was declared. RFC3339 at the wire boundary;
42    /// `DateTime<Utc>` internally.
43    pub declared_at: DateTime<Utc>,
44    /// Optional cryptographic signature over the canonical signing payload
45    /// (see [`canonical_signing_payload`](StateVisibility::canonical_signing_payload)).
46    /// `None` for unsigned declarations.
47    #[serde(default)]
48    pub signature: Option<StateSignature>,
49    /// The record this one supersedes, if any — promotion appends a
50    /// superseding record rather than mutating a prior one. Identified by
51    /// the prior record's content hash.
52    #[serde(default)]
53    pub supersedes: Option<ContentHash>,
54}
55
56impl StateVisibility {
57    /// Build the canonical bytes a signer covers. The `signature` field is
58    /// intentionally excluded (a signature can't sign itself).
59    pub fn canonical_signing_payload(&self) -> Vec<u8> {
60        let mut buf = Vec::with_capacity(128);
61        buf.extend_from_slice(STATE_VISIBILITY_SIGNING_PAYLOAD_VERSION_TAG);
62        buf.extend_from_slice(self.state.as_bytes());
63        buf.extend_from_slice(self.tier.as_str().as_bytes());
64        buf.push(0);
65        match &self.tier {
66            VisibilityTier::TeamScoped { team_id } => buf.extend_from_slice(team_id.as_bytes()),
67            VisibilityTier::Restricted { scope_label }
68            | VisibilityTier::Private { scope_label } => {
69                buf.extend_from_slice(scope_label.as_bytes())
70            }
71            VisibilityTier::Public | VisibilityTier::Internal => {}
72        }
73        buf.push(0);
74        if let Some(embargo_until) = &self.embargo_until {
75            buf.extend_from_slice(embargo_until.to_rfc3339().as_bytes());
76        }
77        buf.push(0);
78        buf.extend_from_slice(self.declarer.name.as_bytes());
79        buf.push(0);
80        buf.extend_from_slice(self.declarer.email.as_bytes());
81        buf.push(0);
82        buf.extend_from_slice(self.declared_at.to_rfc3339().as_bytes());
83        if let Some(supersedes) = &self.supersedes {
84            buf.extend_from_slice(supersedes.as_bytes());
85        }
86        buf
87    }
88
89    /// Per-item validation hook required by [`versioned_msgpack_blob!`].
90    /// A `TeamScoped`/`Restricted` tier must carry a non-empty label —
91    /// an empty label is meaningless and would silently widen the
92    /// audience to "any team / any restricted scope".
93    pub fn validate(&self) -> Result<(), StateVisibilityError> {
94        match &self.tier {
95            VisibilityTier::TeamScoped { team_id } if team_id.trim().is_empty() => {
96                Err(StateVisibilityError::EmptyTierLabel("team_scoped"))
97            }
98            VisibilityTier::Restricted { scope_label } if scope_label.trim().is_empty() => {
99                Err(StateVisibilityError::EmptyTierLabel("restricted"))
100            }
101            VisibilityTier::Private { scope_label } if scope_label.trim().is_empty() => {
102                Err(StateVisibilityError::EmptyTierLabel("private"))
103            }
104            _ => Ok(()),
105        }
106    }
107
108    /// Content-addressed id of this record: `blake3` over the canonical
109    /// rmp-encoded bytes of a one-element [`StateVisibilityBlob`]. This is the
110    /// id a superseding record stores in its [`supersedes`](Self::supersedes)
111    /// pointer, and the key [`StateVisibilityBlob::latest`] resolves the
112    /// supersede chain by — so the write path (which sets `supersedes` from the
113    /// under-lock head) and the read path (which walks the chain) agree by
114    /// construction. The id covers the single record's bytes embedded in the
115    /// versioned envelope, so it stays stable across schema additions that only
116    /// extend the container.
117    pub fn content_hash(&self) -> Result<ContentHash, StateVisibilityError> {
118        let single = StateVisibilityBlob::new(vec![self.clone()]);
119        let bytes = single.encode()?;
120        Ok(ContentHash::from_bytes(*blake3::hash(&bytes).as_bytes()))
121    }
122}
123
124/// On-disk blob containing all visibility records for a single state. One
125/// file per state, encoded with `rmp-serde` — mirrors the
126/// [`RedactionsBlob`](crate::object::RedactionsBlob) sidecar pattern.
127#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
128pub struct StateVisibilityBlob {
129    pub format_version: u8,
130    pub records: Vec<StateVisibility>,
131}
132
133// `new` / `encode` / `decode` / `validate` + `FORMAT_VERSION`. `decode`
134// rejects any blob whose `format_version` isn't the current one.
135versioned_msgpack_blob! {
136    blob: StateVisibilityBlob,
137    item: StateVisibility,
138    field: records,
139    error: StateVisibilityError,
140    codec_err: Codec,
141    version: 1,
142}
143
144impl StateVisibilityBlob {
145    pub fn empty() -> Self {
146        Self::new(Vec::new())
147    }
148
149    pub fn push(&mut self, record: StateVisibility) {
150        self.records.push(record);
151    }
152
153    /// `true` iff this state carries any visibility record — i.e. it is not
154    /// public-by-absence. Used by the sidecar's `has_visibility_for_state`.
155    pub fn has_record(&self) -> bool {
156        !self.records.is_empty()
157    }
158
159    /// The effective record: the **head of the supersede DAG** — the record in
160    /// this blob that no other record supersedes — resolved purely from the
161    /// records' content-intrinsic [`supersedes`](StateVisibility::supersedes)
162    /// pointers, **never** from wall-clock `declared_at`.
163    ///
164    /// Each locally-committed declaration links onto the prior head it read
165    /// under the repo write lock — its `supersedes` points at that head's
166    /// content hash (heddle#317 / PR #529 P1) — so for serialized local writes
167    /// the chain is linear and the head is the **last-committed** record,
168    /// independent of clock skew or whatever order the timestamps happen to
169    /// carry. `declared_at` is an audit/display field only. This also fixes a
170    /// latent cross-host bug: wall-clock cannot order records replicated across
171    /// hosts whose clocks disagree, but the content-hash chain can.
172    ///
173    /// **Fork tie-break (concurrent / cross-host).** Two records can supersede
174    /// the *same* prior with neither superseding the other — a genuine
175    /// concurrent fork, e.g. two hosts that diverged. Both are heads. To make
176    /// every replica resolve the SAME effective record without consulting
177    /// wall-clock, the tie is broken by the **lexicographically greatest record
178    /// content hash** — a content-intrinsic, host-independent key. Cycles are
179    /// cryptographically unconstructable (a record's hash covers its
180    /// `supersedes` pointer, so no record can supersede one minted after it), so
181    /// a non-empty blob always has at least one head.
182    pub fn latest(&self) -> Result<Option<&StateVisibility>, StateVisibilityError> {
183        // Every content hash referenced by some record's `supersedes` pointer.
184        // A record whose own hash appears here has been superseded — it is not
185        // the head.
186        let superseded: std::collections::HashSet<ContentHash> =
187            self.records.iter().filter_map(|r| r.supersedes).collect();
188
189        let mut head: Option<(&StateVisibility, ContentHash)> = None;
190        for record in &self.records {
191            let hash = record.content_hash()?;
192            if superseded.contains(&hash) {
193                continue;
194            }
195            // Among multiple heads (a fork), keep the greatest content hash so
196            // the pick is deterministic and host-independent.
197            let take = match &head {
198                Some((_, best)) => hash > *best,
199                None => true,
200            };
201            if take {
202                head = Some((record, hash));
203            }
204        }
205        Ok(head.map(|(record, _)| record))
206    }
207}
208
209/// Errors produced while encoding/decoding/validating state visibility.
210#[derive(Debug, thiserror::Error)]
211pub enum StateVisibilityError {
212    #[error("unsupported state-visibility format version {0}")]
213    UnsupportedVersion(u8),
214    #[error("state-visibility codec error: {0}")]
215    Codec(String),
216    #[error("{0} tier requires a non-empty label")]
217    EmptyTierLabel(&'static str),
218}
219
220#[cfg(test)]
221mod tests {
222    use chrono::TimeZone;
223
224    use super::*;
225
226    fn principal() -> Principal {
227        Principal {
228            name: "Grace Hopper".into(),
229            email: "grace@example.com".into(),
230        }
231    }
232
233    fn record(tier: VisibilityTier) -> StateVisibility {
234        StateVisibility {
235            state: ChangeId::from_bytes([3u8; 16]),
236            tier,
237            embargo_until: None,
238            declarer: principal(),
239            declared_at: Utc.with_ymd_and_hms(2026, 6, 1, 12, 0, 0).unwrap(),
240            signature: None,
241            supersedes: None,
242        }
243    }
244
245    #[test]
246    fn round_trips_through_msgpack() {
247        let original = StateVisibilityBlob::new(vec![record(VisibilityTier::Restricted {
248            scope_label: "security-embargo".into(),
249        })]);
250        let encoded = original.encode().expect("encode");
251        let decoded = StateVisibilityBlob::decode(&encoded).expect("decode");
252        assert_eq!(decoded, original);
253        // Format-version is load-bearing: future readers branch on it.
254        assert_eq!(decoded.format_version, StateVisibilityBlob::FORMAT_VERSION);
255    }
256
257    #[test]
258    fn round_trips_with_embargo_and_supersedes() {
259        let mut r = record(VisibilityTier::TeamScoped {
260            team_id: "infra".into(),
261        });
262        r.embargo_until = Some(Utc.with_ymd_and_hms(2026, 7, 1, 0, 0, 0).unwrap());
263        r.supersedes = Some(ContentHash::from_bytes([9u8; 32]));
264        let original = StateVisibilityBlob::new(vec![r]);
265        let encoded = original.encode().expect("encode");
266        let decoded = StateVisibilityBlob::decode(&encoded).expect("decode");
267        assert_eq!(decoded, original);
268    }
269
270    #[test]
271    fn decode_rejects_wrong_version() {
272        // Hand-encode a blob whose format_version is not the current one;
273        // decode must reject it via the macro's version-check prologue.
274        let bad = StateVisibilityBlob {
275            format_version: StateVisibilityBlob::FORMAT_VERSION + 7,
276            records: vec![record(VisibilityTier::Internal)],
277        };
278        let bytes = rmp_serde::to_vec(&bad).expect("raw encode");
279        let err = StateVisibilityBlob::decode(&bytes).expect_err("must reject wrong version");
280        assert!(matches!(
281            err,
282            StateVisibilityError::UnsupportedVersion(v) if v == StateVisibilityBlob::FORMAT_VERSION + 7
283        ));
284    }
285
286    #[test]
287    fn validate_rejects_empty_label() {
288        let blob = StateVisibilityBlob::new(vec![record(VisibilityTier::Restricted {
289            scope_label: "  ".into(),
290        })]);
291        assert!(matches!(
292            blob.validate(),
293            Err(StateVisibilityError::EmptyTierLabel("restricted"))
294        ));
295    }
296
297    #[test]
298    fn canonical_payload_is_versioned_and_carries_tier() {
299        let r = record(VisibilityTier::Restricted {
300            scope_label: "security-embargo".into(),
301        });
302        let payload = r.canonical_signing_payload();
303        assert!(payload.starts_with(STATE_VISIBILITY_SIGNING_PAYLOAD_VERSION_TAG));
304        let text = String::from_utf8_lossy(&payload);
305        assert!(text.contains("restricted"));
306        assert!(text.contains("security-embargo"));
307        assert!(text.contains("grace@example.com"));
308    }
309
310    #[test]
311    fn latest_resolves_the_supersede_chain_head() {
312        // The effective record is the head of the supersede chain — the record
313        // no other record supersedes — not the most recent by wall-clock.
314        let early = record(VisibilityTier::Internal);
315        let early_id = early.content_hash().unwrap();
316        let late = StateVisibility {
317            declared_at: Utc.with_ymd_and_hms(2026, 6, 2, 9, 0, 0).unwrap(),
318            supersedes: Some(early_id),
319            ..record(VisibilityTier::Public)
320        };
321        let blob = StateVisibilityBlob::new(vec![early, late.clone()]);
322        assert_eq!(blob.latest().unwrap().unwrap(), &late);
323    }
324
325    #[test]
326    fn latest_ignores_wall_clock_declared_at() {
327        // A record with a strictly LATER declared_at but EARLIER in the
328        // supersede chain must NOT be selected — the chain head wins regardless
329        // of wall-clock. This is the bug class the redesign closes: selection is
330        // content-intrinsic, so it can't be skewed by timestamps (or clock
331        // disagreement across hosts).
332        let head_tier = VisibilityTier::TeamScoped {
333            team_id: "infra".into(),
334        };
335        // The genesis carries the LATEST timestamp...
336        let early_in_chain = StateVisibility {
337            declared_at: Utc.with_ymd_and_hms(2030, 1, 1, 0, 0, 0).unwrap(),
338            ..record(VisibilityTier::Internal)
339        };
340        let early_id = early_in_chain.content_hash().unwrap();
341        // ...the chain head supersedes it but carries an EARLIER timestamp.
342        let head = StateVisibility {
343            declared_at: Utc.with_ymd_and_hms(2000, 1, 1, 0, 0, 0).unwrap(),
344            supersedes: Some(early_id),
345            ..record(head_tier.clone())
346        };
347        let blob = StateVisibilityBlob::new(vec![early_in_chain, head.clone()]);
348        let latest = blob.latest().unwrap().unwrap();
349        assert_eq!(latest, &head);
350        assert_eq!(
351            latest.tier, head_tier,
352            "the chain head wins even though its declared_at is the earlier of the two"
353        );
354    }
355
356    #[test]
357    fn concurrent_fork_resolves_deterministically() {
358        // Two records supersede the SAME prior with neither superseding the
359        // other — a genuine concurrent fork (e.g. two hosts). latest() must pick
360        // the SAME head on every replica via the content-intrinsic tie-break
361        // (greatest content hash), never wall-clock and never input order.
362        let genesis = record(VisibilityTier::Internal);
363        let genesis_id = genesis.content_hash().unwrap();
364        let fork_a = StateVisibility {
365            declared_at: Utc.with_ymd_and_hms(2026, 6, 2, 9, 0, 0).unwrap(),
366            supersedes: Some(genesis_id),
367            ..record(VisibilityTier::TeamScoped {
368                team_id: "host-a".into(),
369            })
370        };
371        let fork_b = StateVisibility {
372            declared_at: Utc.with_ymd_and_hms(2026, 6, 3, 9, 0, 0).unwrap(),
373            supersedes: Some(genesis_id),
374            ..record(VisibilityTier::TeamScoped {
375                team_id: "host-b".into(),
376            })
377        };
378        // The deterministic winner is the head with the greater content hash.
379        let expected = if fork_a.content_hash().unwrap() > fork_b.content_hash().unwrap() {
380            fork_a.clone()
381        } else {
382            fork_b.clone()
383        };
384        // Resolved identically regardless of the order the records appear in.
385        let blob1 = StateVisibilityBlob::new(vec![genesis.clone(), fork_a.clone(), fork_b.clone()]);
386        let blob2 = StateVisibilityBlob::new(vec![genesis, fork_b, fork_a]);
387        assert_eq!(blob1.latest().unwrap().unwrap(), &expected);
388        assert_eq!(
389            blob2.latest().unwrap().unwrap(),
390            &expected,
391            "the fork must resolve to the same head independent of record order"
392        );
393    }
394
395    #[test]
396    fn empty_blob_has_no_record() {
397        assert!(!StateVisibilityBlob::empty().has_record());
398    }
399}