Skip to main content

areev_loop/analyzers/
retention_sweep.rs

1//! Retention sweep (T0): grains older than a declared maximum age.
2//!
3//! The *governed* half of storage limitation (GDPR Art. 5(1)(e)). The host
4//! can enforce retention directly on a cron (`areev retention sweep`); this
5//! analyzer instead routes the same deletion through the review queue, so
6//! retention inherits BECAUSE, separation of duties, and the audit chain —
7//! the product's own governance story applied to compliance.
8//!
9//! **Why single-grain FORGETs rather than one PURGE.** A `PURGE OLDER THAN`
10//! line has no audited execution path through the substrate (it is refused
11//! there by design — bulk erasure from a proposal is exactly the shape the
12//! destructive gate exists to stop). Emitting one FORGET per grain means the
13//! reviewer sees precisely what disappears, every removal gets its own
14//! Tier-2 audit record, and the batch is stamped destructive so it needs
15//! admin + `allow_destructive` to apply.
16//!
17//! Off by default and never auto-appliable: a deletion policy is a decision
18//! the deployment makes, not one an analyzer infers.
19
20use crate::analyzer::{AnalyzeCtx, Analyzer};
21use crate::cal;
22use crate::error::Result;
23use crate::manifest::*;
24use crate::model::{ActionKind, Severity};
25use crate::recommendation::{Proposal, RecDraft, Summary};
26use serde_json::{json, Map};
27
28/// Upper bound on grains named by one proposal, so a first run over a large
29/// memory produces a reviewable unit rather than a 50k-line batch. Anything
30/// beyond it is reported in the summary — never silently dropped — and the
31/// next run picks it up.
32const DEFAULT_MAX_GRAINS: i64 = 500;
33
34/// Namespaces a retention proposal must never name: the loop's own state and
35/// the OMS `agent:*` reserved space (`agent:authz` grants + audit records,
36/// `agent:identity`, …). Substrate-agnostic — the engine cannot know which
37/// concrete namespaces a host reserves, but these two shapes are spec-level.
38fn is_reserved_ns(ns: &str) -> bool {
39    ns == crate::LOOP_NS || ns.starts_with("agent:")
40}
41
42pub struct RetentionSweep {
43    manifest: AnalyzerManifest,
44}
45
46impl RetentionSweep {
47    pub fn new() -> Self {
48        RetentionSweep {
49            manifest: AnalyzerManifest {
50                id: "loop.retention_sweep/1".into(),
51                title: "Retention sweep".into(),
52                description: "Proposes tombstoning grains past a declared retention age \
53                              (storage limitation), through the review queue."
54                    .into(),
55                tier: Tier::T0,
56                cadence: CadenceClass::Slow,
57                requires: vec![],
58                target_classes: vec![TargetClass::Memory],
59                // Deletion never auto-applies, whatever the policy grants.
60                auto_apply: AutoApplyClass::Never,
61                trust_class: TrustClass::Builtin,
62                params: vec![
63                    ParamSpec::Int {
64                        name: "max_age_days".into(),
65                        // 0 = no policy declared → the analyzer proposes
66                        // nothing. Retention must be stated, never inferred.
67                        default: 0,
68                        min: 0,
69                        max: 36_500,
70                        description: "Erase grains older than this many days. 0 disables \
71                                      the analyzer (no retention policy declared)."
72                            .into(),
73                    },
74                    ParamSpec::Str {
75                        name: "grain_type".into(),
76                        default: String::new(),
77                        max_len: 32,
78                        description: "Restrict the sweep to one grain type (e.g. \"event\"). \
79                                      Empty sweeps every type."
80                            .into(),
81                    },
82                    ParamSpec::Int {
83                        name: "max_grains".into(),
84                        default: DEFAULT_MAX_GRAINS,
85                        min: 1,
86                        max: 5_000,
87                        description: "Maximum grains named by one proposal.".into(),
88                    },
89                ],
90                default_on: false,
91            },
92        }
93    }
94}
95
96impl Default for RetentionSweep {
97    fn default() -> Self {
98        Self::new()
99    }
100}
101
102impl Analyzer for RetentionSweep {
103    fn manifest(&self) -> &AnalyzerManifest {
104        &self.manifest
105    }
106
107    fn analyze(&self, ctx: &AnalyzeCtx) -> Result<Vec<RecDraft>> {
108        let max_age_days = ctx.params().get_int("max_age_days");
109        if max_age_days <= 0 {
110            return Ok(Vec::new()); // no policy declared
111        }
112        let cutoff = ctx.now_ms() - max_age_days * 86_400_000;
113        let want_type = ctx.params().get_str("grain_type").trim().to_string();
114        let max_grains = ctx.params().get_int("max_grains").max(1) as usize;
115
116        // Facts and observations are the retention-bearing types the reader
117        // exposes; an explicit grain_type narrows to one of them.
118        let mut grains = Vec::new();
119        if want_type.is_empty() || want_type == crate::model::grain_type::FACT {
120            grains.extend(ctx.facts()?);
121        }
122        if want_type.is_empty() || want_type == crate::model::grain_type::OBSERVATION {
123            grains.extend(ctx.observations()?);
124        }
125
126        // Group by namespace so each proposal is one reviewable policy unit.
127        let mut by_ns: std::collections::BTreeMap<String, Vec<&crate::model::GrainRecord>> =
128            std::collections::BTreeMap::new();
129        for g in &grains {
130            // Reserved namespaces are the memory's own governance state —
131            // grants, audit records, identity — not user data under a
132            // retention policy. Tombstoning them locks principals out of the
133            // file and destroys the accountability record. A substrate
134            // SHOULD already withhold them; this is the second lock, because
135            // an analyzer that can propose erasing the audit trail is the
136            // one place a reader bug becomes unrecoverable.
137            if is_reserved_ns(&g.namespace) {
138                continue;
139            }
140            // created_at_ms 0 means "unknown" on this reader — never treat a
141            // missing timestamp as infinitely old.
142            if g.created_at_ms > 0 && g.created_at_ms < cutoff {
143                by_ns.entry(g.namespace.clone()).or_default().push(g);
144            }
145        }
146
147        let mut drafts = Vec::new();
148        for (ns, mut over_age) in by_ns {
149            // Oldest first, so a capped batch removes the most overdue.
150            over_age.sort_by_key(|g| (g.created_at_ms, g.hash.clone()));
151            let total = over_age.len();
152            let named = over_age.iter().take(max_grains).collect::<Vec<_>>();
153            let lines: Vec<String> = named.iter().map(|g| cal::forget(&g.hash)).collect();
154            let oldest_days = named
155                .first()
156                .map(|g| (ctx.now_ms() - g.created_at_ms) / 86_400_000)
157                .unwrap_or(0);
158
159            let mut args = Map::new();
160            args.insert("namespace".into(), json!(ns));
161            args.insert("count".into(), json!(named.len()));
162            args.insert("max_age_days".into(), json!(max_age_days));
163            args.insert("oldest_days".into(), json!(oldest_days));
164            // Truncation is stated, never silent: the reviewer must be able
165            // to tell "this is all of it" from "this is the first 500".
166            args.insert("remaining".into(), json!(total.saturating_sub(named.len())));
167
168            drafts.push(
169                RecDraft::new(
170                    format!("host:retention/{ns}"),
171                    ActionKind::Expire,
172                    Summary::new("retention.overdue", args),
173                    Proposal::Cal { cal: cal::batch(&lines) },
174                )
175                .severity(Severity::Low)
176                .evidence(named.iter().map(|g| g.hash.clone()).collect()),
177            );
178        }
179        Ok(drafts)
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186    use crate::testkit::TestSubstrate;
187
188    const DAY: i64 = 86_400_000;
189
190    #[test]
191    fn proposes_only_grains_past_the_declared_age() {
192        let mut sub = TestSubstrate::new();
193        sub.add_fact_at("caller", "old", "note", "ancient", DAY);
194        sub.add_fact_at("caller", "recent", "note", "fresh", 95 * DAY);
195        let now = 100 * DAY;
196
197        // No policy declared → nothing proposed, whatever the data says.
198        assert!(sub.analyze(&RetentionSweep::new(), now).is_empty());
199
200        let drafts = sub.analyze_with(
201            &RetentionSweep::new(),
202            now,
203            &[("max_age_days", serde_json::json!(30))],
204        );
205        assert_eq!(drafts.len(), 1, "one proposal per namespace");
206        let Proposal::Cal { cal } = &drafts[0].proposal else { panic!("expected CAL") };
207        assert_eq!(cal.lines().count(), 1, "only the over-age grain: {cal}");
208        assert!(cal.starts_with("FORGET "), "single-grain tombstones: {cal}");
209        assert_eq!(drafts[0].evidence.len(), 1);
210    }
211
212    #[test]
213    fn caps_the_batch_and_says_how_many_remain() {
214        let mut sub = TestSubstrate::new();
215        for i in 0..5 {
216            sub.add_fact_at("caller", &format!("s{i}"), "note", "old", DAY + i);
217        }
218        let drafts = sub.analyze_with(
219            &RetentionSweep::new(),
220            100 * DAY,
221            &[
222                ("max_age_days", serde_json::json!(30)),
223                ("max_grains", serde_json::json!(2)),
224            ],
225        );
226        assert_eq!(drafts.len(), 1);
227        let Proposal::Cal { cal } = &drafts[0].proposal else { panic!("expected CAL") };
228        assert_eq!(cal.lines().count(), 2, "batch is capped");
229        let rendered = drafts[0].summary.render();
230        assert!(rendered.contains('3'), "the remainder is stated: {rendered}");
231    }
232
233    #[test]
234    fn never_proposes_erasing_governance_state() {
235        let mut sub = TestSubstrate::new();
236        sub.add_fact_at("agent:authz", "user:bot", "mg:permits", "read ON *", DAY);
237        sub.add_fact_at("agent:harness", "run:r1", "mg:harness", "config", DAY);
238        sub.add_fact_at("caller", "old", "note", "ancient", DAY);
239        let drafts = sub.analyze_with(
240            &RetentionSweep::new(),
241            100 * DAY,
242            &[("max_age_days", serde_json::json!(30))],
243        );
244        assert_eq!(drafts.len(), 1, "only the user namespace: {drafts:?}");
245        let Proposal::Cal { cal } = &drafts[0].proposal else { panic!("expected CAL") };
246        assert_eq!(
247            cal.lines().count(),
248            1,
249            "reserved grant and harness grains are not named: {cal}"
250        );
251    }
252
253    #[test]
254    fn unknown_creation_time_is_never_treated_as_ancient() {
255        let mut sub = TestSubstrate::new();
256        sub.add_fact_at("caller", "notime", "note", "x", 0);
257        let drafts = sub.analyze_with(
258            &RetentionSweep::new(),
259            100 * DAY,
260            &[("max_age_days", serde_json::json!(1))],
261        );
262        assert!(drafts.is_empty(), "a missing timestamp must not mean 'delete me'");
263    }
264}