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: `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}