use clap::Parser;
use serde_json::json;
use crate::CliError;
use crate::output::{print_json, print_markdown};
use crate::setup::CliContext;
#[derive(Parser, Debug)]
pub struct Args {
#[arg(long = "mem", value_name = "NAME")]
pub mem_name: String,
#[arg(long = "observations", value_name = "FILE")]
pub observations: Option<std::path::PathBuf>,
}
fn load_observations(
path: &std::path::Path,
now: &str,
) -> anyhow::Result<memstead_base::engine::query::SuppliedObservations> {
let text = std::fs::read_to_string(path).map_err(|e| {
CliError::new(
crate::output::ExitKind::Generic,
"INVALID_OBSERVATION",
format!("reading {}: {e}", path.display()),
)
})?;
let value: serde_json::Value = serde_json::from_str(&text).map_err(|e| {
CliError::new(
crate::output::ExitKind::Validation,
"INVALID_OBSERVATION",
format!("{} is not valid JSON: {e}", path.display()),
)
})?;
let rows_value = match &value {
serde_json::Value::Array(_) => value.clone(),
serde_json::Value::Object(o) if o.get("observations").is_some_and(|v| v.is_array()) => {
o["observations"].clone()
}
_ => {
return Err(CliError::new(
crate::output::ExitKind::Validation,
"INVALID_OBSERVATION",
format!(
"{} must be a JSON array of observation rows or an object with an \
`observations` array",
path.display()
),
)
.into());
}
};
let rows: Vec<memstead_base::anchor::SuppliedObservationInput> =
serde_json::from_value(rows_value).map_err(|e| {
CliError::new(
crate::output::ExitKind::Validation,
"INVALID_OBSERVATION",
format!("{}: observation rows do not parse: {e}", path.display()),
)
})?;
memstead_base::anchor::validate_supplied_observations(&rows, now).map_err(|e| {
CliError::new(crate::output::ExitKind::Validation, e.code(), e.to_string())
.with_details(serde_json::Value::Object(e.detail().into_iter().collect()))
.into()
})
}
pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
let now = memstead_base::engine::mutation::iso_now();
let supplied = match args.observations.as_deref() {
Some(path) => load_observations(path, &now)?,
None => memstead_base::engine::query::SuppliedObservations::new(),
};
let mut engine = ctx.cli_engine()?.into_base();
let report = engine
.verify_mem_anchors_with(&args.mem_name, &supplied)
.map_err(|e| anyhow::Error::from(CliError::from_engine_op(e)))?;
if let Some(why) = &report.sidecar_error {
return Err(CliError::new(
crate::output::ExitKind::Validation,
"ANCHORS_SIDECAR_UNREADABLE",
format!(
"mem `{}`: the anchors sidecar could not be read ({why}); nothing was \
measured, so no state is reported and nothing was recorded. Fix or remove \
the sidecar, then run again.",
report.mem
),
)
.with_details(json!({
"mem": report.mem,
"reason": why,
"fully_adjudicated": report.fully_adjudicated(),
"population": report.population_statement(),
"verdict_coverage": crate::coverage::VERIFY_ANCHORS
.axis_coverage()
.expect("verify-anchors is a verdict surface")
.wire_line(),
}))
.into());
}
let observations_recorded = engine
.record_anchor_observations(
&args.mem_name,
&report.recordable_observations,
Some("verify-anchors: supplied observations recorded"),
)
.map_err(|e| anyhow::Error::from(CliError::from_engine_op(e)))?;
let backfill: Vec<memstead_base::anchor::ObservedArtifactHash> = report
.anchors
.iter()
.filter(|a| {
a.state == "recheck"
&& a.observed_hash.is_some()
&& matches!(a.class.as_str(), "anchored" | "derived")
})
.map(|a| memstead_base::anchor::ObservedArtifactHash {
entity: a.entity_id.clone(),
artifact: a.artifact.clone(),
hash: a.observed_hash.clone().expect("filtered on Some"),
})
.collect();
let backfilled = engine
.record_anchor_observed_hashes(
&args.mem_name,
&backfill,
Some("verify-anchors: first-observation hash backfill"),
)
.map_err(|e| anyhow::Error::from(CliError::from_engine_op(e)))?;
let persisted = engine
.workspace_root()
.map(|root| {
memstead_base::ingest::findings::record_standalone_findings(root, &report).map_err(
|e| {
anyhow::Error::from(CliError::new(
crate::output::ExitKind::Generic,
"FINDINGS_STORE_ERROR",
e.to_string(),
))
},
)
})
.transpose()?;
if ctx.json {
let findings = persisted.as_ref().map(|fs| {
json!({
"new": fs.iter().filter(|f| !f.already_seen).count(),
"already_seen": fs.iter().filter(|f| f.already_seen).count(),
"items": fs,
})
});
print_json(&json!({
"verdict_coverage": crate::coverage::VERIFY_ANCHORS
.axis_coverage()
.expect("verify-anchors is a verdict surface")
.wire_line(),
"mem": report.mem,
"resolves": report.resolves,
"drifted": report.drifted,
"recheck": report.recheck,
"unresolvable": report.unresolvable,
"unobserved": report.unobserved,
"dangling": report.dangling,
"population": report.population_statement(),
"fully_adjudicated": report.fully_adjudicated(),
"entity_end_unreconciled": report.unreconciled,
"anchors": report.anchors,
"hash_backfilled": backfilled,
"observations": {
"supplied": supplied.len(),
"matched": supplied.len() - report.unmatched_observations.len(),
"unmatched": report.unmatched_observations,
"recorded": observations_recorded,
},
"findings": findings,
}))?;
} else {
let mut out = format!(
"# Anchor verification — `{}`\n\n- Resolves: {}\n- Drifted: {}\n- Recheck: {}\n\
- Unresolvable (artifact gone): {}\n- Unobserved (not measured this pass): {}\n\
- Dangling (entity gone): {}\n- Population: {}\n",
report.mem,
report.resolves,
report.drifted,
report.recheck,
report.unresolvable,
report.unobserved,
report.dangling,
report.population_statement(),
);
if let Some(cov) = crate::coverage::VERIFY_ANCHORS.axis_coverage() {
out.push_str(&format!("- Verdict coverage: {}\n", cov.wire_line()));
}
if let Some(why) = &report.unreconciled {
out.push_str(&format!(
"\n> **Entity end not reconciled** — {why}. Dangling rows would not have been \
detected, so the counts above describe the artifact end only.\n"
));
}
if report.anchors.is_empty() {
out.push_str("\n_(no anchors in this mem)_\n");
} else {
let flagged: Vec<_> = report
.anchors
.iter()
.filter(|a| a.state != memstead_base::anchor::AnchorState::Resolves.as_wire())
.collect();
if !flagged.is_empty() {
out.push_str("\n## Flagged anchors\n\n");
for a in flagged {
out.push_str(&format!(
"- **{}**: `{}` → `{}` ({} {})\n",
a.state, a.entity_id, a.artifact, a.class, a.grain,
));
}
}
let aging: Vec<_> = report
.anchors
.iter()
.filter(|a| a.observed_at.is_some())
.collect();
if !aging.is_empty() {
out.push_str("\n## Observed rows (url)\n\n");
for a in aging {
let days = a.unobserved_for_days.unwrap_or(0);
let age = if a.observation_supplied {
"observed this run".to_string()
} else {
format!("unobserved for {days} day(s)")
};
out.push_str(&format!(
"- **{}**: `{}` → `{}` — observed {} ({age})\n",
a.state,
a.entity_id,
a.artifact,
a.observed_at.as_deref().unwrap_or("?"),
));
}
}
}
if !supplied.is_empty() {
out.push_str(&format!(
"\nObservations supplied: {}, matched {}, recorded on {} row(s).\n",
supplied.len(),
supplied.len() - report.unmatched_observations.len(),
observations_recorded,
));
if !report.unmatched_observations.is_empty() {
out.push_str("Unmatched (no url anchor of this mem names the artifact):\n");
for u in &report.unmatched_observations {
out.push_str(&format!("- `{u}`\n"));
}
}
}
if backfilled > 0 {
out.push_str(&format!(
"\nBackfilled {backfilled} observed hash(es) onto hash-less anchors — the \
recheck queue drains on the next pass.\n"
));
}
match &persisted {
Some(fs) => {
let new = fs.iter().filter(|f| !f.already_seen).count();
let seen = fs.len() - new;
out.push_str(&format!(
"\nFindings persisted (standalone store): {new} new, {seen} already seen.\n"
));
}
None => out.push_str("\n_Findings not persisted — engine has no workspace root._\n"),
}
print_markdown(&out);
}
Ok(())
}