Skip to content

Commit 1937199

Browse files
committed
feat(nodedb-types): add bitemporal audit-retention config types
Add `BitemporalRetention` (per-collection policy: engine tag, audit retain duration, enforcement mode) and `BitemporalTuning` (operator- tunable tick interval for the enforcement loop) to `nodedb-types`. Expose both through `config::retention` and `config::tuning::bitemporal` sub-modules, and wire `TuningConfig::bitemporal_retention_tick()` so the Event Plane can read the cadence without depending on the full config hierarchy.
1 parent f95e995 commit 1937199

4 files changed

Lines changed: 213 additions & 0 deletions

File tree

nodedb-types/src/config/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
pub mod retention;
12
pub mod tuning;
23

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

nodedb-types/src/config/tuning/mod.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1+
mod bitemporal;
12
mod data_plane;
23
mod engines;
34
mod memory;
45
mod network;
56
mod scheduler;
67
mod shutdown;
78

9+
pub use bitemporal::BitemporalTuning;
810
pub use data_plane::{DataPlaneTuning, QueryTuning};
911
pub use engines::{
1012
DEFAULT_MAX_DEPTH, DEFAULT_MAX_VISITED, GraphTuning, KvTuning, SparseTuning, TimeseriesToning,
@@ -51,6 +53,17 @@ pub struct TuningConfig {
5153
pub scheduler: SchedulerTuning,
5254
#[serde(default)]
5355
pub shutdown: ShutdownTuning,
56+
#[serde(default)]
57+
pub bitemporal: BitemporalTuning,
58+
}
59+
60+
impl TuningConfig {
61+
/// Tick interval for the bitemporal audit-retention enforcement loop.
62+
/// Returns `None` when the operator wants the loop default; returns
63+
/// `Some(d)` when `[tuning.bitemporal] tick_interval_secs` is set.
64+
pub fn bitemporal_retention_tick(&self) -> Option<std::time::Duration> {
65+
Some(self.bitemporal.tick_interval())
66+
}
5467
}
5568

5669
#[cfg(test)]

0 commit comments

Comments
 (0)