1use 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
28const DEFAULT_MAX_GRAINS: i64 = 500;
33
34fn 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 auto_apply: AutoApplyClass::Never,
61 trust_class: TrustClass::Builtin,
62 params: vec![
63 ParamSpec::Int {
64 name: "max_age_days".into(),
65 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()); }
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 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 let mut by_ns: std::collections::BTreeMap<String, Vec<&crate::model::GrainRecord>> =
128 std::collections::BTreeMap::new();
129 for g in &grains {
130 if is_reserved_ns(&g.namespace) {
138 continue;
139 }
140 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 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 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 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}