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