Skip to main content

heddle_object_model/object/thread_replication/metadata/
retention.rs

1//! Material lifetime, independent of audience and replication destination.
2use serde::{Deserialize, Serialize};
3
4use super::super::invalid;
5use crate::error::Result;
6
7#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
8#[serde(tag = "mode", content = "seconds", rename_all = "snake_case")]
9pub enum MaterialRetention {
10    Discard,
11    Bounded(u64),
12    Retain,
13}
14
15impl MaterialRetention {
16    /// Concurrent policies restrict lifetime; they never extend one another.
17    /// The same rule applies to Thread policy and a material-specific limit.
18    pub fn intersection(self, other: Self) -> Result<Self> {
19        self.validate()?;
20        other.validate()?;
21        Ok(match (self, other) {
22            (Self::Discard, _) | (_, Self::Discard) => Self::Discard,
23            (Self::Retain, value) | (value, Self::Retain) => value,
24            (Self::Bounded(left), Self::Bounded(right)) => Self::Bounded(left.min(right)),
25        })
26    }
27
28    /// Resolve from the immutable original retention timestamp, in Unix seconds.
29    /// Callers persist this deadline; retry or delivery time must not replace
30    /// the original timestamp. `None` denotes explicitly unbounded retention.
31    pub fn deadline(self, original_unix_seconds: i64) -> Result<Option<i64>> {
32        self.validate()?;
33        match self {
34            Self::Discard => Ok(Some(original_unix_seconds)),
35            Self::Retain => Ok(None),
36            Self::Bounded(seconds) => {
37                let seconds = i64::try_from(seconds)
38                    .map_err(|_| invalid("Thread retention duration overflows timestamp"))?;
39                original_unix_seconds
40                    .checked_add(seconds)
41                    .map(Some)
42                    .ok_or_else(|| invalid("Thread retention deadline overflows timestamp"))
43            }
44        }
45    }
46
47    pub fn validate(self) -> Result<()> {
48        if let Self::Bounded(seconds) = self
49            && (seconds == 0 || seconds > (i64::MAX / 1000) as u64)
50        {
51            return Err(invalid("invalid bounded Thread retention duration"));
52        }
53        Ok(())
54    }
55}
56
57#[cfg(test)]
58mod tests {
59    use super::MaterialRetention::{self, Bounded, Discard, Retain};
60
61    #[test]
62    fn concurrent_limits_are_order_independent_and_never_extend_lifetime() {
63        let policies = [Discard, Bounded(10), Bounded(60), Retain];
64        for left in policies {
65            for right in policies {
66                let resolved = left.intersection(right).expect("valid policies");
67                assert_eq!(resolved, right.intersection(left).expect("valid policies"));
68                for original in [100, 200] {
69                    let deadline = resolved.deadline(original).expect("bounded timestamp");
70                    for candidate in [left, right] {
71                        if let Some(limit) = candidate.deadline(original).expect("valid deadline") {
72                            assert!(deadline.is_some_and(|value| value <= limit));
73                        }
74                    }
75                }
76            }
77        }
78        assert_eq!(Retain.intersection(Retain).expect("valid policies"), Retain);
79    }
80
81    #[test]
82    fn deadlines_reject_invalid_or_overflowing_lifetimes() {
83        assert!(Bounded(0).deadline(100).is_err());
84        assert!(Bounded(1).deadline(i64::MAX).is_err());
85        assert!(
86            MaterialRetention::Bounded(u64::MAX)
87                .intersection(Discard)
88                .is_err()
89        );
90        assert_eq!(
91            Bounded(10).deadline(100).expect("valid deadline"),
92            Some(110)
93        );
94        assert_eq!(Discard.deadline(100).expect("discard deadline"), Some(100));
95    }
96}
97
98#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
99#[serde(deny_unknown_fields)]
100pub struct RetentionPolicy {
101    pub source: MaterialRetention,
102    pub collaboration: MaterialRetention,
103    pub evidence: MaterialRetention,
104    pub scrubbed_timeline: MaterialRetention,
105    pub raw_transcripts: MaterialRetention,
106}
107
108impl RetentionPolicy {
109    pub fn validate(&self) -> Result<()> {
110        for policy in [
111            self.source,
112            self.collaboration,
113            self.evidence,
114            self.scrubbed_timeline,
115            self.raw_transcripts,
116        ] {
117            policy.validate()?;
118        }
119        if self.raw_transcripts == MaterialRetention::Retain {
120            return Err(invalid(
121                "raw Thread retention requires an explicit bounded lifetime",
122            ));
123        }
124        Ok(())
125    }
126}