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),
18/// `unresolvable` (the source artifact is GONE: a measured failure), or
19/// `unobserved` (the pass could not observe the anchor at all, so
20/// nothing about it was measured) — honestly, never fabricating a
21/// state. The last two shared one bucket until consistency-sweep 03/05,
22/// which is why this surface, the one you reach without a binding,
23/// could not tell a measured failure from an absent measurement. Works
24/// on a hand-authored mem with no binding at all; on a binding-backed
25/// mem it reports the same states the binding verify sees (one shared
26/// resolution mechanism). No entity changes; findings are recorded for
27/// the measured conditions only, since a finding asserts something that
28/// was measured.
29///
30/// A row whose ENTITY the mem no longer holds is reported as `dangling`,
31/// its own class beside the states above: those describe the artifact
32/// end, and a vanished entity says nothing about the source. Nothing
33/// repairs it. Where the entity end could not be reconciled at all, the
34/// output says so rather than showing clean counts over state it never
35/// examined.
36///
37/// The counts never travel alone: every rendering states the
38/// `population` they were computed over and whether the axis was
39/// `fully_adjudicated`, because a resolution figure read on its own is
40/// read as health.
41#[derive(Parser, Debug)]
42pub struct Args {
43    /// Which mem to verify (by name).
44    #[arg(long = "mem", value_name = "NAME")]
45    pub mem_name: String,
46}
47
48pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
49    let cli_engine = ctx.cli_engine()?;
50    let engine = cli_engine.base();
51
52    let report = engine
53        .verify_mem_anchors(&args.mem_name)
54        .map_err(|e| anyhow::Error::from(CliError::from_engine_op(e)))?;
55
56    // Persist the flagged findings under the mem-scoped standalone
57    // key (agent-trust plan 14): a binding-less mem's verification no
58    // longer observes-and-forgets — the next pass re-serves what the
59    // previous one recorded as `already_seen`. Binding-backed stores
60    // (keyed by hash(D), own files) are untouched. An engine without
61    // a workspace root has no durable store; stated, never silent.
62    let persisted = engine
63        .workspace_root()
64        .map(|root| {
65            memstead_base::ingest::findings::record_standalone_findings(root, &report).map_err(
66                |e| {
67                    anyhow::Error::from(CliError::new(
68                        crate::output::ExitKind::Generic,
69                        "FINDINGS_STORE_ERROR",
70                        e.to_string(),
71                    ))
72                },
73            )
74        })
75        .transpose()?;
76
77    if ctx.json {
78        let findings = persisted.as_ref().map(|fs| {
79            json!({
80                "new": fs.iter().filter(|f| !f.already_seen).count(),
81                "already_seen": fs.iter().filter(|f| f.already_seen).count(),
82                "items": fs,
83            })
84        });
85        print_json(&json!({
86            // The coverage rule (memstead_base::ops::coverage): this
87            // surface answers for anchors alone and says so.
88            "verdict_coverage": crate::coverage::VERIFY_ANCHORS
89                .axis_coverage()
90                .expect("verify-anchors is a verdict surface")
91                .wire_line(),
92            "mem": report.mem,
93            "resolved": report.resolved,
94            "drifted": report.drifted,
95            "recheck": report.recheck,
96            "unresolvable": report.unresolvable,
97            "unobserved": report.unobserved,
98            "dangling": report.dangling,
99            "population": report.population_statement(),
100            "fully_adjudicated": report.fully_adjudicated(),
101            "entity_end_unreconciled": report.unreconciled,
102            "anchors": report.anchors,
103            "findings": findings,
104        }))?;
105    } else {
106        // The figures and the population they were computed over render as ONE
107        // unit (consistency-sweep 03/05, criteria 1 and 3): a count shown
108        // without what it could not adjudicate is read as health.
109        let mut out = format!(
110            "# Anchor verification — `{}`\n\n- Resolved: {}\n- Drifted: {}\n- Recheck: {}\n\
111             - Unresolvable (artifact gone): {}\n- Unobserved (not measured this pass): {}\n\
112             - Dangling (entity gone): {}\n- Population: {}\n",
113            report.mem,
114            report.resolved,
115            report.drifted,
116            report.recheck,
117            report.unresolvable,
118            report.unobserved,
119            report.dangling,
120            report.population_statement(),
121        );
122        // The coverage rule: the one axis this verdict answers for,
123        // in the output itself (memstead_base::ops::coverage).
124        if let Some(cov) = crate::coverage::VERIFY_ANCHORS.axis_coverage() {
125            out.push_str(&format!("- Verdict coverage: {}\n", cov.wire_line()));
126        }
127        // Stated both ways (consistency-sweep 03/02): a dangling count of
128        // zero means "reconciled, none found" only when the reconciliation
129        // ran, and four clean counts over a mem whose entity end was never
130        // examined are the silent-clean this campaign exists to remove.
131        if let Some(why) = &report.unreconciled {
132            out.push_str(&format!(
133                "\n> **Entity end not reconciled** — {why}. Dangling rows would not have been \
134                 detected, so the counts above describe the artifact end only.\n"
135            ));
136        }
137        if report.anchors.is_empty() {
138            out.push_str("\n_(no anchors in this mem)_\n");
139        } else {
140            // Non-resolved rows are the actionable set; resolved rows
141            // stay off the detail list so a healthy mem reads as four
142            // counts, not a table.
143            let flagged: Vec<_> = report
144                .anchors
145                .iter()
146                .filter(|a| a.state != "resolved")
147                .collect();
148            if !flagged.is_empty() {
149                out.push_str("\n## Flagged anchors\n\n");
150                for a in flagged {
151                    out.push_str(&format!(
152                        "- **{}**: `{}` → `{}` ({} {})\n",
153                        a.state, a.entity_id, a.artifact, a.class, a.grain,
154                    ));
155                }
156            }
157        }
158        match &persisted {
159            Some(fs) => {
160                let new = fs.iter().filter(|f| !f.already_seen).count();
161                let seen = fs.len() - new;
162                out.push_str(&format!(
163                    "\nFindings persisted (standalone store): {new} new, {seen} already seen.\n"
164                ));
165            }
166            None => out.push_str("\n_Findings not persisted — engine has no workspace root._\n"),
167        }
168        print_markdown(&out);
169    }
170    Ok(())
171}