Skip to main content

memstead_cli/commands/
verify_anchors.rs

1//! `memstead verify-anchors --mem <name>` — the standalone drift
2//! statement: verify every anchor in a mem against its declared source,
3//! with no binding required. Read-only on mem CONTENT — a sidecar read
4//! plus filesystem observation, no entity touched. It is not a pure
5//! read: like any verify, a completed run records its findings store.
6
7use clap::Parser;
8use serde_json::json;
9
10use crate::CliError;
11use crate::output::{print_json, print_markdown};
12use crate::setup::CliContext;
13
14/// Verify every anchor in a mem against its declared source. Per
15/// anchor: `resolved` (source present, hash matches), `drifted`
16/// (present, hash differs, stability says drifted), `recheck` (hash
17/// differs under `unstable`, or a hash is missing on either side), or
18/// `unresolvable` (source absent, or a grain the mechanism does not
19/// reach) — honestly, never fabricating a state. Works on a
20/// hand-authored mem with no binding at all; on a binding-backed mem it
21/// reports the same states the binding verify sees (one shared
22/// resolution mechanism). No entity changes; findings are recorded.
23#[derive(Parser, Debug)]
24pub struct Args {
25    /// Which mem to verify (by name).
26    #[arg(long = "mem", value_name = "NAME")]
27    pub mem_name: String,
28}
29
30pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
31    let cli_engine = ctx.cli_engine()?;
32    let engine = cli_engine.base();
33
34    let report = engine
35        .verify_mem_anchors(&args.mem_name)
36        .map_err(|e| anyhow::Error::from(CliError::from_engine_op(e)))?;
37
38    // Persist the flagged findings under the mem-scoped standalone
39    // key (agent-trust plan 14): a binding-less mem's verification no
40    // longer observes-and-forgets — the next pass re-serves what the
41    // previous one recorded as `already_seen`. Binding-backed stores
42    // (keyed by hash(D), own files) are untouched. An engine without
43    // a workspace root has no durable store; stated, never silent.
44    let persisted = engine
45        .workspace_root()
46        .map(|root| {
47            memstead_base::ingest::findings::record_standalone_findings(root, &report).map_err(
48                |e| {
49                    anyhow::Error::from(CliError::new(
50                        crate::output::ExitKind::Generic,
51                        "FINDINGS_STORE_ERROR",
52                        e.to_string(),
53                    ))
54                },
55            )
56        })
57        .transpose()?;
58
59    if ctx.json {
60        let findings = persisted.as_ref().map(|fs| {
61            json!({
62                "new": fs.iter().filter(|f| !f.already_seen).count(),
63                "already_seen": fs.iter().filter(|f| f.already_seen).count(),
64                "items": fs,
65            })
66        });
67        print_json(&json!({
68            "mem": report.mem,
69            "resolved": report.resolved,
70            "drifted": report.drifted,
71            "recheck": report.recheck,
72            "unresolvable": report.unresolvable,
73            "anchors": report.anchors,
74            "findings": findings,
75        }))?;
76    } else {
77        let mut out = format!(
78            "# Anchor verification — `{}`\n\n- Resolved: {}\n- Drifted: {}\n- Recheck: {}\n- Unresolvable: {}\n",
79            report.mem, report.resolved, report.drifted, report.recheck, report.unresolvable,
80        );
81        if report.anchors.is_empty() {
82            out.push_str("\n_(no anchors in this mem)_\n");
83        } else {
84            // Non-resolved rows are the actionable set; resolved rows
85            // stay off the detail list so a healthy mem reads as four
86            // counts, not a table.
87            let flagged: Vec<_> = report
88                .anchors
89                .iter()
90                .filter(|a| a.state != "resolved")
91                .collect();
92            if !flagged.is_empty() {
93                out.push_str("\n## Flagged anchors\n\n");
94                for a in flagged {
95                    out.push_str(&format!(
96                        "- **{}**: `{}` → `{}` ({} {})\n",
97                        a.state, a.entity_id, a.artifact, a.class, a.grain,
98                    ));
99                }
100            }
101        }
102        match &persisted {
103            Some(fs) => {
104                let new = fs.iter().filter(|f| !f.already_seen).count();
105                let seen = fs.len() - new;
106                out.push_str(&format!(
107                    "\nFindings persisted (standalone store): {new} new, {seen} already seen.\n"
108                ));
109            }
110            None => out.push_str("\n_Findings not persisted — engine has no workspace root._\n"),
111        }
112        print_markdown(&out);
113    }
114    Ok(())
115}