nodedb_types/config/retention.rs
1// SPDX-License-Identifier: Apache-2.0
2
3//! Cross-engine bitemporal retention configuration.
4//!
5//! Bitemporal collections split "retention" along two independent axes:
6//!
7//! - `data_retain_ms` — how long the *current* logical row stays queryable
8//! in live reads (the "as of now" view). Superseded by normal
9//! engine-specific retention when the collection is not bitemporal.
10//! - `audit_retain_ms` — how long *superseded versions* of a row are
11//! preserved for historical / audit-time queries (the "as of then" view).
12//!
13//! The minimum audit retention is a floor enforced by policy — some
14//! deployments (regulated, GDPR-on-paper, SOC2) require audit history to
15//! survive beyond the default so operators can't silently configure it
16//! below the compliance floor.
17//!
18//! Lives in `nodedb-types` because multiple engines (Columnar, Array,
19//! EdgeStore, DocumentStrict) compose it into their engine-specific
20//! config. Engine code consults the two axes separately: data purge
21//! deletes the live row when `data_retain_ms` elapses; audit purge
22//! deletes superseded versions when `audit_retain_ms` elapses.
23
24use serde::{Deserialize, Serialize};
25
26/// Error validating a [`BitemporalRetention`].
27#[derive(Debug, thiserror::Error)]
28#[error("bitemporal retention: {field} — {reason}")]
29pub struct RetentionValidationError {
30 pub field: String,
31 pub reason: String,
32}
33
34/// Per-collection bitemporal retention policy.
35///
36/// Two independent axes: data (live rows) and audit (superseded
37/// versions). Zero on either axis means "retain forever".
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
39pub struct BitemporalRetention {
40 /// Retention for currently-live rows (versions where
41 /// `_ts_valid_until == MAX`). `0` = retain forever.
42 pub data_retain_ms: u64,
43
44 /// Retention for superseded versions (versions where
45 /// `_ts_valid_until < MAX`). `0` = retain forever.
46 pub audit_retain_ms: u64,
47
48 /// Policy floor for `audit_retain_ms`. Operators cannot configure
49 /// `audit_retain_ms` below this value. `0` = no floor.
50 pub minimum_audit_retain_ms: u64,
51}
52
53impl BitemporalRetention {
54 /// Retain everything forever. Sensible conservative default for
55 /// bitemporal collections where operator intent is unstated.
56 pub const fn retain_forever() -> Self {
57 Self {
58 data_retain_ms: 0,
59 audit_retain_ms: 0,
60 minimum_audit_retain_ms: 0,
61 }
62 }
63
64 /// Validate axis consistency and policy floor.
65 pub fn validate(&self) -> Result<(), RetentionValidationError> {
66 if self.minimum_audit_retain_ms > 0
67 && self.audit_retain_ms > 0
68 && self.audit_retain_ms < self.minimum_audit_retain_ms
69 {
70 return Err(RetentionValidationError {
71 field: "audit_retain_ms".into(),
72 reason: format!(
73 "{} is below minimum_audit_retain_ms ({})",
74 self.audit_retain_ms, self.minimum_audit_retain_ms
75 ),
76 });
77 }
78 Ok(())
79 }
80}
81
82impl Default for BitemporalRetention {
83 fn default() -> Self {
84 Self::retain_forever()
85 }
86}
87
88#[cfg(test)]
89mod tests {
90 use super::*;
91
92 #[test]
93 fn retain_forever_validates() {
94 assert!(BitemporalRetention::retain_forever().validate().is_ok());
95 }
96
97 #[test]
98 fn audit_below_floor_rejected() {
99 let r = BitemporalRetention {
100 data_retain_ms: 0,
101 audit_retain_ms: 60_000,
102 minimum_audit_retain_ms: 120_000,
103 };
104 let err = r.validate().expect_err("must reject");
105 assert_eq!(err.field, "audit_retain_ms");
106 }
107
108 #[test]
109 fn audit_at_or_above_floor_ok() {
110 let r = BitemporalRetention {
111 data_retain_ms: 0,
112 audit_retain_ms: 120_000,
113 minimum_audit_retain_ms: 120_000,
114 };
115 assert!(r.validate().is_ok());
116 }
117
118 #[test]
119 fn zero_audit_ignores_floor() {
120 // audit_retain_ms == 0 means "retain forever" — floor does not apply.
121 let r = BitemporalRetention {
122 data_retain_ms: 0,
123 audit_retain_ms: 0,
124 minimum_audit_retain_ms: 120_000,
125 };
126 assert!(r.validate().is_ok());
127 }
128
129 #[test]
130 fn data_and_audit_are_independent() {
131 let r = BitemporalRetention {
132 data_retain_ms: 30_000,
133 audit_retain_ms: 300_000,
134 minimum_audit_retain_ms: 0,
135 };
136 assert!(r.validate().is_ok());
137 }
138}