Skip to main content

wm_tools/expansion/
telemetry_tools.rs

1//! Edge-galaxy telemetry tools — typed records, hourly rollups, retention.
2//!
3//! Thread 4 (2026-09-13): the `telemetry` galaxy is evidence, not cognition
4//! (excluded from default recall/dream/consolidation). These tools give the
5//! producers a typed contract and the operators a retention path:
6//!
7//! - `telemetry.record` — validate + store one window/rollup record.
8//! - `telemetry.rollup` — aggregate completed hours into `telemetry.rollup`
9//!   memories (deterministic content, so re-runs deduplicate).
10//! - `telemetry.prune`  — destructive retention: delete windows/rollups older
11//!   than their horizons (pipeline requires `confirm: true`).
12//! - `telemetry.retention` — read-only planner: per-tier counts/ages/eligible
13//!   inventory under the same horizons as prune; never gated.
14//!
15//! Policy target (see `docs/EDGE_GALAXY_TELEMETRY.md`): ring 5 min / windows
16//! 7 d / hourly rollups 90 d. This module implements the window + rollup
17//! halves of that policy; the producer's local ring is Lakshmi's concern.
18
19#![forbid(unsafe_code)]
20
21use std::collections::BTreeMap;
22use std::sync::Arc;
23
24use async_trait::async_trait;
25use chrono::{DateTime, Duration as ChronoDuration, Timelike, Utc};
26use serde_json::{Value, json};
27use wm_core::{Context, CoreError, EffectRow, Galaxy, Gana, Resource, Tool, ToolStats};
28use wm_memory::Memory;
29use wm_memory::MemoryStore;
30use wm_memory::search::SearchEngine;
31
32use super::common;
33
34const TAG_WINDOW: &str = "window";
35const TAG_ROLLUP: &str = "rollup";
36const TAG_OBSERVATION: &str = "observation";
37const TAG_FUNNEL: &str = "funnel";
38const RECORD_KINDS: [&str; 4] = [
39    "telemetry.window",
40    "telemetry.rollup",
41    "telemetry.observation",
42    "telemetry.funnel",
43];
44pub(crate) const IMPORTANCE_CEILING: f32 = 0.40;
45const SOURCE_TRUST: f32 = 0.7;
46
47fn telemetry_effects(destructive: bool, writes: bool) -> EffectRow {
48    EffectRow {
49        reads: vec![Resource::Galaxy("telemetry".into())],
50        writes: if writes {
51            vec![Resource::Galaxy("telemetry".into())]
52        } else {
53            vec![]
54        },
55        destructive,
56        ..Default::default()
57    }
58}
59
60/// Validate a telemetry record shape. Returns the kind string.
61pub(crate) fn validate_record(record: &Value) -> std::result::Result<&'static str, String> {
62    let kind = record
63        .get("kind")
64        .and_then(Value::as_str)
65        .ok_or_else(|| "record.kind is required".to_string())?;
66    let kind = RECORD_KINDS
67        .iter()
68        .find(|k| **k == kind)
69        .ok_or_else(|| format!("record.kind must be one of {RECORD_KINDS:?}, got '{kind}'"))?;
70    record
71        .get("ts")
72        .and_then(Value::as_str)
73        .ok_or_else(|| "record.ts (RFC3339) is required".to_string())?;
74    if *kind == "telemetry.funnel" {
75        // Local activation evidence: milestone identity plus bounded,
76        // content-free attribution fields. No free-text field is accepted.
77        let milestone = record
78            .get("milestone")
79            .and_then(Value::as_str)
80            .filter(|milestone| !milestone.is_empty())
81            .ok_or_else(|| {
82                "record.milestone (non-empty string) is required for telemetry.funnel records"
83                    .to_string()
84            })?;
85        if !super::funnel::is_known_milestone(milestone) {
86            return Err(format!(
87                "record.milestone '{milestone}' is not a known funnel milestone"
88            ));
89        }
90        if let Some(channel) = record.get("channel") {
91            let channel = channel.as_str().ok_or_else(|| {
92                "record.channel (string) must be a site install channel".to_string()
93            })?;
94            if super::funnel::Channel::parse(channel).is_none() {
95                return Err(format!(
96                    "record.channel '{channel}' must be one of install_sh|binary|npm|docker|cargo|source|unknown"
97                ));
98            }
99        }
100        for field in ["version", "os", "arch"] {
101            if record.get(field).is_some_and(|value| !value.is_string()) {
102                return Err(format!("record.{field} (string) is required when present"));
103            }
104        }
105        if record
106            .get("day_offset")
107            .is_some_and(|offset| offset.as_u64().is_none())
108        {
109            return Err(
110                "record.day_offset (non-negative integer) is required when present".to_string(),
111            );
112        }
113        return Ok(kind);
114    }
115    if *kind == "telemetry.observation" {
116        // Policy decision records (step-0 observation ladder): identity and
117        // transition fields are mandatory; harmony/dims do not apply.
118        for field in ["policy_id", "metric", "state", "action"] {
119            if record
120                .get(field)
121                .and_then(Value::as_str)
122                .is_none_or(str::is_empty)
123            {
124                return Err(format!(
125                    "record.{field} (non-empty string) is required for telemetry.observation records"
126                ));
127            }
128        }
129        if record.get("value").and_then(Value::as_f64).is_none() {
130            return Err(
131                "record.value (number) is required for telemetry.observation records".to_string(),
132            );
133        }
134        return Ok(kind);
135    }
136    // Windows and rollups carry a harmony score; rollups aggregate it as
137    // `{"harmony":{"avg":...}}`, windows as the scalar `harmony_score`.
138    let harmony = record
139        .get("harmony_score")
140        .and_then(Value::as_f64)
141        .or_else(|| {
142            record
143                .get("harmony")
144                .and_then(|h| h.get("avg"))
145                .and_then(Value::as_f64)
146        })
147        .ok_or_else(|| "record.harmony_score (0.0-1.0) is required".to_string())?;
148    if !(0.0..=1.0).contains(&harmony) {
149        return Err(format!(
150            "record.harmony_score must be in 0.0-1.0, got {harmony}"
151        ));
152    }
153    if !record.get("dims").is_some_and(Value::is_object) {
154        return Err("record.dims (object) is required".to_string());
155    }
156    // Windows must carry the honesty field: a window without provenance notes
157    // is a probe, and probes must not pollute trend lanes (learned live
158    // 2026-09-13 when a typed probe flattened the dashboard dharma trend).
159    if *kind == "telemetry.window" && !record.get("dim_notes").is_some_and(Value::is_object) {
160        return Err(
161            "record.dim_notes (object) is required for telemetry.window records".to_string(),
162        );
163    }
164    Ok(kind)
165}
166
167/// Store a validated record in the telemetry galaxy. Returns (id, deduplicated).
168///
169/// Shared with the funnel emitter (`super::funnel`) so tags/class/ceiling
170/// discipline stays centralized on the typed path.
171pub(crate) fn store_record(
172    store: &MemoryStore,
173    search: Option<&SearchEngine>,
174    record: &Value,
175    kind: &str,
176    source: &str,
177) -> std::result::Result<(String, bool), String> {
178    let content =
179        serde_json::to_string(record).map_err(|e| format!("record serialization failed: {e}"))?;
180    let mut memory = Memory::new(Galaxy::Telemetry, content);
181    let mut tags: Vec<String> = record
182        .get("tags")
183        .and_then(Value::as_array)
184        .map(|arr| {
185            arr.iter()
186                .filter_map(Value::as_str)
187                .map(str::to_string)
188                .collect()
189        })
190        .unwrap_or_default();
191    for required in ["telemetry", "edge"] {
192        if !tags.iter().any(|t| t == required) {
193            tags.push(required.to_string());
194        }
195    }
196    let kind_tag = match kind {
197        "telemetry.window" => TAG_WINDOW,
198        "telemetry.observation" => TAG_OBSERVATION,
199        "telemetry.funnel" => TAG_FUNNEL,
200        _ => TAG_ROLLUP,
201    };
202    if !tags.iter().any(|t| t == kind_tag) {
203        tags.push(kind_tag.to_string());
204    }
205    memory.metadata.tags = tags;
206    let importance = record
207        .get("importance")
208        .and_then(Value::as_f64)
209        .map_or(0.3_f32, |v| v as f32);
210    // Clamp rather than reject: the typed telemetry path is machine-fed, and
211    // the contract is the class ceiling. A negative value would otherwise
212    // make the record unreachable in importance-ordered browse.
213    memory.metadata.importance = importance.clamp(0.0, IMPORTANCE_CEILING);
214    memory.metadata.title = record
215        .get("ts")
216        .and_then(Value::as_str)
217        .map(|ts| format!("telemetry {kind_tag} {ts}"));
218    memory.metadata.source = source.to_string();
219    memory.metadata.source_trust = SOURCE_TRUST;
220    memory.metadata.class =
221        wm_memory::typology::detect_class(&memory.content, &memory.metadata.tags)
222            .or(Some(wm_memory::typology::MemoryClass::Telemetry));
223    memory.metadata.tier = memory.metadata.class.map_or(
224        wm_memory::memory::Tier::Working,
225        wm_memory::typology::initial_tier,
226    );
227
228    let new_id = memory.metadata.id;
229    let stored_id = store
230        .put_dedup(Galaxy::Telemetry, &memory)
231        .map_err(|e| format!("telemetry store failed: {e}"))?;
232    let deduplicated = stored_id != new_id;
233    if !deduplicated {
234        common::index_memory(search, &memory);
235    }
236    Ok((stored_id.to_string(), deduplicated))
237}
238
239/// `telemetry.record` — validate and store one window/rollup record.
240pub struct TelemetryRecordTool {
241    store: Arc<MemoryStore>,
242    search: Option<Arc<SearchEngine>>,
243    stats: ToolStats,
244    effects: EffectRow,
245}
246
247impl TelemetryRecordTool {
248    /// Create the record tool.
249    #[must_use]
250    pub fn new(store: Arc<MemoryStore>, search: Option<Arc<SearchEngine>>) -> Self {
251        Self {
252            store,
253            search,
254            stats: ToolStats::default(),
255            effects: telemetry_effects(false, true),
256        }
257    }
258}
259
260#[async_trait]
261impl Tool for TelemetryRecordTool {
262    fn name(&self) -> &str {
263        "telemetry.record"
264    }
265    fn gana(&self) -> Gana {
266        Gana::Ghost
267    }
268    fn effects(&self) -> &EffectRow {
269        &self.effects
270    }
271    fn description(&self) -> &str {
272        "Record one telemetry window/rollup/observation/funnel record (schema wm-telemetry-v1) into the telemetry galaxy. Args: record (object with kind/ts/harmony_score/dims), source (optional trust label), importance (optional, capped 0.40). Deduplicates on identical content."
273    }
274    fn input_schema(&self) -> Value {
275        common::schema(
276            &json!({
277                "record": {"type": "object", "description": "telemetry.window | telemetry.rollup | telemetry.observation | telemetry.funnel record (required)"},
278                "source": common::str_prop("producer label (default 'agent')"),
279                "importance": common::num_prop("0-1, capped to the telemetry class ceiling 0.40"),
280            }),
281            &["record"],
282        )
283    }
284    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
285        let record = if let Some(record) = args.get("record") {
286            record.clone()
287        } else if let Some(content) = args.get("content").and_then(Value::as_str) {
288            serde_json::from_str(content)
289                .map_err(|e| CoreError::InvalidArgs(format!("content is not JSON: {e}")))?
290        } else {
291            return Err(CoreError::InvalidArgs(
292                "record (object) or content (JSON string) is required".into(),
293            ));
294        };
295        let kind = validate_record(&record).map_err(CoreError::InvalidArgs)?;
296        let mut record = record;
297        if let Some(importance) = args.get("importance") {
298            record["importance"] = importance.clone();
299        }
300        let source = args
301            .get("source")
302            .and_then(Value::as_str)
303            .unwrap_or("agent");
304        let (id, deduplicated) =
305            store_record(&self.store, self.search.as_deref(), &record, kind, source)
306                .map_err(CoreError::Internal)?;
307        Ok(json!({
308            "status": "success",
309            "id": id,
310            "galaxy": "telemetry",
311            "kind": kind,
312            "deduplicated": deduplicated,
313        }))
314    }
315    fn stats(&self) -> &ToolStats {
316        &self.stats
317    }
318}
319
320fn fnum(record: &Value, path: &[&str]) -> Option<f64> {
321    let mut value = record;
322    for key in path {
323        value = value.get(*key)?;
324    }
325    value.as_f64()
326}
327
328fn round4(value: f64) -> f64 {
329    (value * 10_000.0).round() / 10_000.0
330}
331
332/// Aggregate a set of window records into one hourly rollup record.
333fn build_rollup(period_start: &str, period_end: &str, windows: &[Value]) -> Value {
334    let mut dim_values: BTreeMap<String, Vec<f64>> = BTreeMap::new();
335    let mut harmony: Vec<f64> = Vec::new();
336    let mut guna = json!({"sattvic": 0, "rajasic": 0, "tamasic": 0});
337    let mut topics: BTreeMap<String, u64> = BTreeMap::new();
338    let mut event_count = 0_u64;
339    for window in windows {
340        if let Some(score) = fnum(window, &["harmony_score"]) {
341            harmony.push(score);
342        }
343        if let Some(dims) = window.get("dims").and_then(Value::as_object) {
344            for (name, score) in dims {
345                if let Some(score) = score.as_f64() {
346                    dim_values.entry(name.clone()).or_default().push(score);
347                }
348            }
349        }
350        if let Some(g) = window.get("guna").and_then(Value::as_object) {
351            for key in ["sattvic", "rajasic", "tamasic"] {
352                if let Some(v) = g.get(key).and_then(Value::as_u64) {
353                    guna[key] = json!(guna[key].as_u64().unwrap_or(0) + v);
354                }
355            }
356        }
357        if let Some(events) = window.get("events").and_then(Value::as_array) {
358            event_count += events.len() as u64;
359            for event in events {
360                if let Some(topic) = event.get("topic").and_then(Value::as_str) {
361                    *topics.entry(topic.to_string()).or_default() += 1;
362                }
363            }
364        }
365    }
366    let stats = |values: &[f64]| -> Value {
367        if values.is_empty() {
368            return json!({"avg": null, "min": null, "max": null});
369        }
370        let sum: f64 = values.iter().sum();
371        json!({
372            "avg": round4(sum / values.len() as f64),
373            "min": round4(values.iter().copied().fold(f64::INFINITY, f64::min)),
374            "max": round4(values.iter().copied().fold(f64::NEG_INFINITY, f64::max)),
375        })
376    };
377    let dims: BTreeMap<String, Value> = dim_values
378        .iter()
379        .map(|(name, values)| (name.clone(), stats(values)))
380        .collect();
381    let harmony_stats = stats(&harmony);
382    json!({
383        "kind": "telemetry.rollup",
384        "ts": period_end,
385        "period_start": period_start,
386        "period_end": period_end,
387        "hours": 1,
388        "samples": windows.len(),
389        "dims": dims,
390        "harmony": harmony_stats,
391        "guna": guna,
392        "events": {"count": event_count, "topics": topics},
393        "source": "wm-rollup",
394        "tags": ["telemetry", "edge", TAG_ROLLUP],
395    })
396}
397
398fn period_bounds(ts: &str) -> Option<(String, String)> {
399    let stamp = DateTime::parse_from_rfc3339(ts).ok()?.with_timezone(&Utc);
400    let start = stamp.date_naive().and_hms_opt(stamp.hour(), 0, 0)?;
401    let start = DateTime::<Utc>::from_naive_utc_and_offset(start, Utc);
402    let end = start + ChronoDuration::hours(1);
403    Some((
404        start.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
405        end.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
406    ))
407}
408
409/// `telemetry.rollup` — aggregate completed hours into rollup memories.
410pub struct TelemetryRollupTool {
411    store: Arc<MemoryStore>,
412    search: Option<Arc<SearchEngine>>,
413    stats: ToolStats,
414    effects: EffectRow,
415}
416
417impl TelemetryRollupTool {
418    /// Create the rollup tool.
419    #[must_use]
420    pub fn new(store: Arc<MemoryStore>, search: Option<Arc<SearchEngine>>) -> Self {
421        Self {
422            store,
423            search,
424            stats: ToolStats::default(),
425            effects: telemetry_effects(false, true),
426        }
427    }
428}
429
430#[async_trait]
431impl Tool for TelemetryRollupTool {
432    fn name(&self) -> &str {
433        "telemetry.rollup"
434    }
435    fn gana(&self) -> Gana {
436        Gana::Ghost
437    }
438    fn effects(&self) -> &EffectRow {
439        &self.effects
440    }
441    fn description(&self) -> &str {
442        "Aggregate completed telemetry windows into hourly rollups (deterministic; re-runs deduplicate). Args: hours (scan horizon, default 24), include_current (default false — only completed hours), dry_run (default false)."
443    }
444    fn input_schema(&self) -> Value {
445        common::schema(
446            &json!({
447                "hours": common::num_prop("how far back to scan (default 24)"),
448                "include_current": common::bool_prop("include the in-progress hour (default false)"),
449                "dry_run": common::bool_prop("report without writing (default false)"),
450            }),
451            &[],
452        )
453    }
454    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
455        let hours = args.get("hours").and_then(Value::as_f64).unwrap_or(24.0);
456        let include_current = args
457            .get("include_current")
458            .and_then(Value::as_bool)
459            .unwrap_or(false);
460        let dry_run = args
461            .get("dry_run")
462            .and_then(Value::as_bool)
463            .unwrap_or(false);
464        let cutoff = Utc::now() - ChronoDuration::minutes((hours * 60.0) as i64);
465        let current_period =
466            period_bounds(&Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true))
467                .map(|(start, _)| start);
468
469        let memories = self
470            .store
471            .scan_all(Galaxy::Telemetry)
472            .map_err(|e| CoreError::Internal(format!("telemetry scan failed: {e}")))?;
473        let mut windows: BTreeMap<String, Vec<Value>> = BTreeMap::new();
474        let mut skipped_current = 0_u64;
475        let mut scanned = 0_u64;
476        for memory in &memories {
477            if !memory.metadata.tags.iter().any(|t| t == TAG_WINDOW) {
478                continue;
479            }
480            let Ok(record) = serde_json::from_str::<Value>(&memory.content) else {
481                continue;
482            };
483            let Some(ts) = record.get("ts").and_then(Value::as_str) else {
484                continue;
485            };
486            let Some(stamp) = DateTime::parse_from_rfc3339(ts)
487                .ok()
488                .map(|s| s.with_timezone(&Utc))
489            else {
490                continue;
491            };
492            if stamp < cutoff {
493                continue;
494            }
495            scanned += 1;
496            let Some((start, _end)) = period_bounds(ts) else {
497                continue;
498            };
499            if !include_current && current_period.as_deref() == Some(start.as_str()) {
500                skipped_current += 1;
501                continue;
502            }
503            windows.entry(start).or_default().push(record);
504        }
505
506        let mut written = 0_u64;
507        let mut deduplicated = 0_u64;
508        let mut first_period: Option<String> = None;
509        let mut last_period: Option<String> = None;
510        for (start, records) in &windows {
511            let end = period_bounds(start).map_or_else(|| start.clone(), |(_, end)| end);
512            let rollup = build_rollup(start, &end, records);
513            first_period.get_or_insert_with(|| start.clone());
514            last_period = Some(start.clone());
515            if dry_run {
516                continue;
517            }
518            let (_, dedup) = store_record(
519                &self.store,
520                self.search.as_deref(),
521                &rollup,
522                "telemetry.rollup",
523                "wm-rollup",
524            )
525            .map_err(CoreError::Internal)?;
526            if dedup {
527                deduplicated += 1;
528            } else {
529                written += 1;
530            }
531        }
532        Ok(json!({
533            "status": "success",
534            "dry_run": dry_run,
535            "scanned": scanned,
536            "windows": scanned,
537            "periods": windows.len(),
538            "written": written,
539            "deduplicated": deduplicated,
540            "skipped_current": skipped_current,
541            "first_period": first_period,
542            "last_period": last_period,
543        }))
544    }
545    fn stats(&self) -> &ToolStats {
546        &self.stats
547    }
548}
549
550/// `telemetry.prune` — destructive retention for windows and rollups.
551pub struct TelemetryPruneTool {
552    store: Arc<MemoryStore>,
553    stats: ToolStats,
554    effects: EffectRow,
555}
556
557impl TelemetryPruneTool {
558    /// Create the prune tool.
559    #[must_use]
560    pub fn new(store: Arc<MemoryStore>) -> Self {
561        Self {
562            store,
563            stats: ToolStats::default(),
564            effects: telemetry_effects(true, true),
565        }
566    }
567}
568
569#[async_trait]
570impl Tool for TelemetryPruneTool {
571    fn name(&self) -> &str {
572        "telemetry.prune"
573    }
574    fn gana(&self) -> Gana {
575        Gana::Ghost
576    }
577    fn effects(&self) -> &EffectRow {
578        &self.effects
579    }
580    fn description(&self) -> &str {
581        "Destructive retention: delete telemetry windows/rollups older than their horizons (defaults 7 d windows / 90 d rollups). Args: windows_older_than_days, rollups_older_than_days, dry_run (default true), limit (0 = all). Requires confirm: true when dry_run is false."
582    }
583    fn input_schema(&self) -> Value {
584        common::schema(
585            &json!({
586                "windows_older_than_days": common::num_prop("window horizon in days (default 7)"),
587                "rollups_older_than_days": common::num_prop("rollup horizon in days (default 90)"),
588                "dry_run": common::bool_prop("report without deleting (default true)"),
589                "limit": common::num_prop("max records to inspect, 0 = all"),
590            }),
591            &[],
592        )
593    }
594    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
595        let window_days = args
596            .get("windows_older_than_days")
597            .and_then(Value::as_f64)
598            .unwrap_or(7.0);
599        let rollup_days = args
600            .get("rollups_older_than_days")
601            .and_then(Value::as_f64)
602            .unwrap_or(90.0);
603        let dry_run = args.get("dry_run").and_then(Value::as_bool).unwrap_or(true);
604        let limit = args.get("limit").and_then(Value::as_u64).unwrap_or(0) as usize;
605
606        let memories = self
607            .store
608            .scan_all(Galaxy::Telemetry)
609            .map_err(|e| CoreError::Internal(format!("telemetry scan failed: {e}")))?;
610        let now = Utc::now();
611        let mut scanned = 0_u64;
612        let mut candidates = 0_u64;
613        let mut deleted = 0_u64;
614        let mut window_candidates = 0_u64;
615        let mut rollup_candidates = 0_u64;
616        let mut errors: Vec<String> = Vec::new();
617        for memory in &memories {
618            if limit > 0 && scanned as usize >= limit {
619                break;
620            }
621            scanned += 1;
622            let is_window = memory.metadata.tags.iter().any(|t| t == TAG_WINDOW);
623            let is_rollup = memory.metadata.tags.iter().any(|t| t == TAG_ROLLUP);
624            let horizon = if is_window {
625                window_days
626            } else if is_rollup {
627                rollup_days
628            } else {
629                continue;
630            };
631            let age = now.signed_duration_since(memory.metadata.created_at);
632            if age < ChronoDuration::minutes((horizon * 1440.0) as i64) {
633                continue;
634            }
635            candidates += 1;
636            if is_window {
637                window_candidates += 1;
638            } else {
639                rollup_candidates += 1;
640            }
641            if dry_run {
642                continue;
643            }
644            match self.store.delete(Galaxy::Telemetry, memory.metadata.id) {
645                Ok(true) => deleted += 1,
646                Ok(false) => {}
647                Err(e) => errors.push(format!("{}: {e}", memory.metadata.id)),
648            }
649        }
650        Ok(json!({
651            "status": "success",
652            "dry_run": dry_run,
653            "scanned": scanned,
654            "candidates": candidates,
655            "deleted": deleted,
656            "by_kind": {"window": window_candidates, "rollup": rollup_candidates},
657            "horizons": {"windows_days": window_days, "rollups_days": rollup_days},
658            "errors": errors,
659        }))
660    }
661    fn stats(&self) -> &ToolStats {
662        &self.stats
663    }
664}
665
666/// Per-tier retention inventory. The eligibility rule mirrors `telemetry.prune`
667/// exactly (age by `created_at`, eligible at `>= horizon`), so a planner run
668/// reporting `prune_due: true` will have work when prune runs.
669fn retention_inventory(
670    store: &MemoryStore,
671    window_days: f64,
672    rollup_days: f64,
673) -> std::result::Result<(Value, bool), String> {
674    #[derive(Default)]
675    struct Tier {
676        count: u64,
677        eligible: u64,
678        bytes: u64,
679        oldest: Option<DateTime<Utc>>,
680        newest: Option<DateTime<Utc>>,
681        oldest_eligible: Option<DateTime<Utc>>,
682        oldest_kept: Option<DateTime<Utc>>,
683    }
684
685    fn stamp(dt: DateTime<Utc>) -> String {
686        dt.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
687    }
688
689    impl Tier {
690        fn observe(&mut self, created: DateTime<Utc>, eligible_now: bool, bytes: u64) {
691            self.count += 1;
692            self.bytes += bytes;
693            self.oldest = Some(self.oldest.map_or(created, |o| o.min(created)));
694            self.newest = Some(self.newest.map_or(created, |n| n.max(created)));
695            if eligible_now {
696                self.eligible += 1;
697                self.oldest_eligible =
698                    Some(self.oldest_eligible.map_or(created, |o| o.min(created)));
699            } else {
700                self.oldest_kept = Some(self.oldest_kept.map_or(created, |o| o.min(created)));
701            }
702        }
703
704        fn ages(&self) -> Value {
705            json!({
706                "count": self.count,
707                "bytes": self.bytes,
708                "oldest_created": self.oldest.map(stamp),
709                "newest_created": self.newest.map(stamp),
710            })
711        }
712
713        fn json(&self, horizon_days: f64) -> Value {
714            let mut value = self.ages();
715            value["eligible"] = json!(self.eligible);
716            value["oldest_eligible"] = json!(self.oldest_eligible.map(stamp));
717            value["next_eligible_since"] = json!(
718                self.oldest_kept
719                    .map(|d| stamp(d + ChronoDuration::minutes((horizon_days * 1440.0) as i64)))
720            );
721            value
722        }
723    }
724
725    let memories = store
726        .scan_all(Galaxy::Telemetry)
727        .map_err(|e| format!("telemetry scan failed: {e}"))?;
728    let now = Utc::now();
729    let mut windows = Tier::default();
730    let mut rollups = Tier::default();
731    let mut observations = Tier::default();
732    let mut funnel = Tier::default();
733    let mut unmanaged = Tier::default();
734    for memory in &memories {
735        let created = memory.metadata.created_at;
736        let bytes = memory.content.len() as u64;
737        let tags = &memory.metadata.tags;
738        if tags.iter().any(|t| t == TAG_WINDOW) {
739            let eligible = now.signed_duration_since(created)
740                >= ChronoDuration::minutes((window_days * 1440.0) as i64);
741            windows.observe(created, eligible, bytes);
742        } else if tags.iter().any(|t| t == TAG_ROLLUP) {
743            let eligible = now.signed_duration_since(created)
744                >= ChronoDuration::minutes((rollup_days * 1440.0) as i64);
745            rollups.observe(created, eligible, bytes);
746        } else if tags.iter().any(|t| t == TAG_OBSERVATION) {
747            observations.observe(created, false, bytes);
748        } else if tags.iter().any(|t| t == TAG_FUNNEL) {
749            funnel.observe(created, false, bytes);
750        } else {
751            unmanaged.observe(created, false, bytes);
752        }
753    }
754
755    let mut observation_json = observations.ages();
756    observation_json["managed"] = json!(false);
757    observation_json["note"] = json!(
758        "policy decision records are governance evidence; telemetry.prune does not delete them"
759    );
760    let mut funnel_json = funnel.ages();
761    funnel_json["managed"] = json!(false);
762    funnel_json["note"] =
763        json!("store-lifetime evidence; telemetry.prune does not delete; reset explicitly");
764    let mut unmanaged_json = unmanaged.ages();
765    unmanaged_json["note"] = json!("telemetry rows without a window/rollup/observation/funnel tag");
766
767    let prune_due = windows.eligible + rollups.eligible > 0;
768    let inventory = json!({
769        "windows": windows.json(window_days),
770        "rollups": rollups.json(rollup_days),
771        "observations": observation_json,
772        "funnel": funnel_json,
773        "unmanaged": unmanaged_json,
774    });
775    Ok((inventory, prune_due))
776}
777
778/// `telemetry.retention` — read-only retention planner.
779pub struct TelemetryRetentionTool {
780    store: Arc<MemoryStore>,
781    stats: ToolStats,
782    effects: EffectRow,
783}
784
785impl TelemetryRetentionTool {
786    /// Create the retention planner.
787    #[must_use]
788    pub fn new(store: Arc<MemoryStore>) -> Self {
789        Self {
790            store,
791            stats: ToolStats::default(),
792            effects: telemetry_effects(false, false),
793        }
794    }
795}
796
797#[async_trait]
798impl Tool for TelemetryRetentionTool {
799    fn name(&self) -> &str {
800        "telemetry.retention"
801    }
802    fn gana(&self) -> Gana {
803        Gana::Ghost
804    }
805    fn effects(&self) -> &EffectRow {
806        &self.effects
807    }
808    fn description(&self) -> &str {
809        "Read-only retention planner: per-tier counts/ages/eligibility for telemetry windows (7 d) and rollups (90 d) under the exact horizons telemetry.prune uses, plus managed=false observation inventory. No confirm and no dharma gate — safe to run any time; act with telemetry.prune."
810    }
811    fn input_schema(&self) -> Value {
812        common::schema(
813            &json!({
814                "windows_older_than_days": common::num_prop("window horizon to project (default 7)"),
815                "rollups_older_than_days": common::num_prop("rollup horizon to project (default 90)"),
816            }),
817            &[],
818        )
819    }
820    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
821        let window_days = args
822            .get("windows_older_than_days")
823            .and_then(Value::as_f64)
824            .unwrap_or(7.0);
825        let rollup_days = args
826            .get("rollups_older_than_days")
827            .and_then(Value::as_f64)
828            .unwrap_or(90.0);
829        let (inventory, prune_due) = retention_inventory(&self.store, window_days, rollup_days)
830            .map_err(CoreError::Internal)?;
831        Ok(json!({
832            "status": "success",
833            "read_only": true,
834            "generated_at": Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
835            "basis": "record created_at (identical to telemetry.prune)",
836            "horizons": {"windows_days": window_days, "rollups_days": rollup_days},
837            "inventory": inventory,
838            "prune_due": prune_due,
839            "advice": if prune_due {
840                "eligible records exist — run telemetry.prune with dry_run:false + confirm:true"
841            } else {
842                "nothing eligible — no prune needed"
843            },
844        }))
845    }
846    fn stats(&self) -> &ToolStats {
847        &self.stats
848    }
849}
850
851/// Register the telemetry surface (4 tools).
852#[must_use]
853pub fn register_telemetry(
854    registry: &wm_dispatch::ToolRegistry,
855    store: &Arc<MemoryStore>,
856    search: Option<Arc<SearchEngine>>,
857) -> wm_dispatch::ToolRegistry {
858    registry
859        .register(Arc::new(TelemetryRecordTool::new(
860            Arc::clone(store),
861            search.clone(),
862        )))
863        .register(Arc::new(TelemetryRollupTool::new(
864            Arc::clone(store),
865            search,
866        )))
867        .register(Arc::new(TelemetryPruneTool::new(Arc::clone(store))))
868        .register(Arc::new(TelemetryRetentionTool::new(Arc::clone(store))))
869}
870
871#[cfg(test)]
872mod tests {
873    use super::*;
874
875    fn open_store() -> (tempfile::TempDir, Arc<MemoryStore>) {
876        let tmp = tempfile::tempdir().unwrap();
877        let store = Arc::new(MemoryStore::open_default(tmp.path()).unwrap());
878        (tmp, store)
879    }
880
881    fn window_record(ts: &str, harmony: f64) -> Value {
882        json!({
883            "kind": "telemetry.window",
884            "ts": ts,
885            "window_seconds": 60,
886            "source": "laksmi",
887            "tags": ["telemetry", "edge", "laksmi", "window"],
888            "harmony_score": harmony,
889            "dims": {"fairness": 0.5, "responsiveness": 0.8},
890            "dim_notes": {"fairness": "test"},
891            "guna": {"sattvic": 1, "rajasic": 0, "tamasic": 2},
892            "top": [],
893            "events": [{"topic": "os.telemetry.energy", "value": 0.5, "threshold": 0.9}],
894            "karma": {"total": 100.0, "delta": 0.0},
895            "dharma": {"total": 1, "blocked": 0, "delta_total": 0, "delta_blocked": 0},
896        })
897    }
898
899    #[tokio::test]
900    async fn record_validates_and_stores() {
901        let (_tmp, store) = open_store();
902        let tool = TelemetryRecordTool::new(Arc::clone(&store), None);
903        let out = tool
904            .call(
905                &mut Context::default(),
906                json!({"record": window_record("2026-09-13T10:00:00+00:00", 0.6)}),
907            )
908            .await
909            .unwrap();
910        assert_eq!(out["status"], "success");
911        assert_eq!(out["kind"], "telemetry.window");
912        assert_eq!(out["deduplicated"], false);
913        assert_eq!(store.count(Galaxy::Telemetry).unwrap(), 1);
914
915        // Identical content deduplicates.
916        let again = tool
917            .call(
918                &mut Context::default(),
919                json!({"record": window_record("2026-09-13T10:00:00+00:00", 0.6)}),
920            )
921            .await
922            .unwrap();
923        assert_eq!(again["deduplicated"], true);
924        assert_eq!(store.count(Galaxy::Telemetry).unwrap(), 1);
925
926        // Invalid shapes are loud.
927        let bad = tool
928            .call(
929                &mut Context::default(),
930                json!({"record": {"kind": "telemetry.window", "ts": "x"}}),
931            )
932            .await;
933        assert!(bad.is_err(), "missing harmony_score/dims must error");
934
935        // A window without provenance notes is a probe, not evidence.
936        let mut probe = window_record("2026-09-13T11:00:00+00:00", 0.5);
937        probe.as_object_mut().unwrap().remove("dim_notes");
938        let probe_reply = tool
939            .call(&mut Context::default(), json!({"record": probe}))
940            .await;
941        assert!(probe_reply.is_err(), "windows require dim_notes");
942    }
943
944    #[tokio::test]
945    async fn record_accepts_policy_observations_and_typed_rollups() {
946        let (_tmp, store) = open_store();
947        let tool = TelemetryRecordTool::new(Arc::clone(&store), None);
948        let obs = json!({
949            "kind": "telemetry.observation",
950            "ts": "2026-09-13T12:00:00+00:00",
951            "policy_id": "energy.over_budget.v1",
952            "metric": "energy",
953            "state": "observing",
954            "action": "observe",
955            "value": 0.33,
956            "enter": 0.9,
957            "exit": 0.97,
958            "dwell_windows": 1,
959            "tags": ["telemetry", "edge", "observation", "policy"],
960        });
961        let out = tool
962            .call(&mut Context::default(), json!({"record": obs}))
963            .await
964            .unwrap();
965        assert_eq!(out["kind"], "telemetry.observation");
966        assert_eq!(store.count(Galaxy::Telemetry).unwrap(), 1);
967
968        // Identity fields are mandatory for observations.
969        let bad = json!({"kind": "telemetry.observation", "ts": "x", "metric": "energy"});
970        assert!(
971            tool.call(&mut Context::default(), json!({"record": bad}))
972                .await
973                .is_err()
974        );
975
976        // Rollups validate via harmony.avg (the typed path now matches what
977        // the rollup builder actually produces).
978        let rollup = json!({
979            "kind": "telemetry.rollup",
980            "ts": "2026-09-13T12:00:00+00:00",
981            "harmony": {"avg": 0.6},
982            "dims": {"fairness": {"avg": 0.5}},
983        });
984        let out = tool
985            .call(&mut Context::default(), json!({"record": rollup}))
986            .await
987            .unwrap();
988        assert_eq!(out["kind"], "telemetry.rollup");
989        assert_eq!(store.count(Galaxy::Telemetry).unwrap(), 2);
990
991        // Funnel records validate on milestone identity, not harmony.
992        let funnel = json!({
993            "kind": "telemetry.funnel",
994            "ts": "2026-09-18T00:00:00+00:00",
995            "milestone": "active_d2",
996            "channel": "install_sh",
997            "version": "9.1.9",
998            "os": "linux",
999            "arch": "x86_64",
1000            "day_offset": 2,
1001        });
1002        let out = tool
1003            .call(&mut Context::default(), json!({"record": funnel}))
1004            .await
1005            .unwrap();
1006        assert_eq!(out["kind"], "telemetry.funnel");
1007        assert_eq!(store.count(Galaxy::Telemetry).unwrap(), 3);
1008        let funnel_tags = store
1009            .scan_all(Galaxy::Telemetry)
1010            .unwrap()
1011            .into_iter()
1012            .find(|m| m.metadata.tags.iter().any(|t| t == TAG_FUNNEL))
1013            .expect("funnel tag");
1014        assert!(funnel_tags.metadata.importance <= IMPORTANCE_CEILING);
1015
1016        // Unknown milestones and channels are loud.
1017        let bad_milestone = json!({
1018            "kind": "telemetry.funnel",
1019            "ts": "2026-09-18T00:00:00+00:00",
1020            "milestone": "activation",
1021        });
1022        assert!(
1023            tool.call(&mut Context::default(), json!({"record": bad_milestone}))
1024                .await
1025                .is_err()
1026        );
1027        let bad_channel = json!({
1028            "kind": "telemetry.funnel",
1029            "ts": "2026-09-18T00:00:00+00:00",
1030            "milestone": "first_launch",
1031            "channel": "email",
1032        });
1033        assert!(
1034            tool.call(&mut Context::default(), json!({"record": bad_channel}))
1035                .await
1036                .is_err()
1037        );
1038    }
1039
1040    #[tokio::test]
1041    async fn rollup_aggregates_completed_hours_idempotently() {
1042        let (_tmp, store) = open_store();
1043        let current = Utc::now();
1044        let last_hour = (current - ChronoDuration::hours(1))
1045            .with_minute(30)
1046            .unwrap()
1047            .with_second(0)
1048            .unwrap();
1049        let prev = last_hour - ChronoDuration::hours(2);
1050        let ts_a = last_hour.to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
1051        let ts_b = (last_hour + ChronoDuration::minutes(5))
1052            .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
1053        let ts_c = prev.to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
1054        for (ts, harmony) in [(&ts_a, 0.4), (&ts_b, 0.6), (&ts_c, 0.9)] {
1055            let record = window_record(ts, harmony);
1056            let mem = Memory::new(Galaxy::Telemetry, serde_json::to_string(&record).unwrap());
1057            let mut mem = mem;
1058            mem.metadata.tags = vec!["telemetry".into(), "edge".into(), "window".into()];
1059            store.put(Galaxy::Telemetry, &mem).unwrap();
1060        }
1061        let tool = TelemetryRollupTool::new(Arc::clone(&store), None);
1062        let out = tool.call(&mut Context::default(), json!({})).await.unwrap();
1063        assert_eq!(out["periods"], 2);
1064        assert_eq!(out["written"], 2);
1065        let out2 = tool.call(&mut Context::default(), json!({})).await.unwrap();
1066        assert_eq!(out2["deduplicated"], 2, "re-run is idempotent");
1067        let rollups: Vec<_> = store
1068            .scan_all(Galaxy::Telemetry)
1069            .unwrap()
1070            .into_iter()
1071            .filter(|m| m.metadata.tags.iter().any(|t| t == TAG_ROLLUP))
1072            .collect();
1073        assert_eq!(rollups.len(), 2);
1074        let records: Vec<Value> = rollups
1075            .iter()
1076            .map(|m| serde_json::from_str(&m.content).unwrap())
1077            .collect();
1078        assert!(records.iter().all(|r| r["kind"] == "telemetry.rollup"));
1079        let total_events: u64 = records
1080            .iter()
1081            .map(|r| r["events"]["count"].as_u64().unwrap_or(0))
1082            .sum();
1083        assert_eq!(total_events, 3);
1084        let samples: Vec<u64> = records
1085            .iter()
1086            .map(|r| r["samples"].as_u64().unwrap_or(0))
1087            .collect();
1088        assert!(
1089            samples.contains(&2) && samples.contains(&1),
1090            "got {samples:?}"
1091        );
1092    }
1093
1094    #[tokio::test]
1095    async fn prune_respects_horizons_and_dry_run() {
1096        let (_tmp, store) = open_store();
1097        let old = Utc::now() - ChronoDuration::days(10);
1098        let ancient = Utc::now() - ChronoDuration::days(100);
1099        let recent = Utc::now() - ChronoDuration::hours(1);
1100        for (tag, created) in [
1101            ("window", old),
1102            ("window", recent),
1103            ("rollup", ancient),
1104            ("rollup", recent),
1105        ] {
1106            let mut mem = Memory::new(Galaxy::Telemetry, format!("{{\"ts\":\"{created}\"}}"));
1107            mem.metadata.tags = vec!["telemetry".into(), tag.into()];
1108            mem.metadata.created_at = created;
1109            store.put(Galaxy::Telemetry, &mem).unwrap();
1110        }
1111        let tool = TelemetryPruneTool::new(Arc::clone(&store));
1112        let dry = tool
1113            .call(&mut Context::default(), json!({"dry_run": true}))
1114            .await
1115            .unwrap();
1116        assert_eq!(dry["dry_run"], true);
1117        assert_eq!(dry["candidates"], 2, "10d window + 100d rollup");
1118        assert_eq!(dry["deleted"], 0);
1119        assert_eq!(store.count(Galaxy::Telemetry).unwrap(), 4);
1120
1121        let wet = tool
1122            .call(&mut Context::default(), json!({"dry_run": false}))
1123            .await
1124            .unwrap();
1125        assert_eq!(wet["deleted"], 2);
1126        assert_eq!(wet["by_kind"]["window"], 1);
1127        assert_eq!(wet["by_kind"]["rollup"], 1);
1128        assert_eq!(store.count(Galaxy::Telemetry).unwrap(), 2);
1129    }
1130
1131    #[tokio::test]
1132    async fn retention_planner_reports_tiers_read_only() {
1133        let (_tmp, store) = open_store();
1134        let now = Utc::now();
1135        for (tag, created) in [
1136            ("window", now - ChronoDuration::days(10)),
1137            ("window", now - ChronoDuration::hours(1)),
1138            ("rollup", now - ChronoDuration::days(100)),
1139            ("rollup", now - ChronoDuration::hours(2)),
1140            ("observation", now - ChronoDuration::days(1)),
1141            ("funnel", now - ChronoDuration::days(400)),
1142            ("", now - ChronoDuration::days(30)),
1143        ] {
1144            let mut mem = Memory::new(Galaxy::Telemetry, format!("{{\"ts\":\"{created}\"}}"));
1145            mem.metadata.tags = if tag.is_empty() {
1146                vec!["telemetry".into()]
1147            } else {
1148                vec!["telemetry".into(), tag.into()]
1149            };
1150            mem.metadata.created_at = created;
1151            store.put(Galaxy::Telemetry, &mem).unwrap();
1152        }
1153
1154        let tool = TelemetryRetentionTool::new(Arc::clone(&store));
1155        let out = tool.call(&mut Context::default(), json!({})).await.unwrap();
1156        assert_eq!(out["status"], "success");
1157        assert_eq!(out["read_only"], true);
1158        assert_eq!(out["prune_due"], true);
1159        assert_eq!(out["inventory"]["windows"]["count"], 2);
1160        assert_eq!(out["inventory"]["windows"]["eligible"], 1);
1161        assert_eq!(out["inventory"]["rollups"]["count"], 2);
1162        assert_eq!(out["inventory"]["rollups"]["eligible"], 1);
1163        assert_eq!(out["inventory"]["observations"]["count"], 1);
1164        assert_eq!(out["inventory"]["observations"]["managed"], false);
1165        assert_eq!(
1166            out["inventory"]["funnel"]["count"], 1,
1167            "funnel records are inventoried as a tier"
1168        );
1169        assert!(
1170            out["inventory"]["funnel"]["eligible"].is_null(),
1171            "funnel records are store-lifetime evidence — never prune-eligible"
1172        );
1173        assert_eq!(out["inventory"]["funnel"]["managed"], false);
1174        assert_eq!(out["inventory"]["unmanaged"]["count"], 1);
1175        assert!(
1176            out["inventory"]["windows"]["next_eligible_since"].is_string(),
1177            "a kept window must project its next eligibility"
1178        );
1179        assert!(out["inventory"]["windows"]["bytes"].as_u64().unwrap() > 0);
1180        assert_eq!(
1181            store.count(Galaxy::Telemetry).unwrap(),
1182            7,
1183            "the planner is read-only"
1184        );
1185
1186        // Wider horizons project no work, without touching the store.
1187        let calm = tool
1188            .call(
1189                &mut Context::default(),
1190                json!({"windows_older_than_days": 365, "rollups_older_than_days": 3650}),
1191            )
1192            .await
1193            .unwrap();
1194        assert_eq!(calm["prune_due"], false);
1195        assert_eq!(store.count(Galaxy::Telemetry).unwrap(), 7);
1196
1197        // And a wet prune executes exactly what the planner predicted —
1198        // windows/rollups only, never the funnel evidence.
1199        let prune = TelemetryPruneTool::new(Arc::clone(&store));
1200        let wet = prune
1201            .call(&mut Context::default(), json!({"dry_run": false}))
1202            .await
1203            .unwrap();
1204        assert_eq!(wet["deleted"], 2);
1205        assert_eq!(store.count(Galaxy::Telemetry).unwrap(), 5);
1206        assert_eq!(
1207            store
1208                .scan_all(Galaxy::Telemetry)
1209                .unwrap()
1210                .iter()
1211                .filter(|m| m.metadata.tags.iter().any(|t| t == TAG_FUNNEL))
1212                .count(),
1213            1,
1214            "prune must leave funnel records untouched"
1215        );
1216    }
1217}