1use crate::metadata::{FrameKind, FrameMetadata};
9use crate::scope::{Scope, scope_matches};
10
11#[derive(Debug, Clone, Copy)]
17pub struct RetentionCandidate<'a> {
18 pub metadata: &'a FrameMetadata,
20 pub sequence: Option<u64>,
22 pub written_at_unix_ms: Option<u64>,
24 pub last_accessed_unix_ms: Option<u64>,
26 pub retention_label: Option<&'a str>,
28}
29
30impl<'a> RetentionCandidate<'a> {
31 #[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 #[must_use]
45 pub fn with_sequence(mut self, sequence: u64) -> Self {
46 self.sequence = Some(sequence);
47 self
48 }
49
50 #[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 #[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 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74pub enum RetentionDecision {
75 Keep,
77 Drop,
79 Defer,
81}
82
83#[derive(Debug, Clone, PartialEq, Eq)]
85pub enum RetentionRule {
86 KeepFrameKind(FrameKind),
88 KeepScope(Scope),
90 KeepRecent {
93 min_sequence: u64,
96 },
97 DropWrittenBefore {
99 older_than_unix_ms: u64,
102 },
103 DropLastAccessedBefore {
106 older_than_unix_ms: u64,
109 },
110 DropOutsideScope {
113 required_scope: Option<Scope>,
115 },
116 KeepLabel(String),
118 DropLabel(String),
120}
121
122impl RetentionRule {
123 #[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#[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 #[must_use]
232 pub fn new() -> Self {
233 Self::default()
234 }
235
236 #[must_use]
238 pub fn rule(mut self, rule: RetentionRule) -> Self {
239 self.rules.push(rule);
240 self
241 }
242
243 #[must_use]
245 pub fn default_decision(mut self, decision: RetentionDecision) -> Self {
246 self.default_decision = decision;
247 self
248 }
249
250 #[must_use]
252 pub fn keep_summaries(self) -> Self {
253 self.rule(RetentionRule::KeepFrameKind(FrameKind::CompactionSummary))
254 }
255
256 #[must_use]
258 pub fn keep_demoted_messages(self) -> Self {
259 self.rule(RetentionRule::KeepFrameKind(FrameKind::DemotedMessage))
260 }
261
262 #[must_use]
264 pub fn keep_scope(self, scope: impl Into<Scope>) -> Self {
265 self.rule(RetentionRule::KeepScope(scope.into()))
266 }
267
268 #[must_use]
270 pub fn keep_recent(self, min_sequence: u64) -> Self {
271 self.rule(RetentionRule::KeepRecent { min_sequence })
272 }
273
274 #[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 #[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 #[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 #[must_use]
297 pub fn keep_label(self, label: impl Into<String>) -> Self {
298 self.rule(RetentionRule::KeepLabel(label.into()))
299 }
300
301 #[must_use]
303 pub fn drop_label(self, label: impl Into<String>) -> Self {
304 self.rule(RetentionRule::DropLabel(label.into()))
305 }
306
307 #[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 #[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}