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}