Skip to main content

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}