Skip to main content

memstead_cli/commands/
check.rs

1//! `memstead check` — record a check of one entity (agent-trust
2//! plan 14), or a batch of checks from a file.
3//!
4//! Mirrors the MCP `memstead_check` tool 1:1. A check is the
5//! engine-recorded act of verification: verdict from the closed
6//! vocabulary (`ok` | `failed`), optional method note, plan-13
7//! provenance (actor, client, the session's `--role`), and the
8//! entity's `content_hash` at check time — appended to the
9//! workspace's append-only check ledger. Checking mutates nothing:
10//! no entity write, no mem commit. Derived check state is served by
11//! `memstead entity <id> --provenance`.
12//!
13//! `--from <file>` records many checks in ONE engine boot — the batch
14//! family's contract applies: every entry is validated up front
15//! (verdict and kind vocabulary, entity existence) and any invalid
16//! entry refuses the WHOLE batch naming every failing entry; nothing
17//! is recorded on a refusal. The need is measured, not hypothetical:
18//! one campaign run paid 242 engine boots for 242 verdicts.
19
20use clap::Parser;
21use memstead_base::EntityId;
22use memstead_base::check::{CheckFinding, CheckKind, RecordKind, VERDICTS, Verdict};
23use memstead_base::vcs::Actor;
24use serde::Deserialize;
25
26use crate::CliError;
27use crate::output::{ExitKind, print_json, print_markdown};
28use crate::setup::CliContext;
29
30#[derive(Parser, Debug)]
31pub struct Args {
32    /// Full entity id (`mem--slug`) of the entity that was checked.
33    #[arg(required_unless_present = "from", conflicts_with = "from")]
34    pub id: Option<String>,
35
36    /// The verdict: `ok` | `failed`. The vocabulary is closed —
37    /// nuance goes in `--method` or in process-mem entities.
38    #[arg(long, required_unless_present = "from", conflicts_with = "from")]
39    pub verdict: Option<String>,
40
41    /// Free-text method note — how the check was performed. For a
42    /// conformance check, name the judging model here.
43    #[arg(long, conflicts_with = "from")]
44    pub method: Option<String>,
45
46    /// The check kind: `verification` (default — "I checked this
47    /// entity's content") | `conformance` (a semantic judgment
48    /// against the type's schema prose; the engine stamps the mem's
49    /// schema pin into the record, and the verdict goes stale when
50    /// the content hash moves OR the pin changes) | `x-<name>` (a
51    /// caller-declared kind the engine records verbatim and never
52    /// interprets: it stamps no pin, moves no state, and health lists
53    /// it by count). Anything else refuses `INVALID_CHECK_KIND`.
54    #[arg(long, conflicts_with = "from")]
55    pub kind: Option<String>,
56
57    /// A structured finding as JSON: `{"code": "...", "message": "...",
58    /// "section"?: "<key>", "evidence"?: "..."}`. Persisted on the
59    /// ledger line, echoed on the output, rendered by `health --include
60    /// checks` under the entity's latest verdict. `code` is your own
61    /// vocabulary; the wrapper shape is fixed and refuses unknown keys
62    /// (`INVALID_CHECK_FINDING`).
63    #[arg(long, value_name = "JSON", conflicts_with = "from")]
64    pub finding: Option<String>,
65
66    /// Record a batch of checks from a JSON file in one engine boot:
67    /// `{"checks": [{"id": "...", "verdict": "ok", "method": "...",
68    /// "kind": "...", "finding": {...}}, ...]}` — `method`, `kind` and
69    /// `finding` optional per entry, mirroring the single form. All-or-nothing: any invalid entry
70    /// (unknown verdict or kind, missing entity) refuses the whole
71    /// batch and names EVERY failing entry; nothing is recorded.
72    #[arg(long, value_name = "PATH")]
73    pub from: Option<std::path::PathBuf>,
74}
75
76/// The `--from` file payload. `deny_unknown_fields` on both levels so a
77/// typo'd key refuses loudly instead of silently dropping data — the
78/// batch family's posture.
79#[derive(Deserialize, Debug)]
80#[serde(deny_unknown_fields)]
81struct BatchPayload {
82    checks: Vec<BatchEntry>,
83}
84
85#[derive(Deserialize, Debug)]
86#[serde(deny_unknown_fields)]
87struct BatchEntry {
88    id: String,
89    verdict: String,
90    #[serde(default)]
91    method: Option<String>,
92    #[serde(default)]
93    kind: Option<String>,
94    /// Validated as a whole, so an unknown key or a missing `code` /
95    /// `message` refuses the entry rather than dropping data.
96    #[serde(default)]
97    finding: Option<serde_json::Value>,
98}
99
100/// Parse a wire kind string, `None` input meaning the default
101/// verification kind; an `x-<name>` kind is recorded verbatim. Shared by
102/// the single and batch forms so the two cannot drift.
103fn parse_kind(kind: Option<&str>) -> Result<RecordKind, CliError> {
104    match kind {
105        None => Ok(RecordKind::Engine(CheckKind::Verification)),
106        Some(s) => RecordKind::from_wire(s).ok_or_else(|| {
107            CliError::new(
108                ExitKind::Validation,
109                "INVALID_CHECK_KIND",
110                format!(
111                    "unknown check kind {s:?} — the vocabulary is: {}",
112                    RecordKind::vocabulary_hint()
113                ),
114            )
115        }),
116    }
117}
118
119/// Parse and validate an optional finding, refusing typed on any shape
120/// defect — before anything is appended.
121fn parse_finding(raw: Option<serde_json::Value>) -> Result<Option<CheckFinding>, CliError> {
122    match raw {
123        None => Ok(None),
124        Some(v) => CheckFinding::from_json(v).map(Some).map_err(|reason| {
125            CliError::new(
126                ExitKind::Validation,
127                memstead_base::check::INVALID_CHECK_FINDING_CODE,
128                reason,
129            )
130            .with_details(serde_json::json!({ "shape": CheckFinding::SHAPE }))
131        }),
132    }
133}
134
135/// The derived state the response reports: the engine kind's own state,
136/// or the verification state for a foreign kind (which moves no state).
137fn state_for(
138    engine: &memstead_base::Engine,
139    id: &EntityId,
140    kind: &RecordKind,
141) -> Result<memstead_base::check::CheckState, CliError> {
142    let (state, _) = match kind.engine_kind() {
143        Some(CheckKind::Conformance) => engine.entity_conformance_state(id.mem(), id.as_ref()),
144        _ => engine.entity_check_state(id.mem(), id.as_ref()),
145    }
146    .map_err(CliError::from_engine_op)?;
147    Ok(state)
148}
149
150fn parse_verdict(verdict: &str) -> Result<Verdict, CliError> {
151    Verdict::from_wire(verdict).ok_or_else(|| {
152        CliError::new(
153            ExitKind::Validation,
154            "INVALID_VERDICT",
155            format!(
156                "unknown verdict {verdict:?} — the vocabulary is: {}",
157                VERDICTS.join(", ")
158            ),
159        )
160    })
161}
162
163pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
164    if let Some(path) = &args.from {
165        return run_batch(ctx, path);
166    }
167    // The single form: clap guarantees id + verdict are present when
168    // `--from` is absent.
169    let id_arg = args
170        .id
171        .as_deref()
172        .expect("clap: id required without --from");
173    let verdict_arg = args
174        .verdict
175        .as_deref()
176        .expect("clap: verdict required without --from");
177    let verdict = parse_verdict(verdict_arg)?;
178    let kind = parse_kind(args.kind.as_deref())?;
179    let finding_raw = match args.finding.as_deref() {
180        None => None,
181        Some(text) => Some(
182            serde_json::from_str::<serde_json::Value>(text).map_err(|e| {
183                CliError::new(
184                    ExitKind::Validation,
185                    memstead_base::check::INVALID_CHECK_FINDING_CODE,
186                    format!(
187                        "--finding is not valid JSON ({e}) — shape {}",
188                        CheckFinding::SHAPE
189                    ),
190                )
191            })?,
192        ),
193    };
194    let finding = parse_finding(finding_raw)?;
195    let id = EntityId::canonical(id_arg);
196    let mut engine = ctx.cli_engine()?.into_base();
197    let client = crate::setup::cli_client_id();
198    let record = engine
199        .record_check_with(
200            id.mem(),
201            id.as_ref(),
202            verdict,
203            &kind,
204            args.method.as_deref(),
205            finding,
206            Actor::Cli,
207            Some(&client),
208        )
209        .map_err(CliError::from_engine_op)?;
210    let state = state_for(&engine, &id, &kind)?;
211    if ctx.json {
212        print_json(&serde_json::json!({
213            "entity": record.entity,
214            "verdict": record.verdict,
215            "check_state": state.as_str(),
216            "kind": record.kind.as_deref().unwrap_or("verification"),
217            "schema_ref": record.schema_ref,
218            "role": record.role,
219            "identity": record.identity,
220            "ts": record.ts,
221            "method": record.method,
222            "finding": record.finding,
223        }))?;
224        return Ok(());
225    }
226    print_markdown(&format!(
227        "Check recorded: `{}` — kind `{}`, verdict **{}**, state `{}` (role: {})",
228        record.entity,
229        record.kind.as_deref().unwrap_or("verification"),
230        record.verdict,
231        state.as_str(),
232        record.role
233    ));
234    Ok(())
235}
236
237/// One validated batch entry: id, verdict, kind, method, finding.
238type ParsedEntry = (
239    EntityId,
240    Verdict,
241    RecordKind,
242    Option<String>,
243    Option<CheckFinding>,
244);
245
246/// The `--from` batch: parse, validate EVERY entry, refuse atomically on
247/// any failure, then record all entries against one booted engine.
248fn run_batch(ctx: &CliContext, path: &std::path::Path) -> anyhow::Result<()> {
249    let raw = std::fs::read_to_string(path).map_err(|e| {
250        CliError::new(
251            ExitKind::Generic,
252            "INVALID_INPUT",
253            format!("cannot read --from file {}: {e}", path.display()),
254        )
255    })?;
256    let payload: BatchPayload = serde_json::from_str(&raw).map_err(|e| {
257        CliError::new(
258            ExitKind::Validation,
259            "INVALID_INPUT",
260            format!(
261                "--from payload is not the documented shape ({e}); expected \
262                 {{\"checks\": [{{\"id\", \"verdict\", \"method\"?, \"kind\"?, \"finding\"?}}, …]}}"
263            ),
264        )
265    })?;
266    if payload.checks.is_empty() {
267        return Err(CliError::new(
268            ExitKind::Validation,
269            "INVALID_INPUT",
270            "--from payload carries no checks — an empty batch records nothing",
271        )
272        .into());
273    }
274
275    let mut engine = ctx.cli_engine()?.into_base();
276
277    // Validate everything before recording anything — any failure
278    // refuses the whole batch, naming every failing entry (the batch
279    // family contract).
280    let mut parsed: Vec<ParsedEntry> = Vec::new();
281    let mut failures: Vec<serde_json::Value> = Vec::new();
282    for (i, entry) in payload.checks.iter().enumerate() {
283        let id = EntityId::canonical(&entry.id);
284        let mut entry_errors: Vec<serde_json::Value> = Vec::new();
285        let verdict = match parse_verdict(&entry.verdict) {
286            Ok(v) => Some(v),
287            Err(e) => {
288                entry_errors.push(serde_json::json!({
289                    "code": "INVALID_VERDICT",
290                    "message": e.to_string(),
291                }));
292                None
293            }
294        };
295        let kind = match parse_kind(entry.kind.as_deref()) {
296            Ok(k) => Some(k),
297            Err(e) => {
298                entry_errors.push(serde_json::json!({
299                    "code": "INVALID_CHECK_KIND",
300                    "message": e.to_string(),
301                }));
302                None
303            }
304        };
305        let finding = match parse_finding(entry.finding.clone()) {
306            Ok(f) => Some(f),
307            Err(e) => {
308                entry_errors.push(serde_json::json!({
309                    "code": memstead_base::check::INVALID_CHECK_FINDING_CODE,
310                    "message": e.to_string(),
311                }));
312                None
313            }
314        };
315        let exists = engine
316            .store()
317            .all_entities()
318            .any(|e| !e.stub && e.mem == id.mem() && e.id.0 == *id.as_ref());
319        if !exists {
320            entry_errors.push(serde_json::json!({
321                "code": "ENTITY_NOT_FOUND",
322                "message": format!("entity not found: {}", id.as_ref()),
323            }));
324        }
325        if entry_errors.is_empty() {
326            parsed.push((
327                id,
328                verdict.unwrap(),
329                kind.unwrap(),
330                entry.method.clone(),
331                finding.unwrap(),
332            ));
333        } else {
334            failures.push(serde_json::json!({
335                "index": i,
336                "id": entry.id,
337                "errors": entry_errors,
338            }));
339        }
340    }
341    if !failures.is_empty() {
342        return Err(CliError::new(
343            ExitKind::Validation,
344            "BATCH_REFUSED",
345            format!(
346                "batch check REFUSED — {} of {} entr(ies) failed validation, nothing recorded",
347                failures.len(),
348                payload.checks.len()
349            ),
350        )
351        .with_details(serde_json::json!({ "failed_entries": failures }))
352        .into());
353    }
354
355    let client = crate::setup::cli_client_id();
356    let mut recorded: Vec<serde_json::Value> = Vec::new();
357    for (id, verdict, kind, method, finding) in &parsed {
358        let record = engine
359            .record_check_with(
360                id.mem(),
361                id.as_ref(),
362                *verdict,
363                kind,
364                method.as_deref(),
365                finding.clone(),
366                Actor::Cli,
367                Some(&client),
368            )
369            .map_err(CliError::from_engine_op)?;
370        recorded.push(serde_json::json!({
371            "entity": record.entity,
372            "verdict": record.verdict,
373            "kind": record.kind.as_deref().unwrap_or("verification"),
374            "schema_ref": record.schema_ref,
375            "ts": record.ts,
376            "finding": record.finding,
377        }));
378    }
379
380    if ctx.json {
381        print_json(&serde_json::json!({
382            "recorded": recorded.len(),
383            "checks": recorded,
384        }))?;
385        return Ok(());
386    }
387    let mut md = format!("# Batch check recorded — {} entr(ies)\n\n", recorded.len());
388    for r in &recorded {
389        md.push_str(&format!(
390            "- ✓ `{}` — {} ({})\n",
391            r["entity"].as_str().unwrap_or_default(),
392            r["verdict"].as_str().unwrap_or_default(),
393            r["kind"].as_str().unwrap_or_default(),
394        ));
395    }
396    print_markdown(&md);
397    Ok(())
398}