Skip to main content

brainos_backends/
baseline.rs

1//! System baseline + drift detection — the executor behind
2//! `brain baseline capture/diff/list` and the `baseline.capture`/`diff`/`list`
3//! native capabilities.
4//!
5//! A *baseline* is a deterministic, offline snapshot of the brain's stable
6//! system facts — version, platform, the effective LLM/embedding wiring,
7//! adapter exposure, action toggles, the live native-capability inventory,
8//! and the security surface. Each snapshot is a flat `key → value` map: every
9//! entry is one fact, so a later *diff* can report precisely which facts were
10//! added, removed, or changed since the baseline was taken.
11//!
12//! **Storage.** Snapshots are persisted as versioned JSON files under
13//! `~/.brain/baselines/baseline-<NNNN>.json`, where `<NNNN>` is a monotonically
14//! increasing version number. This is local-first and inspectable, and — unlike
15//! writing into the semantic fact store — it never opens SQLite/RuVector, so it
16//! can't contend with a running daemon (the same reason `brain status` avoids
17//! opening the DB directly).
18//!
19//! **Truthful by construction.** Capture reads only the loaded config, the
20//! process environment, and the capability inventory it is handed. There is no
21//! LLM and no network: the snapshot is exactly what the running config declares,
22//! and a diff is a literal set comparison of two such snapshots.
23//!
24//! **Capability inventory is injected.** The native-capability descriptors live
25//! in the binary crate; rather than reach back into it (a layering inversion),
26//! the caller passes the inventory in as a [`CapabilitySummary`] slice — the
27//! composition root assembles it from the descriptor list. Keeps this core a
28//! pure function of its inputs.
29
30use std::collections::BTreeMap;
31use std::path::{Path, PathBuf};
32
33use anyhow::{Context, Result};
34use brain::BrainConfig;
35use serde::{Deserialize, Serialize};
36
37/// The capabilities this backend declares: baseline capture/diff/list. Always
38/// wired — local snapshot files, no network. `capture` is a local write;
39/// `diff`/`list` are read-only.
40pub fn capabilities(_config: &BrainConfig) -> Vec<intent::ToolDescriptor> {
41    use crate::capabilities::{backend, native, read_only, usage};
42    use intent::ToolAnnotations;
43    let base = || backend("baseline");
44    vec![
45        native(
46            "baseline",
47            "capture",
48            base(),
49            ToolAnnotations::default(),
50            usage(
51                "The user wants to record the current system state so future drift can be detected.",
52                "For comparing against an existing snapshot — use baseline.diff.",
53                &["None — offline config + capability snapshot."],
54                "free / local snapshot file",
55                "\"Capture a baseline of the current setup\"",
56            ),
57        ),
58        native(
59            "baseline",
60            "diff",
61            base(),
62            read_only(),
63            usage(
64                "The user asks what has changed since a baseline, or to detect configuration drift.",
65                "When no baseline has been captured yet — capture one first.",
66                &["At least one baseline has been captured."],
67                "free / local snapshot comparison",
68                "\"What's drifted since my last baseline?\"",
69            ),
70        ),
71        native(
72            "baseline",
73            "list",
74            base(),
75            read_only(),
76            usage(
77                "The user wants to see which baseline snapshots have been stored.",
78                "For the contents of a specific snapshot — use baseline.diff.",
79                &["None."],
80                "free / local directory listing",
81                "\"List the baselines I've captured\"",
82            ),
83        ),
84    ]
85}
86
87/// One native capability, flattened for the baseline snapshot. The composition
88/// root builds these from the registered descriptors and hands them to
89/// [`capture`] / [`diff`], so this core never depends on the descriptor source.
90#[derive(Debug, Clone)]
91pub struct CapabilitySummary {
92    pub namespace: String,
93    pub action: String,
94    /// The consent tier (e.g. `read`, `write`, `external`); `unknown` if unset.
95    pub tier: String,
96}
97
98/// One persisted baseline snapshot.
99#[derive(Debug, Clone, Serialize, Deserialize)]
100struct Baseline {
101    /// Monotonic version number; also the `<NNNN>` in the filename.
102    version: u32,
103    /// When the snapshot was captured (RFC3339).
104    captured_at: String,
105    /// Optional human-readable label.
106    #[serde(default)]
107    label: Option<String>,
108    /// The flat fact map — every entry is one `key → value` system fact.
109    facts: BTreeMap<String, String>,
110}
111
112// ─── capture ──────────────────────────────────────────────────────────────────
113
114/// Capture a new baseline snapshot and store it as the next version. Returns the
115/// rendered confirmation (version, fact count, and the file path).
116pub fn capture(
117    config: &BrainConfig,
118    inventory: &[CapabilitySummary],
119    label: Option<&str>,
120) -> Result<String> {
121    let dir = baseline_dir(config);
122    std::fs::create_dir_all(&dir)
123        .with_context(|| format!("creating baseline directory {}", dir.display()))?;
124
125    let version = next_version(&dir);
126    let baseline = Baseline {
127        version,
128        captured_at: chrono::Utc::now().to_rfc3339(),
129        label: label.map(str::to_string),
130        facts: capture_facts(config, inventory),
131    };
132
133    let path = baseline_path(&dir, version);
134    let json =
135        serde_json::to_string_pretty(&baseline).context("serialising the baseline snapshot")?;
136    std::fs::write(&path, json)
137        .with_context(|| format!("writing baseline to {}", path.display()))?;
138
139    let mut out = format!(
140        "Captured baseline v{} ({} facts){}\n",
141        version,
142        baseline.facts.len(),
143        label.map(|l| format!(" — {l}")).unwrap_or_default(),
144    );
145    out.push_str(&format!("  {}", path.display()));
146    Ok(out)
147}
148
149/// Build the snapshot from the loaded config, the process environment, and the
150/// injected capability inventory. No I/O beyond reading `config` — so the result
151/// is deterministic and stable across runs of the same config + inventory.
152fn capture_facts(
153    config: &BrainConfig,
154    inventory: &[CapabilitySummary],
155) -> BTreeMap<String, String> {
156    let mut f = BTreeMap::new();
157    let mut put = |k: &str, v: String| {
158        f.insert(k.to_string(), v);
159    };
160
161    // Identity & platform.
162    put("brain.version", env!("CARGO_PKG_VERSION").to_string());
163    put("os.platform", std::env::consts::OS.to_string());
164    put("os.arch", std::env::consts::ARCH.to_string());
165
166    // LLM wiring. Per-provider entries make drift point at the exact provider
167    // that changed; the legacy single-provider fields are recorded only when no
168    // `providers[]` are configured (their deprecated-but-live fallback role).
169    put("llm.context_window", config.llm.context_window.to_string());
170    if config.llm.providers.is_empty() {
171        #[allow(deprecated)]
172        {
173            put("llm.provider", config.llm.provider.clone());
174            put("llm.model", config.llm.model.clone());
175        }
176    } else {
177        for p in &config.llm.providers {
178            put(
179                &format!("llm.provider.{}", p.name),
180                format!("{} ({})", p.kind, p.model),
181            );
182        }
183    }
184
185    // Embedding + encryption.
186    put("embedding.model", config.embedding.model.clone());
187    put(
188        "embedding.dimensions",
189        config.embedding.dimensions.to_string(),
190    );
191    put("encryption.enabled", config.encryption.enabled.to_string());
192
193    // Adapter exposure — what the daemon listens on.
194    let a = &config.adapters;
195    put("adapter.http", adapter_fact(a.http.enabled, a.http.port));
196    put("adapter.ws", adapter_fact(a.ws.enabled, a.ws.port));
197    put("adapter.mcp", adapter_fact(a.mcp.enabled, a.mcp.port));
198    put("adapter.grpc", adapter_fact(a.grpc.enabled, a.grpc.port));
199    put(
200        "adapter.terminal",
201        adapter_fact(a.terminal.enabled, a.terminal.port),
202    );
203
204    // Action toggles.
205    put(
206        "actions.web_search",
207        config.actions.web_search.enabled.to_string(),
208    );
209    put(
210        "actions.scheduling",
211        config.actions.scheduling.enabled.to_string(),
212    );
213    put(
214        "actions.messaging",
215        config.actions.messaging.enabled.to_string(),
216    );
217
218    // Agents.
219    put(
220        "agents.auto_discovery",
221        config.agents.auto_discovery.to_string(),
222    );
223    let mut delegates: Vec<&str> = config
224        .agents
225        .delegates
226        .iter()
227        .map(|d| d.name.as_str())
228        .collect();
229    delegates.sort_unstable();
230    put("agents.delegates", join_or_none(&delegates));
231
232    // Reflex sources (reactive signal taps). `fs` is a list of watchers, so its
233    // fact is the watcher count; `cron`/`sys` are single entries with a toggle.
234    put("reflex.fs.watchers", config.reflex.fs.len().to_string());
235    put(
236        "reflex.cron.enabled",
237        config.reflex.cron.enabled.to_string(),
238    );
239    put("reflex.sys.enabled", config.reflex.sys.enabled.to_string());
240
241    // Monitored services.
242    let mut services: Vec<&str> = config
243        .monitoring
244        .services
245        .iter()
246        .map(|s| s.name.as_str())
247        .collect();
248    services.sort_unstable();
249    put("monitoring.services", join_or_none(&services));
250
251    // Security surface.
252    let mut allow: Vec<&str> = config
253        .security
254        .exec_allowlist
255        .iter()
256        .map(String::as_str)
257        .collect();
258    allow.sort_unstable();
259    put("security.exec_allowlist", join_or_none(&allow));
260    put(
261        "security.allowed_paths",
262        config.security.allowed_paths.len().to_string(),
263    );
264
265    // Live native-capability inventory — one fact per capability, keyed by verb,
266    // valued by its consent tier. A capability appearing/disappearing (e.g. when
267    // an `actions.*` toggle flips) shows up as an added/removed fact in the diff.
268    for c in inventory {
269        put(
270            &format!("capability.{}.{}", c.namespace, c.action),
271            c.tier.clone(),
272        );
273    }
274
275    f
276}
277
278fn adapter_fact(enabled: bool, port: u16) -> String {
279    if enabled {
280        format!("enabled port={port}")
281    } else {
282        "disabled".to_string()
283    }
284}
285
286/// Render a sorted list as a comma-joined string, or `(none)` when empty, so an
287/// empty list is still a stable, comparable fact value rather than a missing key.
288fn join_or_none(items: &[&str]) -> String {
289    if items.is_empty() {
290        "(none)".to_string()
291    } else {
292        items.join(", ")
293    }
294}
295
296// ─── diff ───────────────────────────────────────────────────────────────────
297
298/// Compare two baselines and render the drift. With no versions, compares the
299/// latest stored baseline against the *current* live state; `from` picks the
300/// stored baseline to compare from; `to` compares against another stored
301/// baseline instead of live state.
302pub fn diff(
303    config: &BrainConfig,
304    inventory: &[CapabilitySummary],
305    from: Option<u32>,
306    to: Option<u32>,
307) -> Result<String> {
308    let dir = baseline_dir(config);
309    let stored = list_baselines(&dir);
310    if stored.is_empty() {
311        anyhow::bail!(
312            "no baselines stored in {} — capture one first with `brain baseline capture`",
313            dir.display()
314        );
315    }
316
317    // Resolve the "from" side: an explicit version, or the latest stored.
318    let from_version = from.unwrap_or_else(|| stored.last().map(|b| b.version).unwrap());
319    let base = load_baseline(&dir, from_version)?;
320
321    // Resolve the "to" side: another stored baseline, or the current live state.
322    let (target_facts, target_label) = match to {
323        Some(v) => {
324            let b = load_baseline(&dir, v)?;
325            (
326                b.facts,
327                format!("baseline v{v} ({})", short_time(&b.captured_at)),
328            )
329        }
330        None => (
331            capture_facts(config, inventory),
332            "current live state".to_string(),
333        ),
334    };
335
336    let from_label = format!(
337        "baseline v{} ({})",
338        base.version,
339        short_time(&base.captured_at)
340    );
341    let mut out = format!("Drift: {from_label}  →  {target_label}\n");
342    if let Some(l) = &base.label {
343        out.push_str(&format!("  from label: {l}\n"));
344    }
345
346    let drift = Drift::between(&base.facts, &target_facts);
347    out.push_str(&drift.render());
348    Ok(out)
349}
350
351/// The set difference between two fact maps.
352struct Drift {
353    added: Vec<(String, String)>,
354    removed: Vec<(String, String)>,
355    /// `(key, old, new)`.
356    changed: Vec<(String, String, String)>,
357}
358
359impl Drift {
360    fn between(from: &BTreeMap<String, String>, to: &BTreeMap<String, String>) -> Drift {
361        let mut added = Vec::new();
362        let mut removed = Vec::new();
363        let mut changed = Vec::new();
364
365        for (k, new) in to {
366            match from.get(k) {
367                None => added.push((k.clone(), new.clone())),
368                Some(old) if old != new => changed.push((k.clone(), old.clone(), new.clone())),
369                Some(_) => {}
370            }
371        }
372        for (k, old) in from {
373            if !to.contains_key(k) {
374                removed.push((k.clone(), old.clone()));
375            }
376        }
377        Drift {
378            added,
379            removed,
380            changed,
381        }
382    }
383
384    fn is_empty(&self) -> bool {
385        self.added.is_empty() && self.removed.is_empty() && self.changed.is_empty()
386    }
387
388    fn render(&self) -> String {
389        if self.is_empty() {
390            return "  No drift — every fact matches.\n".to_string();
391        }
392        let mut s = String::new();
393        for (k, old, new) in &self.changed {
394            s.push_str(&format!("  ~ {k}: {old} → {new}\n"));
395        }
396        for (k, v) in &self.added {
397            s.push_str(&format!("  + {k}: {v}\n"));
398        }
399        for (k, v) in &self.removed {
400            s.push_str(&format!("  - {k}: {v}\n"));
401        }
402        s.push_str(&format!(
403            "\n  {} changed, {} added, {} removed\n",
404            self.changed.len(),
405            self.added.len(),
406            self.removed.len(),
407        ));
408        s
409    }
410}
411
412// ─── list ─────────────────────────────────────────────────────────────────
413
414/// Render the stored baseline snapshots, newest first.
415pub fn list(config: &BrainConfig) -> Result<String> {
416    let dir = baseline_dir(config);
417    let mut baselines = list_baselines(&dir);
418    if baselines.is_empty() {
419        return Ok(format!(
420            "No baselines stored in {}. Capture one with `brain baseline capture`.",
421            dir.display()
422        ));
423    }
424    baselines.reverse(); // newest first
425    let mut out = format!("Baselines in {}:\n", dir.display());
426    for b in &baselines {
427        let label = b
428            .label
429            .as_deref()
430            .map(|l| format!(" — {l}"))
431            .unwrap_or_default();
432        out.push_str(&format!(
433            "  v{:<4} {}  ({} facts){}\n",
434            b.version,
435            short_time(&b.captured_at),
436            b.facts.len(),
437            label,
438        ));
439    }
440    Ok(out)
441}
442
443// ─── storage helpers ──────────────────────────────────────────────────────────
444
445fn baseline_dir(config: &BrainConfig) -> PathBuf {
446    config.data_dir().join("baselines")
447}
448
449fn baseline_path(dir: &Path, version: u32) -> PathBuf {
450    dir.join(format!("baseline-{version:04}.json"))
451}
452
453/// All stored baselines, ascending by version. Unreadable / malformed files are
454/// skipped rather than aborting the whole command.
455fn list_baselines(dir: &Path) -> Vec<Baseline> {
456    let Ok(entries) = std::fs::read_dir(dir) else {
457        return Vec::new();
458    };
459    let mut out: Vec<Baseline> = entries
460        .flatten()
461        .map(|e| e.path())
462        .filter(|p| is_baseline_file(p))
463        .filter_map(|p| std::fs::read_to_string(&p).ok())
464        .filter_map(|s| serde_json::from_str::<Baseline>(&s).ok())
465        .collect();
466    out.sort_by_key(|b| b.version);
467    out
468}
469
470fn is_baseline_file(p: &Path) -> bool {
471    p.is_file()
472        && p.file_name()
473            .and_then(|n| n.to_str())
474            .is_some_and(|n| n.starts_with("baseline-") && n.ends_with(".json"))
475}
476
477fn load_baseline(dir: &Path, version: u32) -> Result<Baseline> {
478    let path = baseline_path(dir, version);
479    let content = std::fs::read_to_string(&path).with_context(|| {
480        format!(
481            "no baseline v{version} at {} — run `brain baseline list` to see stored versions",
482            path.display()
483        )
484    })?;
485    serde_json::from_str(&content)
486        .with_context(|| format!("parsing baseline v{version} at {}", path.display()))
487}
488
489/// The next free version = one past the highest stored version (1 when empty).
490fn next_version(dir: &Path) -> u32 {
491    list_baselines(dir)
492        .last()
493        .map(|b| b.version + 1)
494        .unwrap_or(1)
495}
496
497/// Trim an RFC3339 timestamp to `YYYY-MM-DD HH:MM` for compact display; falls
498/// back to the raw string if it isn't the shape we expect.
499fn short_time(rfc3339: &str) -> String {
500    chrono::DateTime::parse_from_rfc3339(rfc3339)
501        .map(|t| t.format("%Y-%m-%d %H:%M").to_string())
502        .unwrap_or_else(|_| rfc3339.to_string())
503}
504
505// ─── cortex backend wiring ──────────────────────────────────────────────────
506
507/// The [`cortex::actions::BaselineBackend`] implementation. Holds a snapshot of
508/// the config and the capability inventory taken at boot, and runs the
509/// deterministic capture/diff/list on each dispatch. The chat tool-loop
510/// dispatches `baseline.*` through this; the CLI calls the free functions
511/// directly (assembling the inventory itself).
512pub struct BaselineProvider {
513    config: BrainConfig,
514    inventory: Vec<CapabilitySummary>,
515}
516
517impl BaselineProvider {
518    pub fn new(config: BrainConfig, inventory: Vec<CapabilitySummary>) -> Self {
519        Self { config, inventory }
520    }
521}
522
523#[async_trait::async_trait]
524impl cortex::actions::BaselineBackend for BaselineProvider {
525    async fn capture(&self, label: Option<&str>) -> Result<String, cortex::actions::ActionError> {
526        capture(&self.config, &self.inventory, label).map_err(to_action_err)
527    }
528
529    async fn diff(
530        &self,
531        from: Option<u32>,
532        to: Option<u32>,
533    ) -> Result<String, cortex::actions::ActionError> {
534        diff(&self.config, &self.inventory, from, to).map_err(to_action_err)
535    }
536
537    async fn list(&self) -> Result<String, cortex::actions::ActionError> {
538        list(&self.config).map_err(to_action_err)
539    }
540}
541
542fn to_action_err(e: anyhow::Error) -> cortex::actions::ActionError {
543    cortex::actions::ActionError::ExecutionFailed(e.to_string())
544}
545
546#[cfg(test)]
547mod tests {
548    use super::*;
549
550    fn test_config() -> BrainConfig {
551        BrainConfig::default()
552    }
553
554    /// A minimal always-on inventory, mirroring the native set's stable members
555    /// so the capability-fact assertions hold without depending on the CLI.
556    fn test_inventory() -> Vec<CapabilitySummary> {
557        vec![
558            CapabilitySummary {
559                namespace: "memory".into(),
560                action: "store".into(),
561                tier: "write".into(),
562            },
563            CapabilitySummary {
564                namespace: "net".into(),
565                action: "http".into(),
566                tier: "external".into(),
567            },
568        ]
569    }
570
571    #[test]
572    fn capture_is_deterministic_for_one_config() {
573        let cfg = test_config();
574        let inv = test_inventory();
575        assert_eq!(capture_facts(&cfg, &inv), capture_facts(&cfg, &inv));
576    }
577
578    #[test]
579    fn capture_records_core_identity_facts() {
580        let facts = capture_facts(&test_config(), &test_inventory());
581        assert_eq!(
582            facts.get("brain.version").map(String::as_str),
583            Some(env!("CARGO_PKG_VERSION"))
584        );
585        assert_eq!(
586            facts.get("os.arch").map(String::as_str),
587            Some(std::env::consts::ARCH)
588        );
589        // Injected inventory shows up as capability facts with their tier.
590        assert!(facts.contains_key("capability.memory.store"));
591        assert_eq!(
592            facts.get("capability.memory.store").map(String::as_str),
593            Some("write")
594        );
595    }
596
597    #[test]
598    fn dropping_an_inventory_entry_drops_its_capability_fact() {
599        let cfg = test_config();
600        let with_web = capture_facts(&cfg, &test_inventory());
601        // Inventory without the web capability (mirrors web_search disabled).
602        let without_web = capture_facts(
603            &cfg,
604            &[CapabilitySummary {
605                namespace: "memory".into(),
606                action: "store".into(),
607                tier: "write".into(),
608            }],
609        );
610
611        assert!(with_web.contains_key("capability.net.http"));
612        assert!(!without_web.contains_key("capability.net.http"));
613
614        let drift = Drift::between(&with_web, &without_web);
615        assert!(drift
616            .removed
617            .iter()
618            .any(|(k, _)| k == "capability.net.http"));
619    }
620
621    #[test]
622    fn drift_between_identical_maps_is_empty() {
623        let facts = capture_facts(&test_config(), &test_inventory());
624        let drift = Drift::between(&facts, &facts);
625        assert!(drift.is_empty());
626        assert!(drift.render().contains("No drift"));
627    }
628
629    #[test]
630    fn drift_classifies_added_removed_changed() {
631        let mut from = BTreeMap::new();
632        from.insert("keep".to_string(), "same".to_string());
633        from.insert("change".to_string(), "old".to_string());
634        from.insert("gone".to_string(), "x".to_string());
635        let mut to = BTreeMap::new();
636        to.insert("keep".to_string(), "same".to_string());
637        to.insert("change".to_string(), "new".to_string());
638        to.insert("fresh".to_string(), "y".to_string());
639
640        let drift = Drift::between(&from, &to);
641        assert_eq!(drift.added, vec![("fresh".to_string(), "y".to_string())]);
642        assert_eq!(drift.removed, vec![("gone".to_string(), "x".to_string())]);
643        assert_eq!(
644            drift.changed,
645            vec![("change".to_string(), "old".to_string(), "new".to_string())]
646        );
647        assert!(!drift.is_empty());
648    }
649
650    #[test]
651    fn next_version_increments_and_round_trips() {
652        let dir = tempfile::tempdir().unwrap();
653        let p = dir.path();
654        assert_eq!(next_version(p), 1);
655
656        let b = Baseline {
657            version: 1,
658            captured_at: chrono::Utc::now().to_rfc3339(),
659            label: Some("first".into()),
660            facts: capture_facts(&test_config(), &test_inventory()),
661        };
662        std::fs::write(
663            baseline_path(p, 1),
664            serde_json::to_string_pretty(&b).unwrap(),
665        )
666        .unwrap();
667
668        assert_eq!(next_version(p), 2);
669        let loaded = load_baseline(p, 1).unwrap();
670        assert_eq!(loaded.version, 1);
671        assert_eq!(loaded.label.as_deref(), Some("first"));
672        assert_eq!(loaded.facts, b.facts);
673    }
674
675    #[test]
676    fn list_baselines_ignores_non_baseline_files_and_sorts() {
677        let dir = tempfile::tempdir().unwrap();
678        let p = dir.path();
679        for v in [3u32, 1, 2] {
680            let b = Baseline {
681                version: v,
682                captured_at: chrono::Utc::now().to_rfc3339(),
683                label: None,
684                facts: BTreeMap::new(),
685            };
686            std::fs::write(baseline_path(p, v), serde_json::to_string(&b).unwrap()).unwrap();
687        }
688        std::fs::write(p.join("notes.txt"), "ignore me").unwrap();
689        std::fs::write(p.join("baseline-bad.json"), "{not json").unwrap();
690
691        let versions: Vec<u32> = list_baselines(p).iter().map(|b| b.version).collect();
692        assert_eq!(versions, vec![1, 2, 3]);
693    }
694
695    #[test]
696    fn empty_lists_render_as_stable_none_value() {
697        // A fresh default has no manual delegates — the fact is "(none)", a
698        // comparable value, not a missing key.
699        let facts = capture_facts(&test_config(), &test_inventory());
700        assert_eq!(
701            facts.get("agents.delegates").map(String::as_str),
702            Some("(none)")
703        );
704    }
705
706    #[test]
707    fn short_time_compacts_rfc3339() {
708        assert_eq!(short_time("2026-06-09T10:11:12.345Z"), "2026-06-09 10:11");
709        // Garbage falls back to the raw string.
710        assert_eq!(short_time("not-a-time"), "not-a-time");
711    }
712}