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//!
10//! `--observations <file>` supplies what the engine cannot observe itself:
11//! a `url` anchor's artifact, retrieved by the caller. Each row adjudicates
12//! through the same funnel a file anchor does, and the resulting state is
13//! recorded on the sidecar row (`last_observed`, sidecar version 2) so the
14//! row ages visibly from then on. The engine never fetches.
15
16use clap::Parser;
17use serde_json::json;
18
19use crate::CliError;
20use crate::output::{print_json, print_markdown};
21use crate::setup::CliContext;
22
23/// Verify every anchor in a mem against its declared source. Per
24/// anchor: `resolved` (source present, hash matches), `drifted`
25/// (present, hash differs, stability says drifted), `recheck` (hash
26/// differs under `unstable`, or a hash is missing on either side),
27/// `unresolvable` (the source artifact is GONE: a measured failure), or
28/// `unobserved` (the pass could not observe the anchor at all, so
29/// nothing about it was measured) — honestly, never fabricating a
30/// state. The last two shared one bucket until consistency-sweep 03/05,
31/// which is why this surface, the one you reach without a binding,
32/// could not tell a measured failure from an absent measurement. Works
33/// on a hand-authored mem with no binding at all; on a binding-backed
34/// mem it reports the same states the binding verify sees (one shared
35/// resolution mechanism). No entity changes; findings are recorded for
36/// the measured conditions only, since a finding asserts something that
37/// was measured.
38///
39/// A row whose ENTITY the mem no longer holds is reported as `dangling`,
40/// its own class beside the states above: those describe the artifact
41/// end, and a vanished entity says nothing about the source. Nothing
42/// repairs it. Where the entity end could not be reconciled at all, the
43/// output says so rather than showing clean counts over state it never
44/// examined.
45///
46/// The counts never travel alone: every rendering states the
47/// `population` they were computed over and whether the axis was
48/// `fully_adjudicated`, because a resolution figure read on its own is
49/// read as health.
50#[derive(Parser, Debug)]
51pub struct Args {
52 /// Which mem to verify (by name).
53 #[arg(long = "mem", value_name = "NAME")]
54 pub mem_name: String,
55
56 /// JSON file of observer-supplied observations for the anchors the
57 /// engine cannot observe itself (`url` grain; the engine never fetches).
58 /// Either a bare array or `{"observations": [...]}`; each row is
59 /// `{"artifact": "<url>", "hash": "<prepared-content hash>" | "content":
60 /// "<retrieved text>" | "absent": true, "observed_at": "<ISO-8601>"?}`
61 /// — exactly one of `hash` / `content` / `absent`, `observed_at`
62 /// defaulting to now. `content` is hashed under the same rule the write
63 /// path applies to an anchor's `content`. A url row with a supplied
64 /// observation adjudicates like a file anchor (equal hash `resolved`,
65 /// differing hash `drifted` under `stable` and `recheck` under
66 /// `unstable`, `absent` → `recheck`); a url row without one stays
67 /// `unobserved`. Matched observations are recorded on the sidecar rows
68 /// as `last_observed`, so later runs and every anchor surface show how
69 /// long each row has gone unobserved. Rows naming no url anchor of the
70 /// mem are reported as unmatched and change nothing. A malformed row
71 /// refuses the whole run with `INVALID_OBSERVATION` before any state
72 /// changes.
73 #[arg(long = "observations", value_name = "FILE")]
74 pub observations: Option<std::path::PathBuf>,
75}
76
77/// Read and validate the `--observations` file: a bare array or an object
78/// with an `observations` array. Refuses typed before the engine boots.
79fn load_observations(
80 path: &std::path::Path,
81 now: &str,
82) -> anyhow::Result<memstead_base::engine::query::SuppliedObservations> {
83 let text = std::fs::read_to_string(path).map_err(|e| {
84 CliError::new(
85 crate::output::ExitKind::Generic,
86 "INVALID_OBSERVATION",
87 format!("reading {}: {e}", path.display()),
88 )
89 })?;
90 let value: serde_json::Value = serde_json::from_str(&text).map_err(|e| {
91 CliError::new(
92 crate::output::ExitKind::Validation,
93 "INVALID_OBSERVATION",
94 format!("{} is not valid JSON: {e}", path.display()),
95 )
96 })?;
97 let rows_value = match &value {
98 serde_json::Value::Array(_) => value.clone(),
99 serde_json::Value::Object(o) if o.get("observations").is_some_and(|v| v.is_array()) => {
100 o["observations"].clone()
101 }
102 _ => {
103 return Err(CliError::new(
104 crate::output::ExitKind::Validation,
105 "INVALID_OBSERVATION",
106 format!(
107 "{} must be a JSON array of observation rows or an object with an \
108 `observations` array",
109 path.display()
110 ),
111 )
112 .into());
113 }
114 };
115 let rows: Vec<memstead_base::anchor::SuppliedObservationInput> =
116 serde_json::from_value(rows_value).map_err(|e| {
117 CliError::new(
118 crate::output::ExitKind::Validation,
119 "INVALID_OBSERVATION",
120 format!("{}: observation rows do not parse: {e}", path.display()),
121 )
122 })?;
123 memstead_base::anchor::validate_supplied_observations(&rows, now).map_err(|e| {
124 CliError::new(crate::output::ExitKind::Validation, e.code(), e.to_string())
125 .with_details(serde_json::Value::Object(e.detail().into_iter().collect()))
126 .into()
127 })
128}
129
130pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
131 // Validate the supplied observations before any engine state is touched:
132 // a malformed row refuses the run, and nothing has changed yet.
133 let now = memstead_base::engine::mutation::iso_now();
134 let supplied = match args.observations.as_deref() {
135 Some(path) => load_observations(path, &now)?,
136 None => memstead_base::engine::query::SuppliedObservations::new(),
137 };
138
139 let mut engine = ctx.cli_engine()?.into_base();
140
141 let report = engine
142 .verify_mem_anchors_with(&args.mem_name, &supplied)
143 .map_err(|e| anyhow::Error::from(CliError::from_engine_op(e)))?;
144
145 // Record the supplied observations on their url rows (`last_observed`),
146 // so the rows carry a dated state from here on and age visibly. An
147 // identical re-run stages nothing.
148 let observations_recorded = engine
149 .record_anchor_observations(
150 &args.mem_name,
151 &report.recordable_observations,
152 Some("verify-anchors: supplied observations recorded"),
153 )
154 .map_err(|e| anyhow::Error::from(CliError::from_engine_op(e)))?;
155
156 // Backfill observed hashes onto hash-less anchors, exactly as the
157 // binding-backed verify does after its pass — the engine writer skips
158 // anchors that already carry a hash, so a re-run stages nothing. Until
159 // 2026-08-31 only the binding path backfilled, so every manually
160 // re-pinned anchor on a binding-less mem read `recheck` forever and its
161 // repair waited on a verify surface the mem did not have (backlog, live
162 // melt: every manual re-pin read `hash_source: backfill`).
163 let backfill: Vec<memstead_base::anchor::ObservedArtifactHash> = report
164 .anchors
165 .iter()
166 .filter(|a| {
167 a.state == "recheck"
168 && a.observed_hash.is_some()
169 && matches!(a.class.as_str(), "anchored" | "derived")
170 })
171 .map(|a| memstead_base::anchor::ObservedArtifactHash {
172 entity: a.entity_id.clone(),
173 artifact: a.artifact.clone(),
174 hash: a.observed_hash.clone().expect("filtered on Some"),
175 })
176 .collect();
177 let backfilled = engine
178 .record_anchor_observed_hashes(
179 &args.mem_name,
180 &backfill,
181 Some("verify-anchors: first-observation hash backfill"),
182 )
183 .map_err(|e| anyhow::Error::from(CliError::from_engine_op(e)))?;
184
185 // Persist the flagged findings under the mem-scoped standalone
186 // key (agent-trust plan 14): a binding-less mem's verification no
187 // longer observes-and-forgets — the next pass re-serves what the
188 // previous one recorded as `already_seen`. Binding-backed stores
189 // (keyed by hash(D), own files) are untouched. An engine without
190 // a workspace root has no durable store; stated, never silent.
191 let persisted = engine
192 .workspace_root()
193 .map(|root| {
194 memstead_base::ingest::findings::record_standalone_findings(root, &report).map_err(
195 |e| {
196 anyhow::Error::from(CliError::new(
197 crate::output::ExitKind::Generic,
198 "FINDINGS_STORE_ERROR",
199 e.to_string(),
200 ))
201 },
202 )
203 })
204 .transpose()?;
205
206 if ctx.json {
207 let findings = persisted.as_ref().map(|fs| {
208 json!({
209 "new": fs.iter().filter(|f| !f.already_seen).count(),
210 "already_seen": fs.iter().filter(|f| f.already_seen).count(),
211 "items": fs,
212 })
213 });
214 print_json(&json!({
215 // The coverage rule (memstead_base::ops::coverage): this
216 // surface answers for anchors alone and says so.
217 "verdict_coverage": crate::coverage::VERIFY_ANCHORS
218 .axis_coverage()
219 .expect("verify-anchors is a verdict surface")
220 .wire_line(),
221 "mem": report.mem,
222 "resolved": report.resolved,
223 "drifted": report.drifted,
224 "recheck": report.recheck,
225 "unresolvable": report.unresolvable,
226 "unobserved": report.unobserved,
227 "dangling": report.dangling,
228 "population": report.population_statement(),
229 "fully_adjudicated": report.fully_adjudicated(),
230 "entity_end_unreconciled": report.unreconciled,
231 "anchors": report.anchors,
232 "hash_backfilled": backfilled,
233 "observations": {
234 "supplied": supplied.len(),
235 "matched": supplied.len() - report.unmatched_observations.len(),
236 "unmatched": report.unmatched_observations,
237 "recorded": observations_recorded,
238 },
239 "findings": findings,
240 }))?;
241 } else {
242 // The figures and the population they were computed over render as ONE
243 // unit (consistency-sweep 03/05, criteria 1 and 3): a count shown
244 // without what it could not adjudicate is read as health.
245 let mut out = format!(
246 "# Anchor verification — `{}`\n\n- Resolved: {}\n- Drifted: {}\n- Recheck: {}\n\
247 - Unresolvable (artifact gone): {}\n- Unobserved (not measured this pass): {}\n\
248 - Dangling (entity gone): {}\n- Population: {}\n",
249 report.mem,
250 report.resolved,
251 report.drifted,
252 report.recheck,
253 report.unresolvable,
254 report.unobserved,
255 report.dangling,
256 report.population_statement(),
257 );
258 // The coverage rule: the one axis this verdict answers for,
259 // in the output itself (memstead_base::ops::coverage).
260 if let Some(cov) = crate::coverage::VERIFY_ANCHORS.axis_coverage() {
261 out.push_str(&format!("- Verdict coverage: {}\n", cov.wire_line()));
262 }
263 // Stated both ways (consistency-sweep 03/02): a dangling count of
264 // zero means "reconciled, none found" only when the reconciliation
265 // ran, and four clean counts over a mem whose entity end was never
266 // examined are the silent-clean this campaign exists to remove.
267 if let Some(why) = &report.unreconciled {
268 out.push_str(&format!(
269 "\n> **Entity end not reconciled** — {why}. Dangling rows would not have been \
270 detected, so the counts above describe the artifact end only.\n"
271 ));
272 }
273 if report.anchors.is_empty() {
274 out.push_str("\n_(no anchors in this mem)_\n");
275 } else {
276 // Non-resolved rows are the actionable set; resolved rows
277 // stay off the detail list so a healthy mem reads as four
278 // counts, not a table.
279 let flagged: Vec<_> = report
280 .anchors
281 .iter()
282 .filter(|a| a.state != "resolved")
283 .collect();
284 if !flagged.is_empty() {
285 out.push_str("\n## Flagged anchors\n\n");
286 for a in flagged {
287 out.push_str(&format!(
288 "- **{}**: `{}` → `{}` ({} {})\n",
289 a.state, a.entity_id, a.artifact, a.class, a.grain,
290 ));
291 }
292 }
293 // Rows whose state rests on a recorded observation, by age: a
294 // url row is adjudicated only when someone observes it, so how
295 // long ago that was is part of what the state means.
296 let aging: Vec<_> = report
297 .anchors
298 .iter()
299 .filter(|a| a.observed_at.is_some())
300 .collect();
301 if !aging.is_empty() {
302 out.push_str("\n## Observed rows (url)\n\n");
303 for a in aging {
304 let days = a.unobserved_for_days.unwrap_or(0);
305 let age = if a.observation_supplied {
306 "observed this run".to_string()
307 } else {
308 format!("unobserved for {days} day(s)")
309 };
310 out.push_str(&format!(
311 "- **{}**: `{}` → `{}` — observed {} ({age})\n",
312 a.state,
313 a.entity_id,
314 a.artifact,
315 a.observed_at.as_deref().unwrap_or("?"),
316 ));
317 }
318 }
319 }
320 if !supplied.is_empty() {
321 out.push_str(&format!(
322 "\nObservations supplied: {}, matched {}, recorded on {} row(s).\n",
323 supplied.len(),
324 supplied.len() - report.unmatched_observations.len(),
325 observations_recorded,
326 ));
327 if !report.unmatched_observations.is_empty() {
328 out.push_str("Unmatched (no url anchor of this mem names the artifact):\n");
329 for u in &report.unmatched_observations {
330 out.push_str(&format!("- `{u}`\n"));
331 }
332 }
333 }
334 if backfilled > 0 {
335 out.push_str(&format!(
336 "\nBackfilled {backfilled} observed hash(es) onto hash-less anchors — the \
337 recheck queue drains on the next pass.\n"
338 ));
339 }
340 match &persisted {
341 Some(fs) => {
342 let new = fs.iter().filter(|f| !f.already_seen).count();
343 let seen = fs.len() - new;
344 out.push_str(&format!(
345 "\nFindings persisted (standalone store): {new} new, {seen} already seen.\n"
346 ));
347 }
348 None => out.push_str("\n_Findings not persisted — engine has no workspace root._\n"),
349 }
350 print_markdown(&out);
351 }
352 Ok(())
353}