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//! 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}