Skip to main content

rig_memory_policy/
retention.rs

1//! Deterministic retention decisions over backend-provided memory metadata.
2//!
3//! This module intentionally avoids storage APIs and wall-clock dependencies.
4//! Backends pass in frame metadata plus optional sequence/timestamp facts, and
5//! receive a pure keep/drop/defer decision they can apply however their store
6//! represents deletes, compaction, or archival tiers.
7
8use crate::metadata::{FrameKind, FrameMetadata};
9use crate::scope::{Scope, scope_matches};
10
11/// A backend-provided candidate for retention evaluation.
12///
13/// The candidate borrows the stable [`FrameMetadata`] envelope and carries
14/// optional facts supplied by the backend. Missing facts simply make rules that
15/// depend on them non-matching.
16#[derive(Debug, Clone, Copy)]
17pub struct RetentionCandidate<'a> {
18    /// Stable frame metadata decoded from the backend entry.
19    pub metadata: &'a FrameMetadata,
20    /// Backend-specific monotonic sequence number, where larger is newer.
21    pub sequence: Option<u64>,
22    /// Backend-supplied write timestamp in Unix milliseconds.
23    pub written_at_unix_ms: Option<u64>,
24    /// Backend-supplied last-access timestamp in Unix milliseconds.
25    pub last_accessed_unix_ms: Option<u64>,
26    /// Optional retention label stamped by the backend or host app.
27    pub retention_label: Option<&'a str>,
28}
29
30impl<'a> RetentionCandidate<'a> {
31    /// Construct a candidate from required frame metadata.
32    #[must_use]
33    pub fn new(metadata: &'a FrameMetadata) -> Self {
34        Self {
35            metadata,
36            sequence: None,
37            written_at_unix_ms: None,
38            last_accessed_unix_ms: None,
39            retention_label: None,
40        }
41    }
42
43    /// Attach a backend-specific monotonic sequence number.
44    #[must_use]
45    pub fn with_sequence(mut self, sequence: u64) -> Self {
46        self.sequence = Some(sequence);
47        self
48    }
49
50    /// Attach a backend-supplied write timestamp in Unix milliseconds.
51    #[must_use]
52    pub fn with_written_at_unix_ms(mut self, written_at_unix_ms: u64) -> Self {
53        self.written_at_unix_ms = Some(written_at_unix_ms);
54        self
55    }
56
57    /// Attach a backend-supplied last-access timestamp in Unix milliseconds.
58    #[must_use]
59    pub fn with_last_accessed_unix_ms(mut self, last_accessed_unix_ms: u64) -> Self {
60        self.last_accessed_unix_ms = Some(last_accessed_unix_ms);
61        self
62    }
63
64    /// Attach a host-defined retention label.
65    #[must_use]
66    pub fn with_retention_label(mut self, retention_label: &'a str) -> Self {
67        self.retention_label = Some(retention_label);
68        self
69    }
70}
71
72/// Result of evaluating one candidate against a retention policy.
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74pub enum RetentionDecision {
75    /// The backend should retain the candidate.
76    Keep,
77    /// The backend may delete, compact away, or archive the candidate.
78    Drop,
79    /// The policy has no opinion; the backend should apply its default.
80    Defer,
81}
82
83/// One deterministic retention rule.
84#[derive(Debug, Clone, PartialEq, Eq)]
85pub enum RetentionRule {
86    /// Keep candidates whose frame kind matches `kind`.
87    KeepFrameKind(FrameKind),
88    /// Keep candidates whose normalized scope exactly matches this scope.
89    KeepScope(Scope),
90    /// Keep candidates at or above `min_sequence`, where larger sequence
91    /// values are newer.
92    KeepRecent {
93        /// Minimum sequence number to keep, where larger sequence values are
94        /// newer.
95        min_sequence: u64,
96    },
97    /// Drop candidates whose write timestamp is older than `older_than_unix_ms`.
98    DropWrittenBefore {
99        /// Exclusive Unix-millisecond threshold; candidates written before
100        /// this value are dropped.
101        older_than_unix_ms: u64,
102    },
103    /// Drop candidates whose last-access timestamp is older than
104    /// `older_than_unix_ms`.
105    DropLastAccessedBefore {
106        /// Exclusive Unix-millisecond threshold; candidates last accessed
107        /// before this value are dropped.
108        older_than_unix_ms: u64,
109    },
110    /// Drop candidates whose normalized scope does not exactly match
111    /// `required_scope`. `None` requires an unscoped candidate.
112    DropOutsideScope {
113        /// Required exact scope. `None` requires candidates to be unscoped.
114        required_scope: Option<Scope>,
115    },
116    /// Keep candidates with a host-defined retention label.
117    KeepLabel(String),
118    /// Drop candidates with a host-defined retention label.
119    DropLabel(String),
120}
121
122impl RetentionRule {
123    /// Evaluate this rule against a single candidate.
124    #[must_use]
125    pub fn evaluate(&self, candidate: RetentionCandidate<'_>) -> RetentionDecision {
126        match self {
127            Self::KeepFrameKind(kind) if candidate.metadata.kind == *kind => {
128                RetentionDecision::Keep
129            }
130            Self::KeepScope(scope) if scope.matches(candidate.metadata.scope.as_deref()) => {
131                RetentionDecision::Keep
132            }
133            Self::KeepRecent { min_sequence } => candidate
134                .sequence
135                .map(|sequence| {
136                    if sequence >= *min_sequence {
137                        RetentionDecision::Keep
138                    } else {
139                        RetentionDecision::Defer
140                    }
141                })
142                .unwrap_or(RetentionDecision::Defer),
143            Self::DropWrittenBefore { older_than_unix_ms } => candidate
144                .written_at_unix_ms
145                .map(|written_at| {
146                    if written_at < *older_than_unix_ms {
147                        RetentionDecision::Drop
148                    } else {
149                        RetentionDecision::Defer
150                    }
151                })
152                .unwrap_or(RetentionDecision::Defer),
153            Self::DropLastAccessedBefore { older_than_unix_ms } => candidate
154                .last_accessed_unix_ms
155                .map(|last_accessed| {
156                    if last_accessed < *older_than_unix_ms {
157                        RetentionDecision::Drop
158                    } else {
159                        RetentionDecision::Defer
160                    }
161                })
162                .unwrap_or(RetentionDecision::Defer),
163            Self::DropOutsideScope { required_scope } => {
164                let required = required_scope.as_ref().map(Scope::as_str);
165                if scope_matches(required, candidate.metadata.scope.as_deref()) {
166                    RetentionDecision::Defer
167                } else {
168                    RetentionDecision::Drop
169                }
170            }
171            Self::KeepLabel(label) if candidate.retention_label == Some(label.as_str()) => {
172                RetentionDecision::Keep
173            }
174            Self::DropLabel(label) if candidate.retention_label == Some(label.as_str()) => {
175                RetentionDecision::Drop
176            }
177            _ => RetentionDecision::Defer,
178        }
179    }
180}
181
182/// Ordered set of retention rules.
183///
184/// Rules are evaluated in insertion order. The first [`RetentionDecision::Keep`]
185/// or [`RetentionDecision::Drop`] wins; [`RetentionDecision::Defer`] falls
186/// through to later rules and finally to `default_decision`.
187///
188/// # Example
189///
190/// ```
191/// use rig_memory_policy::{
192///     FrameKind, FrameMetadata, RetentionCandidate, RetentionDecision,
193///     RetentionPolicy,
194/// };
195///
196/// let metadata = FrameMetadata {
197///     schema_version: 1,
198///     kind: FrameKind::CompactionSummary,
199///     conversation_id: "conv-1".into(),
200///     chat_role: "assistant".into(),
201///     dedup_key: "abc".into(),
202///     scope: Some("tenant-a".into()),
203/// };
204/// let policy = RetentionPolicy::new()
205///     .keep_summaries()
206///     .drop_written_before(1_700_000_000_000)
207///     .default_decision(RetentionDecision::Drop);
208///
209/// let decision = policy.evaluate(
210///     RetentionCandidate::new(&metadata).with_written_at_unix_ms(1_600_000_000_000),
211/// );
212/// assert_eq!(decision, RetentionDecision::Keep);
213/// ```
214#[derive(Debug, Clone, PartialEq, Eq)]
215pub struct RetentionPolicy {
216    rules: Vec<RetentionRule>,
217    default_decision: RetentionDecision,
218}
219
220impl Default for RetentionPolicy {
221    fn default() -> Self {
222        Self {
223            rules: Vec::new(),
224            default_decision: RetentionDecision::Defer,
225        }
226    }
227}
228
229impl RetentionPolicy {
230    /// Construct an empty policy with [`RetentionDecision::Defer`] as default.
231    #[must_use]
232    pub fn new() -> Self {
233        Self::default()
234    }
235
236    /// Add a custom rule to the end of the policy.
237    #[must_use]
238    pub fn rule(mut self, rule: RetentionRule) -> Self {
239        self.rules.push(rule);
240        self
241    }
242
243    /// Set the decision returned when no rule matches.
244    #[must_use]
245    pub fn default_decision(mut self, decision: RetentionDecision) -> Self {
246        self.default_decision = decision;
247        self
248    }
249
250    /// Keep all compaction summaries.
251    #[must_use]
252    pub fn keep_summaries(self) -> Self {
253        self.rule(RetentionRule::KeepFrameKind(FrameKind::CompactionSummary))
254    }
255
256    /// Keep all demoted messages.
257    #[must_use]
258    pub fn keep_demoted_messages(self) -> Self {
259        self.rule(RetentionRule::KeepFrameKind(FrameKind::DemotedMessage))
260    }
261
262    /// Keep candidates in the required exact scope.
263    #[must_use]
264    pub fn keep_scope(self, scope: impl Into<Scope>) -> Self {
265        self.rule(RetentionRule::KeepScope(scope.into()))
266    }
267
268    /// Keep candidates at or above `min_sequence`.
269    #[must_use]
270    pub fn keep_recent(self, min_sequence: u64) -> Self {
271        self.rule(RetentionRule::KeepRecent { min_sequence })
272    }
273
274    /// Drop candidates written before `older_than_unix_ms`.
275    #[must_use]
276    pub fn drop_written_before(self, older_than_unix_ms: u64) -> Self {
277        self.rule(RetentionRule::DropWrittenBefore { older_than_unix_ms })
278    }
279
280    /// Drop candidates last accessed before `older_than_unix_ms`.
281    #[must_use]
282    pub fn drop_last_accessed_before(self, older_than_unix_ms: u64) -> Self {
283        self.rule(RetentionRule::DropLastAccessedBefore { older_than_unix_ms })
284    }
285
286    /// Drop candidates outside an exact scope. `None` keeps only unscoped
287    /// candidates from matching this rule.
288    #[must_use]
289    pub fn drop_outside_scope(self, required_scope: Option<impl Into<Scope>>) -> Self {
290        self.rule(RetentionRule::DropOutsideScope {
291            required_scope: required_scope.map(Into::into),
292        })
293    }
294
295    /// Keep candidates with the provided host-defined retention label.
296    #[must_use]
297    pub fn keep_label(self, label: impl Into<String>) -> Self {
298        self.rule(RetentionRule::KeepLabel(label.into()))
299    }
300
301    /// Drop candidates with the provided host-defined retention label.
302    #[must_use]
303    pub fn drop_label(self, label: impl Into<String>) -> Self {
304        self.rule(RetentionRule::DropLabel(label.into()))
305    }
306
307    /// Evaluate one candidate against this policy.
308    #[must_use]
309    pub fn evaluate(&self, candidate: RetentionCandidate<'_>) -> RetentionDecision {
310        self.rules
311            .iter()
312            .map(|rule| rule.evaluate(candidate))
313            .find(|decision| *decision != RetentionDecision::Defer)
314            .unwrap_or(self.default_decision)
315    }
316
317    /// Return the ordered rules in this policy.
318    #[must_use]
319    pub fn rules(&self) -> &[RetentionRule] {
320        &self.rules
321    }
322}
323
324#[cfg(test)]
325#[allow(clippy::unwrap_used, clippy::panic, clippy::indexing_slicing)]
326mod tests {
327    use super::*;
328
329    fn metadata(kind: FrameKind, scope: Option<&str>) -> FrameMetadata {
330        FrameMetadata {
331            schema_version: 1,
332            kind,
333            conversation_id: "conv".to_string(),
334            chat_role: "assistant".to_string(),
335            dedup_key: "key".to_string(),
336            scope: scope.map(str::to_string),
337        }
338    }
339
340    #[test]
341    fn keep_rule_wins_before_later_drop_rule() {
342        let metadata = metadata(FrameKind::CompactionSummary, Some("tenant-a"));
343        let policy = RetentionPolicy::new()
344            .keep_summaries()
345            .drop_written_before(200);
346        let candidate = RetentionCandidate::new(&metadata).with_written_at_unix_ms(100);
347        assert_eq!(policy.evaluate(candidate), RetentionDecision::Keep);
348    }
349
350    #[test]
351    fn drop_rule_wins_before_later_keep_rule() {
352        let metadata = metadata(FrameKind::CompactionSummary, Some("tenant-a"));
353        let policy = RetentionPolicy::new()
354            .drop_written_before(200)
355            .keep_summaries();
356        let candidate = RetentionCandidate::new(&metadata).with_written_at_unix_ms(100);
357        assert_eq!(policy.evaluate(candidate), RetentionDecision::Drop);
358    }
359
360    #[test]
361    fn recent_rule_keeps_at_or_above_min_sequence() {
362        let metadata = metadata(FrameKind::DemotedMessage, Some("tenant-a"));
363        let policy = RetentionPolicy::new()
364            .keep_recent(10)
365            .default_decision(RetentionDecision::Drop);
366        assert_eq!(
367            policy.evaluate(RetentionCandidate::new(&metadata).with_sequence(10)),
368            RetentionDecision::Keep
369        );
370        assert_eq!(
371            policy.evaluate(RetentionCandidate::new(&metadata).with_sequence(9)),
372            RetentionDecision::Drop
373        );
374    }
375
376    #[test]
377    fn ttl_like_rule_drops_old_written_frames() {
378        let metadata = metadata(FrameKind::DemotedMessage, None);
379        let policy = RetentionPolicy::new().drop_written_before(1_000);
380        assert_eq!(
381            policy.evaluate(RetentionCandidate::new(&metadata).with_written_at_unix_ms(999)),
382            RetentionDecision::Drop
383        );
384        assert_eq!(
385            policy.evaluate(RetentionCandidate::new(&metadata).with_written_at_unix_ms(1_000)),
386            RetentionDecision::Defer
387        );
388    }
389
390    #[test]
391    fn missing_optional_fields_do_not_match_field_dependent_rules() {
392        let metadata = metadata(FrameKind::DemotedMessage, None);
393        let policy = RetentionPolicy::new()
394            .keep_recent(10)
395            .drop_written_before(1_000)
396            .drop_last_accessed_before(1_000);
397        assert_eq!(
398            policy.evaluate(RetentionCandidate::new(&metadata)),
399            RetentionDecision::Defer
400        );
401    }
402
403    #[test]
404    fn scope_guard_drops_candidates_outside_exact_scope() {
405        let inside = metadata(FrameKind::DemotedMessage, Some("tenant-a"));
406        let outside = metadata(FrameKind::DemotedMessage, Some("tenant-b"));
407        let unscoped = metadata(FrameKind::DemotedMessage, None);
408        let policy = RetentionPolicy::new().drop_outside_scope(Some("tenant-a"));
409
410        assert_eq!(
411            policy.evaluate(RetentionCandidate::new(&inside)),
412            RetentionDecision::Defer
413        );
414        assert_eq!(
415            policy.evaluate(RetentionCandidate::new(&outside)),
416            RetentionDecision::Drop
417        );
418        assert_eq!(
419            policy.evaluate(RetentionCandidate::new(&unscoped)),
420            RetentionDecision::Drop
421        );
422    }
423
424    #[test]
425    fn label_rules_are_string_backed() {
426        let metadata = metadata(FrameKind::DemotedMessage, None);
427        let policy = RetentionPolicy::new().keep_label("legal_hold");
428        assert_eq!(
429            policy.evaluate(RetentionCandidate::new(&metadata).with_retention_label("legal_hold")),
430            RetentionDecision::Keep
431        );
432        assert_eq!(
433            policy.evaluate(RetentionCandidate::new(&metadata).with_retention_label("ephemeral")),
434            RetentionDecision::Defer
435        );
436    }
437}