1use chrono::{DateTime, Utc};
17use serde::{Deserialize, Serialize};
18
19use crate::object::{ChangeId, ContentHash, Principal, StateSignature, VisibilityTier};
20
21pub const STATE_VISIBILITY_SIGNING_PAYLOAD_VERSION_TAG: &[u8] = b"hd-statevis-v1\x00";
25
26#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
28pub struct StateVisibility {
29 pub state: ChangeId,
31 pub tier: VisibilityTier,
33 #[serde(default)]
38 pub embargo_until: Option<DateTime<Utc>>,
39 pub declarer: Principal,
41 pub declared_at: DateTime<Utc>,
44 #[serde(default)]
48 pub signature: Option<StateSignature>,
49 #[serde(default)]
53 pub supersedes: Option<ContentHash>,
54}
55
56impl StateVisibility {
57 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 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 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#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
128pub struct StateVisibilityBlob {
129 pub format_version: u8,
130 pub records: Vec<StateVisibility>,
131}
132
133versioned_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 pub fn has_record(&self) -> bool {
156 !self.records.is_empty()
157 }
158
159 pub fn latest(&self) -> Result<Option<&StateVisibility>, StateVisibilityError> {
183 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 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#[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 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 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 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 let head_tier = VisibilityTier::TeamScoped {
333 team_id: "infra".into(),
334 };
335 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 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 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 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 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}