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