Skip to main content

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}