memstead_base/check.rs
1//! Check records — the engine-recorded act of verification
2//! (agent-trust plan 14).
3//!
4//! A check is an agent recording "entity E checked, verdict ok |
5//! failed, via method M". It is engine state, never entity content:
6//! absent from markdown and `content_hash`, and it produces no mem
7//! commit — checking-touches-nothing is what makes check-staleness
8//! computable. Records are append-only JSONL under the workspace
9//! store (`.memstead/state/checks/checks.jsonl`); a newer check of
10//! the same kind supersedes older ones for state derivation but
11//! never erases them (kinds derive independently, see [`CheckKind`]).
12//!
13//! Unlike the friction ledger next door, recording here is NOT
14//! best-effort: a check the ledger failed to persist must refuse —
15//! the caller believes the act was recorded, and a silently dropped
16//! check is exactly the self-report dishonesty this tier exists to
17//! end. For the same reason there is no rotation cap: check history
18//! is the substrate process state derives from, not disposable
19//! telemetry.
20//!
21//! Each record carries plan-13 provenance (actor, client, declared
22//! role) plus the entity's `content_hash` at check time. State
23//! derivation compares that hash against the current one:
24//!
25//! - no record → `never_checked`
26//! - hash matches, ok → `checked_ok`
27//! - hash matches, failed → `check_failed`
28//! - hash differs → `check_stale` (whatever the verdict was,
29//! it no longer speaks to the current content — stated, never
30//! silently carried forward)
31//!
32//! A `conformance` record additionally carries the mem's schema pin
33//! and goes stale when the pin moves ([`derive_state_pinned`]): the
34//! prose it judged against is no longer the prose in force.
35
36use std::io::Write;
37use std::path::{Path, PathBuf};
38
39use serde::{Deserialize, Serialize};
40
41/// The closed verdict vocabulary. Nuance goes in the method note or
42/// in process-mem entities — never in new verdict values.
43pub const VERDICTS: [&str; 2] = ["ok", "failed"];
44
45/// The closed kind vocabulary. `verification` is the default and
46/// today's behaviour: "I checked this entity's content". `conformance`
47/// is the semantic judgment "this entity satisfies its type's
48/// schema prose (`write_rules` / `writing_guidance`)" — recorded with
49/// the mem's schema pin, stamped by the engine at record time, so the
50/// verdict's freshness against both the content AND the prose version
51/// stays computable. A third kind is a separate decision; closed
52/// kinds keep health aggregation well-defined, matching the closed
53/// verdict vocabulary.
54pub const CHECK_KINDS: [&str; 2] = ["verification", "conformance"];
55
56/// A check kind from the closed vocabulary.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum CheckKind {
59 Verification,
60 Conformance,
61}
62
63impl CheckKind {
64 /// Parse a wire value; `None` for anything outside the vocabulary.
65 pub fn from_wire(s: &str) -> Option<Self> {
66 match s {
67 "verification" => Some(Self::Verification),
68 "conformance" => Some(Self::Conformance),
69 _ => None,
70 }
71 }
72
73 pub fn as_str(self) -> &'static str {
74 match self {
75 Self::Verification => "verification",
76 Self::Conformance => "conformance",
77 }
78 }
79}
80
81/// A check verdict from the closed vocabulary.
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83pub enum Verdict {
84 Ok,
85 Failed,
86}
87
88impl Verdict {
89 /// Parse a wire value; `None` for anything outside the vocabulary.
90 pub fn from_wire(s: &str) -> Option<Self> {
91 match s {
92 "ok" => Some(Self::Ok),
93 "failed" => Some(Self::Failed),
94 _ => None,
95 }
96 }
97
98 pub fn as_str(self) -> &'static str {
99 match self {
100 Self::Ok => "ok",
101 Self::Failed => "failed",
102 }
103 }
104}
105
106/// One recorded check — the full ledger line.
107#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct CheckRecord {
109 /// Unix epoch seconds at record time.
110 pub ts: u64,
111 /// Full entity id (`mem--slug`).
112 pub entity: String,
113 /// `ok` | `failed`.
114 pub verdict: String,
115 /// Optional free-text method note ("diffed against source spec",
116 /// "re-ran the derivation").
117 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub method: Option<String>,
119 /// The entity's `content_hash` at check time — the staleness
120 /// baseline.
121 pub entity_hash: String,
122 /// Recorded actor identity (plan-13 provenance).
123 pub actor: String,
124 /// Recorded client identity (`name@version`), when known.
125 #[serde(default, skip_serializing_if = "Option::is_none")]
126 pub client: Option<String>,
127 /// The caller-declared role, or `"unspecified"` — recorded
128 /// honestly; downstream gates treat unspecified as
129 /// cannot-confirm, never as any real role.
130 pub role: String,
131 /// The caller-declared identity (agent-trust plan 15): an opaque
132 /// caller-chosen string, the ONLY comparator the independence
133 /// gate uses. Absent on ledger lines written before identities
134 /// existed and on identity-less callers — both downgrade every
135 /// comparison to `unconfirmable`, never to a guessed category.
136 #[serde(default, skip_serializing_if = "Option::is_none")]
137 pub identity: Option<String>,
138 /// The check kind, from [`CHECK_KINDS`]. Absent on ledger lines
139 /// written before kinds existed AND on freshly recorded
140 /// `verification` checks — both read as `verification`, so an
141 /// existing ledger upgrades with no migration and a kind-omitted
142 /// caller's lines stay byte-identical to before.
143 #[serde(default, skip_serializing_if = "Option::is_none")]
144 pub kind: Option<String>,
145 /// For `conformance` records: the mem's schema pin
146 /// (`name@x.y.z`) as stamped by the engine at record time — never
147 /// caller-supplied, so a verdict cannot claim a prose version the
148 /// caller never read. Absent on `verification` records.
149 #[serde(default, skip_serializing_if = "Option::is_none")]
150 pub schema_ref: Option<String>,
151}
152
153impl CheckRecord {
154 /// The record's kind, legacy lines included: an absent or
155 /// unrecognised kind reads as `verification`, which is exactly
156 /// what every pre-kind line was.
157 pub fn resolved_kind(&self) -> CheckKind {
158 self.kind
159 .as_deref()
160 .and_then(CheckKind::from_wire)
161 .unwrap_or(CheckKind::Verification)
162 }
163}
164
165/// Derived per-entity check state.
166#[derive(Debug, Clone, Copy, PartialEq, Eq)]
167pub enum CheckState {
168 NeverChecked,
169 CheckedOk,
170 CheckFailed,
171 CheckStale,
172}
173
174impl CheckState {
175 pub fn as_str(self) -> &'static str {
176 match self {
177 Self::NeverChecked => "never_checked",
178 Self::CheckedOk => "checked_ok",
179 Self::CheckFailed => "check_failed",
180 Self::CheckStale => "check_stale",
181 }
182 }
183}
184
185/// Derive the state from the newest record (if any) and the entity's
186/// current `content_hash`. Hash-only: this is the `verification`
187/// derivation, and stays the whole story for that kind — a schema
188/// re-pin never stales a verification verdict.
189pub fn derive_state(latest: Option<&CheckRecord>, current_hash: &str) -> CheckState {
190 derive_state_pinned(latest, current_hash, None)
191}
192
193/// Derive the state with schema-pin awareness: beyond the hash
194/// comparison, a record that carries a `schema_ref` (a `conformance`
195/// record) is stale when the mem's current pin differs from the
196/// recorded one — the prose the verdict judged against is no longer
197/// the prose in force. A mem that has since lost its pin entirely
198/// stales the verdict the same way. Records without a `schema_ref`
199/// (every `verification` record) are unaffected by the pin argument.
200pub fn derive_state_pinned(
201 latest: Option<&CheckRecord>,
202 current_hash: &str,
203 current_schema_ref: Option<&str>,
204) -> CheckState {
205 match latest {
206 None => CheckState::NeverChecked,
207 Some(rec) if rec.entity_hash != current_hash => CheckState::CheckStale,
208 Some(rec)
209 if rec.schema_ref.is_some() && rec.schema_ref.as_deref() != current_schema_ref =>
210 {
211 CheckState::CheckStale
212 }
213 Some(rec) if rec.verdict == "failed" => CheckState::CheckFailed,
214 Some(_) => CheckState::CheckedOk,
215 }
216}
217
218/// The ledger's directory under the workspace store:
219/// `<root>/.memstead/state/checks/`.
220fn checks_dir(workspace_root: &Path) -> PathBuf {
221 workspace_root
222 .join(crate::workspace_store::WORKSPACE_STORE_DIR)
223 .join("state")
224 .join("checks")
225}
226
227/// The ledger file path for a workspace.
228pub fn check_ledger_path(workspace_root: &Path) -> PathBuf {
229 checks_dir(workspace_root).join("checks.jsonl")
230}
231
232/// Append/read handle for a workspace's check ledger.
233#[derive(Debug, Clone)]
234pub struct CheckLedger {
235 path: PathBuf,
236}
237
238impl CheckLedger {
239 pub fn for_workspace(workspace_root: &Path) -> Self {
240 Self {
241 path: check_ledger_path(workspace_root),
242 }
243 }
244
245 /// Append one record. One `write` syscall of one complete line on
246 /// an `O_APPEND` handle — concurrent writers interleave whole
247 /// lines, never tear them. Errors propagate: a check that did not
248 /// persist must refuse at the surface.
249 pub fn record(&self, rec: &CheckRecord) -> std::io::Result<()> {
250 if let Some(dir) = self.path.parent() {
251 std::fs::create_dir_all(dir)?;
252 }
253 let mut line = serde_json::to_string(rec).map_err(std::io::Error::other)?;
254 line.push('\n');
255 let mut f = std::fs::OpenOptions::new()
256 .create(true)
257 .append(true)
258 .open(&self.path)?;
259 f.write_all(line.as_bytes())
260 }
261
262 /// All records, oldest first. A missing ledger is an empty one;
263 /// unparseable lines are skipped (a torn tail must not poison the
264 /// readable history).
265 pub fn all(&self) -> Vec<CheckRecord> {
266 let Ok(content) = std::fs::read_to_string(&self.path) else {
267 return Vec::new();
268 };
269 content
270 .lines()
271 .filter_map(|l| serde_json::from_str(l).ok())
272 .collect()
273 }
274
275 /// The newest record for one entity, of any kind. State
276 /// derivation is per (entity, kind) — use [`Self::latest_for_kind`]
277 /// there; this remains the "what happened last" accessor.
278 pub fn latest_for(&self, entity: &str) -> Option<CheckRecord> {
279 self.all().into_iter().rev().find(|r| r.entity == entity)
280 }
281
282 /// The newest record for one entity of one kind. A later check of
283 /// the OTHER kind never supersedes it: the two derivations answer
284 /// different questions.
285 pub fn latest_for_kind(&self, entity: &str, kind: CheckKind) -> Option<CheckRecord> {
286 self.all()
287 .into_iter()
288 .rev()
289 .find(|r| r.entity == entity && r.resolved_kind() == kind)
290 }
291}
292
293#[cfg(test)]
294mod tests {
295 use super::*;
296 use tempfile::TempDir;
297
298 fn rec(entity: &str, verdict: &str, hash: &str) -> CheckRecord {
299 CheckRecord {
300 ts: 1,
301 entity: entity.to_string(),
302 verdict: verdict.to_string(),
303 method: None,
304 entity_hash: hash.to_string(),
305 actor: "cli".to_string(),
306 client: None,
307 role: "checker".to_string(),
308 identity: None,
309 kind: None,
310 schema_ref: None,
311 }
312 }
313
314 #[test]
315 fn state_derivation_covers_all_four_states() {
316 assert_eq!(derive_state(None, "h1"), CheckState::NeverChecked);
317 let ok = rec("m--e", "ok", "h1");
318 assert_eq!(derive_state(Some(&ok), "h1"), CheckState::CheckedOk);
319 assert_eq!(derive_state(Some(&ok), "h2"), CheckState::CheckStale);
320 let failed = rec("m--e", "failed", "h1");
321 assert_eq!(derive_state(Some(&failed), "h1"), CheckState::CheckFailed);
322 // A failed check on changed content is stale too — the verdict
323 // no longer speaks to current content either way.
324 assert_eq!(derive_state(Some(&failed), "h2"), CheckState::CheckStale);
325 }
326
327 #[test]
328 fn ledger_appends_and_serves_newest_per_entity() {
329 let tmp = TempDir::new().unwrap();
330 let ledger = CheckLedger::for_workspace(tmp.path());
331 assert!(ledger.latest_for("m--a").is_none());
332 ledger.record(&rec("m--a", "failed", "h1")).unwrap();
333 ledger.record(&rec("m--b", "ok", "h9")).unwrap();
334 ledger.record(&rec("m--a", "ok", "h2")).unwrap();
335 let latest = ledger.latest_for("m--a").unwrap();
336 assert_eq!(latest.verdict, "ok");
337 assert_eq!(latest.entity_hash, "h2");
338 // Supersession never erases: all three records remain.
339 assert_eq!(ledger.all().len(), 3);
340 }
341
342 #[test]
343 fn verdict_vocabulary_is_closed() {
344 assert!(Verdict::from_wire("ok").is_some());
345 assert!(Verdict::from_wire("failed").is_some());
346 assert!(Verdict::from_wire("passed").is_none());
347 assert!(Verdict::from_wire("OK").is_none());
348 }
349
350 fn conf(entity: &str, verdict: &str, hash: &str, pin: &str) -> CheckRecord {
351 CheckRecord {
352 kind: Some("conformance".to_string()),
353 schema_ref: Some(pin.to_string()),
354 ..rec(entity, verdict, hash)
355 }
356 }
357
358 #[test]
359 fn kind_vocabulary_is_closed() {
360 assert!(CheckKind::from_wire("verification").is_some());
361 assert!(CheckKind::from_wire("conformance").is_some());
362 assert!(CheckKind::from_wire("semantic").is_none());
363 assert!(CheckKind::from_wire("Conformance").is_none());
364 }
365
366 /// Criterion 5: a pre-kind ledger line (no `kind` field) parses
367 /// and derives as a `verification` record, byte-for-byte the old
368 /// shape on the write side too.
369 #[test]
370 fn legacy_lines_read_as_verification() {
371 let legacy = r#"{"ts":1,"entity":"m--e","verdict":"ok","entity_hash":"h1","actor":"cli","role":"checker"}"#;
372 let parsed: CheckRecord = serde_json::from_str(legacy).unwrap();
373 assert_eq!(parsed.resolved_kind(), CheckKind::Verification);
374 // A freshly built verification record serialises with no kind
375 // and no schema_ref key at all.
376 let fresh = rec("m--e", "ok", "h1");
377 let line = serde_json::to_string(&fresh).unwrap();
378 assert!(!line.contains("kind"));
379 assert!(!line.contains("schema_ref"));
380 // An identity-less record carries no identity key either —
381 // pre-plan-15 lines and identity-less callers stay
382 // byte-identical (agent-trust plan 15, criterion 3).
383 assert!(!line.contains("identity"));
384 }
385
386 /// Criterion 3: state derives per (entity, kind) — a later check
387 /// of the other kind does not supersede.
388 #[test]
389 fn latest_is_per_kind() {
390 let tmp = TempDir::new().unwrap();
391 let ledger = CheckLedger::for_workspace(tmp.path());
392 ledger.record(&rec("m--a", "ok", "h1")).unwrap();
393 ledger
394 .record(&conf("m--a", "failed", "h1", "planning@1.0.0"))
395 .unwrap();
396 let v = ledger
397 .latest_for_kind("m--a", CheckKind::Verification)
398 .unwrap();
399 assert_eq!(v.verdict, "ok");
400 let c = ledger
401 .latest_for_kind("m--a", CheckKind::Conformance)
402 .unwrap();
403 assert_eq!(c.verdict, "failed");
404 assert_eq!(c.schema_ref.as_deref(), Some("planning@1.0.0"));
405 }
406
407 /// Criterion 4: a conformance verdict is stale on a content move
408 /// AND on a pin move; a verification verdict ignores pin moves.
409 #[test]
410 fn conformance_stales_on_pin_move_verification_does_not() {
411 let c = conf("m--e", "ok", "h1", "planning@1.0.0");
412 assert_eq!(
413 derive_state_pinned(Some(&c), "h1", Some("planning@1.0.0")),
414 CheckState::CheckedOk
415 );
416 assert_eq!(
417 derive_state_pinned(Some(&c), "h2", Some("planning@1.0.0")),
418 CheckState::CheckStale
419 );
420 assert_eq!(
421 derive_state_pinned(Some(&c), "h1", Some("planning@2.0.0")),
422 CheckState::CheckStale
423 );
424 // The mem losing its pin stales the verdict too.
425 assert_eq!(
426 derive_state_pinned(Some(&c), "h1", None),
427 CheckState::CheckStale
428 );
429 // Verification: unaffected by any pin argument.
430 let v = rec("m--e", "ok", "h1");
431 assert_eq!(
432 derive_state_pinned(Some(&v), "h1", Some("planning@9.0.0")),
433 CheckState::CheckedOk
434 );
435 assert_eq!(derive_state(Some(&v), "h1"), CheckState::CheckedOk);
436 }
437}