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