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::{CHECK_KINDS, CheckKind, 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). The vocabulary is
51    /// closed.
52    #[arg(long, conflicts_with = "from")]
53    pub kind: Option<String>,
54
55    /// Record a batch of checks from a JSON file in one engine boot:
56    /// `{"checks": [{"id": "...", "verdict": "ok", "method": "...",
57    /// "kind": "..."}, ...]}` — `method` and `kind` optional per entry,
58    /// mirroring the single form. All-or-nothing: any invalid entry
59    /// (unknown verdict or kind, missing entity) refuses the whole
60    /// batch and names EVERY failing entry; nothing is recorded.
61    #[arg(long, value_name = "PATH")]
62    pub from: Option<std::path::PathBuf>,
63}
64
65/// The `--from` file payload. `deny_unknown_fields` on both levels so a
66/// typo'd key refuses loudly instead of silently dropping data — the
67/// batch family's posture.
68#[derive(Deserialize, Debug)]
69#[serde(deny_unknown_fields)]
70struct BatchPayload {
71    checks: Vec<BatchEntry>,
72}
73
74#[derive(Deserialize, Debug)]
75#[serde(deny_unknown_fields)]
76struct BatchEntry {
77    id: String,
78    verdict: String,
79    #[serde(default)]
80    method: Option<String>,
81    #[serde(default)]
82    kind: Option<String>,
83}
84
85/// Parse a wire kind string, `None` input meaning the default
86/// verification kind. Shared by the single and batch forms so the two
87/// cannot drift.
88fn parse_kind(kind: Option<&str>) -> Result<CheckKind, CliError> {
89    match kind {
90        None => Ok(CheckKind::Verification),
91        Some(s) => CheckKind::from_wire(s).ok_or_else(|| {
92            CliError::new(
93                ExitKind::Validation,
94                "INVALID_CHECK_KIND",
95                format!(
96                    "unknown check kind {s:?} — the vocabulary is: {}",
97                    CHECK_KINDS.join(", ")
98                ),
99            )
100        }),
101    }
102}
103
104fn parse_verdict(verdict: &str) -> Result<Verdict, CliError> {
105    Verdict::from_wire(verdict).ok_or_else(|| {
106        CliError::new(
107            ExitKind::Validation,
108            "INVALID_VERDICT",
109            format!(
110                "unknown verdict {verdict:?} — the vocabulary is: {}",
111                VERDICTS.join(", ")
112            ),
113        )
114    })
115}
116
117pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
118    if let Some(path) = &args.from {
119        return run_batch(ctx, path);
120    }
121    // The single form: clap guarantees id + verdict are present when
122    // `--from` is absent.
123    let id_arg = args
124        .id
125        .as_deref()
126        .expect("clap: id required without --from");
127    let verdict_arg = args
128        .verdict
129        .as_deref()
130        .expect("clap: verdict required without --from");
131    let verdict = parse_verdict(verdict_arg)?;
132    let kind = parse_kind(args.kind.as_deref())?;
133    let id = EntityId::canonical(id_arg);
134    let mut engine = ctx.cli_engine()?.into_base();
135    let client = crate::setup::cli_client_id();
136    let record = engine
137        .record_check(
138            id.mem(),
139            id.as_ref(),
140            verdict,
141            kind,
142            args.method.as_deref(),
143            Actor::Cli,
144            Some(&client),
145        )
146        .map_err(CliError::from_engine_op)?;
147    let (state, _) = match kind {
148        CheckKind::Verification => engine.entity_check_state(id.mem(), id.as_ref()),
149        CheckKind::Conformance => engine.entity_conformance_state(id.mem(), id.as_ref()),
150    }
151    .map_err(CliError::from_engine_op)?;
152    if ctx.json {
153        print_json(&serde_json::json!({
154            "entity": record.entity,
155            "verdict": record.verdict,
156            "check_state": state.as_str(),
157            "kind": record.kind.as_deref().unwrap_or("verification"),
158            "schema_ref": record.schema_ref,
159            "role": record.role,
160            "identity": record.identity,
161            "ts": record.ts,
162            "method": record.method,
163        }))?;
164        return Ok(());
165    }
166    print_markdown(&format!(
167        "Check recorded: `{}` — kind `{}`, verdict **{}**, state `{}` (role: {})",
168        record.entity,
169        record.kind.as_deref().unwrap_or("verification"),
170        record.verdict,
171        state.as_str(),
172        record.role
173    ));
174    Ok(())
175}
176
177/// The `--from` batch: parse, validate EVERY entry, refuse atomically on
178/// any failure, then record all entries against one booted engine.
179fn run_batch(ctx: &CliContext, path: &std::path::Path) -> anyhow::Result<()> {
180    let raw = std::fs::read_to_string(path).map_err(|e| {
181        CliError::new(
182            ExitKind::Generic,
183            "INVALID_INPUT",
184            format!("cannot read --from file {}: {e}", path.display()),
185        )
186    })?;
187    let payload: BatchPayload = serde_json::from_str(&raw).map_err(|e| {
188        CliError::new(
189            ExitKind::Validation,
190            "INVALID_INPUT",
191            format!(
192                "--from payload is not the documented shape ({e}); expected \
193                 {{\"checks\": [{{\"id\", \"verdict\", \"method\"?, \"kind\"?}}, …]}}"
194            ),
195        )
196    })?;
197    if payload.checks.is_empty() {
198        return Err(CliError::new(
199            ExitKind::Validation,
200            "INVALID_INPUT",
201            "--from payload carries no checks — an empty batch records nothing",
202        )
203        .into());
204    }
205
206    let mut engine = ctx.cli_engine()?.into_base();
207
208    // Validate everything before recording anything — any failure
209    // refuses the whole batch, naming every failing entry (the batch
210    // family contract).
211    let mut parsed: Vec<(EntityId, Verdict, CheckKind, Option<String>)> = Vec::new();
212    let mut failures: Vec<serde_json::Value> = Vec::new();
213    for (i, entry) in payload.checks.iter().enumerate() {
214        let id = EntityId::canonical(&entry.id);
215        let mut entry_errors: Vec<serde_json::Value> = Vec::new();
216        let verdict = match parse_verdict(&entry.verdict) {
217            Ok(v) => Some(v),
218            Err(e) => {
219                entry_errors.push(serde_json::json!({
220                    "code": "INVALID_VERDICT",
221                    "message": e.to_string(),
222                }));
223                None
224            }
225        };
226        let kind = match parse_kind(entry.kind.as_deref()) {
227            Ok(k) => Some(k),
228            Err(e) => {
229                entry_errors.push(serde_json::json!({
230                    "code": "INVALID_CHECK_KIND",
231                    "message": e.to_string(),
232                }));
233                None
234            }
235        };
236        let exists = engine
237            .store()
238            .all_entities()
239            .any(|e| !e.stub && e.mem == id.mem() && e.id.0 == *id.as_ref());
240        if !exists {
241            entry_errors.push(serde_json::json!({
242                "code": "ENTITY_NOT_FOUND",
243                "message": format!("entity not found: {}", id.as_ref()),
244            }));
245        }
246        if entry_errors.is_empty() {
247            parsed.push((id, verdict.unwrap(), kind.unwrap(), entry.method.clone()));
248        } else {
249            failures.push(serde_json::json!({
250                "index": i,
251                "id": entry.id,
252                "errors": entry_errors,
253            }));
254        }
255    }
256    if !failures.is_empty() {
257        return Err(CliError::new(
258            ExitKind::Validation,
259            "BATCH_REFUSED",
260            format!(
261                "batch check REFUSED — {} of {} entr(ies) failed validation, nothing recorded",
262                failures.len(),
263                payload.checks.len()
264            ),
265        )
266        .with_details(serde_json::json!({ "failed_entries": failures }))
267        .into());
268    }
269
270    let client = crate::setup::cli_client_id();
271    let mut recorded: Vec<serde_json::Value> = Vec::new();
272    for (id, verdict, kind, method) in &parsed {
273        let record = engine
274            .record_check(
275                id.mem(),
276                id.as_ref(),
277                *verdict,
278                *kind,
279                method.as_deref(),
280                Actor::Cli,
281                Some(&client),
282            )
283            .map_err(CliError::from_engine_op)?;
284        recorded.push(serde_json::json!({
285            "entity": record.entity,
286            "verdict": record.verdict,
287            "kind": record.kind.as_deref().unwrap_or("verification"),
288            "schema_ref": record.schema_ref,
289            "ts": record.ts,
290        }));
291    }
292
293    if ctx.json {
294        print_json(&serde_json::json!({
295            "recorded": recorded.len(),
296            "checks": recorded,
297        }))?;
298        return Ok(());
299    }
300    let mut md = format!("# Batch check recorded — {} entr(ies)\n\n", recorded.len());
301    for r in &recorded {
302        md.push_str(&format!(
303            "- ✓ `{}` — {} ({})\n",
304            r["entity"].as_str().unwrap_or_default(),
305            r["verdict"].as_str().unwrap_or_default(),
306            r["kind"].as_str().unwrap_or_default(),
307        ));
308    }
309    print_markdown(&md);
310    Ok(())
311}