use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LawfulBasis {
Consent,
ContractPerformance,
LegalObligation,
LegitimateInterests,
Anonymous,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RetentionAction {
Pseudonymize,
HardDelete,
Archive,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RetentionTier {
Critical,
Standard,
Operational,
Anonymous,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RetentionPolicy {
pub name: String,
pub table: String,
pub pii_category: String,
pub tier: RetentionTier,
pub max_age_days: u32,
pub lawful_basis: LawfulBasis,
pub action: RetentionAction,
pub archive_then_delete_after_days: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RetentionRow {
pub id: String,
pub timestamp_ms: i64,
#[serde(default)]
pub legal_hold: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EvictionSet {
pub evict: Vec<String>,
pub retain: Vec<String>,
pub action: RetentionAction,
pub reason: String,
pub cutoff_ms: i64,
}
pub fn apply_retention(
rows: &[RetentionRow],
policy: &RetentionPolicy,
now_ms: i64,
) -> EvictionSet {
if policy.max_age_days == 0 || policy.tier == RetentionTier::Anonymous {
return EvictionSet {
evict: Vec::new(),
retain: rows.iter().map(|r| r.id.clone()).collect(),
action: policy.action.clone(),
reason: format!(
"Policy '{}' has max_age_days=0 or tier=anonymous — all rows retained.",
policy.name
),
cutoff_ms: 0,
};
}
let max_age_ms = (policy.max_age_days as i64) * 24 * 60 * 60 * 1000;
let cutoff_ms = now_ms - max_age_ms;
let mut evict = Vec::new();
let mut retain = Vec::new();
for row in rows {
if row.timestamp_ms == 0 {
retain.push(row.id.clone());
continue;
}
if row.legal_hold {
retain.push(row.id.clone());
continue;
}
if row.timestamp_ms < cutoff_ms {
evict.push(row.id.clone());
} else {
retain.push(row.id.clone());
}
}
EvictionSet {
action: policy.action.clone(),
reason: format!(
"Policy '{}': max_age_days={}, action={:?}, cutoff={}",
policy.name, policy.max_age_days, policy.action, cutoff_ms
),
cutoff_ms,
evict,
retain,
}
}
#[cfg(feature = "wasm")]
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn retention_apply_wasm(rows_json: &str, policy_json: &str, now_ms: f64) -> String {
let rows: Vec<RetentionRow> = match serde_json::from_str(rows_json) {
Ok(r) => r,
Err(e) => return format!("{{\"error\":\"rows parse error: {e}\"}}"),
};
let policy: RetentionPolicy = match serde_json::from_str(policy_json) {
Ok(p) => p,
Err(e) => return format!("{{\"error\":\"policy parse error: {e}\"}}"),
};
let result = apply_retention(&rows, &policy, now_ms as i64);
match serde_json::to_string(&result) {
Ok(s) => s,
Err(e) => format!("{{\"error\":\"serialize error: {e}\"}}"),
}
}
pub fn canonical_policies() -> Vec<RetentionPolicy> {
vec![
RetentionPolicy {
name: "sessions".to_string(),
table: "sessions".to_string(),
pii_category: "session_token".to_string(),
tier: RetentionTier::Critical,
max_age_days: 30,
lawful_basis: LawfulBasis::ContractPerformance,
action: RetentionAction::HardDelete,
archive_then_delete_after_days: None,
},
RetentionPolicy {
name: "audit_logs_hot".to_string(),
table: "audit_logs".to_string(),
pii_category: "actor_id_in_audit_chain".to_string(),
tier: RetentionTier::Operational,
max_age_days: 90,
lawful_basis: LawfulBasis::LegalObligation,
action: RetentionAction::Archive,
archive_then_delete_after_days: Some(2555), },
RetentionPolicy {
name: "api_keys".to_string(),
table: "api_keys".to_string(),
pii_category: "api_key_hash".to_string(),
tier: RetentionTier::Critical,
max_age_days: 365,
lawful_basis: LawfulBasis::ContractPerformance,
action: RetentionAction::HardDelete,
archive_then_delete_after_days: None,
},
RetentionPolicy {
name: "webhook_deliveries".to_string(),
table: "webhook_deliveries".to_string(),
pii_category: "webhook_payload".to_string(),
tier: RetentionTier::Standard,
max_age_days: 30,
lawful_basis: LawfulBasis::LegitimateInterests,
action: RetentionAction::HardDelete,
archive_then_delete_after_days: None,
},
RetentionPolicy {
name: "section_embeddings".to_string(),
table: "section_embeddings".to_string(),
pii_category: "vector_derived_from_content".to_string(),
tier: RetentionTier::Standard,
max_age_days: 90,
lawful_basis: LawfulBasis::LegitimateInterests,
action: RetentionAction::HardDelete,
archive_then_delete_after_days: None,
},
RetentionPolicy {
name: "agent_signature_nonces".to_string(),
table: "agent_signature_nonces".to_string(),
pii_category: "cryptographic_nonce".to_string(),
tier: RetentionTier::Standard,
max_age_days: 1,
lawful_basis: LawfulBasis::LegalObligation,
action: RetentionAction::HardDelete,
archive_then_delete_after_days: None,
},
RetentionPolicy {
name: "agent_inbox_messages".to_string(),
table: "agent_inbox_messages".to_string(),
pii_category: "agent_message_payload".to_string(),
tier: RetentionTier::Standard,
max_age_days: 7,
lawful_basis: LawfulBasis::LegitimateInterests,
action: RetentionAction::HardDelete,
archive_then_delete_after_days: None,
},
RetentionPolicy {
name: "usage_events".to_string(),
table: "usage_events".to_string(),
pii_category: "billing_usage_record".to_string(),
tier: RetentionTier::Operational,
max_age_days: 730, lawful_basis: LawfulBasis::LegalObligation,
action: RetentionAction::Archive,
archive_then_delete_after_days: Some(2555),
},
RetentionPolicy {
name: "usage_rollups".to_string(),
table: "usage_rollups".to_string(),
pii_category: "aggregated_metrics".to_string(),
tier: RetentionTier::Anonymous,
max_age_days: 0,
lawful_basis: LawfulBasis::Anonymous,
action: RetentionAction::Archive,
archive_then_delete_after_days: None,
},
]
}
#[cfg(test)]
mod tests {
use super::*;
fn make_policy(max_age_days: u32, action: RetentionAction) -> RetentionPolicy {
RetentionPolicy {
name: "test_policy".to_string(),
table: "test_table".to_string(),
pii_category: "test_pii".to_string(),
tier: RetentionTier::Standard,
max_age_days,
lawful_basis: LawfulBasis::LegitimateInterests,
action,
archive_then_delete_after_days: None,
}
}
const NOW_MS: i64 = 1_000_000_000_000;
#[test]
fn evicts_old_rows_and_retains_new() {
let policy = make_policy(30, RetentionAction::HardDelete);
let old_ts = NOW_MS - 31 * 24 * 60 * 60 * 1000; let new_ts = NOW_MS - 5 * 24 * 60 * 60 * 1000;
let rows = vec![
RetentionRow {
id: "old".to_string(),
timestamp_ms: old_ts,
legal_hold: false,
},
RetentionRow {
id: "new".to_string(),
timestamp_ms: new_ts,
legal_hold: false,
},
];
let result = apply_retention(&rows, &policy, NOW_MS);
assert_eq!(result.evict, vec!["old"]);
assert_eq!(result.retain, vec!["new"]);
assert_eq!(result.action, RetentionAction::HardDelete);
}
#[test]
fn legal_hold_rows_never_evicted() {
let policy = make_policy(30, RetentionAction::HardDelete);
let old_ts = NOW_MS - 100 * 24 * 60 * 60 * 1000; let rows = vec![RetentionRow {
id: "held".to_string(),
timestamp_ms: old_ts,
legal_hold: true,
}];
let result = apply_retention(&rows, &policy, NOW_MS);
assert!(result.evict.is_empty());
assert_eq!(result.retain, vec!["held"]);
}
#[test]
fn zero_timestamp_never_evicted() {
let policy = make_policy(30, RetentionAction::HardDelete);
let rows = vec![RetentionRow {
id: "immortal".to_string(),
timestamp_ms: 0,
legal_hold: false,
}];
let result = apply_retention(&rows, &policy, NOW_MS);
assert!(result.evict.is_empty());
assert_eq!(result.retain, vec!["immortal"]);
}
#[test]
fn zero_max_age_retains_all() {
let policy = make_policy(0, RetentionAction::HardDelete);
let old_ts = NOW_MS - 1000 * 24 * 60 * 60 * 1000; let rows = vec![RetentionRow {
id: "r1".to_string(),
timestamp_ms: old_ts,
legal_hold: false,
}];
let result = apply_retention(&rows, &policy, NOW_MS);
assert!(result.evict.is_empty());
assert_eq!(result.retain.len(), 1);
}
#[test]
fn anonymous_tier_retains_all() {
let mut policy = make_policy(90, RetentionAction::Archive);
policy.tier = RetentionTier::Anonymous;
let old_ts = NOW_MS - 200 * 24 * 60 * 60 * 1000;
let rows = vec![RetentionRow {
id: "anon".to_string(),
timestamp_ms: old_ts,
legal_hold: false,
}];
let result = apply_retention(&rows, &policy, NOW_MS);
assert!(result.evict.is_empty());
}
#[test]
fn pseudonymize_action_preserved_in_eviction_set() {
let policy = make_policy(90, RetentionAction::Pseudonymize);
let old_ts = NOW_MS - 100 * 24 * 60 * 60 * 1000;
let rows = vec![RetentionRow {
id: "audit_row".to_string(),
timestamp_ms: old_ts,
legal_hold: false,
}];
let result = apply_retention(&rows, &policy, NOW_MS);
assert_eq!(result.evict, vec!["audit_row"]);
assert_eq!(result.action, RetentionAction::Pseudonymize);
}
#[test]
fn canonical_policies_are_valid() {
let policies = canonical_policies();
assert!(!policies.is_empty());
for p in &policies {
if p.tier == RetentionTier::Critical || p.tier == RetentionTier::Standard {
assert!(
p.max_age_days > 0,
"policy '{}' is critical/standard but max_age_days=0",
p.name
);
}
}
let audit = policies.iter().find(|p| p.table == "audit_logs").unwrap();
assert!(
audit.action != RetentionAction::HardDelete,
"audit_logs must not use HardDelete"
);
}
#[test]
fn json_roundtrip() {
let policy = make_policy(30, RetentionAction::Archive);
let json = serde_json::to_string(&policy).unwrap();
let decoded: RetentionPolicy = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.name, policy.name);
assert_eq!(decoded.max_age_days, policy.max_age_days);
}
}