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
10//! supersedes older ones for state derivation but never erases them.
11//!
12//! Unlike the friction ledger next door, recording here is NOT
13//! best-effort: a check the ledger failed to persist must refuse —
14//! the caller believes the act was recorded, and a silently dropped
15//! check is exactly the self-report dishonesty this tier exists to
16//! end. For the same reason there is no rotation cap: check history
17//! is the substrate process state derives from, not disposable
18//! telemetry.
19//!
20//! Each record carries plan-13 provenance (actor, client, declared
21//! role) plus the entity's `content_hash` at check time. State
22//! derivation compares that hash against the current one:
23//!
24//! - no record            → `never_checked`
25//! - hash matches, ok     → `checked_ok`
26//! - hash matches, failed → `check_failed`
27//! - hash differs         → `check_stale` (whatever the verdict was,
28//!   it no longer speaks to the current content — stated, never
29//!   silently carried forward)
30
31use std::io::Write;
32use std::path::{Path, PathBuf};
33
34use serde::{Deserialize, Serialize};
35
36/// The closed verdict vocabulary. Nuance goes in the method note or
37/// in process-mem entities — never in new verdict values.
38pub const VERDICTS: [&str; 2] = ["ok", "failed"];
39
40/// A check verdict from the closed vocabulary.
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum Verdict {
43    Ok,
44    Failed,
45}
46
47impl Verdict {
48    /// Parse a wire value; `None` for anything outside the vocabulary.
49    pub fn from_wire(s: &str) -> Option<Self> {
50        match s {
51            "ok" => Some(Self::Ok),
52            "failed" => Some(Self::Failed),
53            _ => None,
54        }
55    }
56
57    pub fn as_str(self) -> &'static str {
58        match self {
59            Self::Ok => "ok",
60            Self::Failed => "failed",
61        }
62    }
63}
64
65/// One recorded check — the full ledger line.
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct CheckRecord {
68    /// Unix epoch seconds at record time.
69    pub ts: u64,
70    /// Full entity id (`mem--slug`).
71    pub entity: String,
72    /// `ok` | `failed`.
73    pub verdict: String,
74    /// Optional free-text method note ("diffed against source spec",
75    /// "re-ran the derivation").
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub method: Option<String>,
78    /// The entity's `content_hash` at check time — the staleness
79    /// baseline.
80    pub entity_hash: String,
81    /// Recorded actor identity (plan-13 provenance).
82    pub actor: String,
83    /// Recorded client identity (`name@version`), when known.
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub client: Option<String>,
86    /// The caller-declared role, or `"unspecified"` — recorded
87    /// honestly; downstream gates treat unspecified as
88    /// cannot-confirm, never as any real role.
89    pub role: String,
90}
91
92/// Derived per-entity check state.
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub enum CheckState {
95    NeverChecked,
96    CheckedOk,
97    CheckFailed,
98    CheckStale,
99}
100
101impl CheckState {
102    pub fn as_str(self) -> &'static str {
103        match self {
104            Self::NeverChecked => "never_checked",
105            Self::CheckedOk => "checked_ok",
106            Self::CheckFailed => "check_failed",
107            Self::CheckStale => "check_stale",
108        }
109    }
110}
111
112/// Derive the state from the newest record (if any) and the entity's
113/// current `content_hash`.
114pub fn derive_state(latest: Option<&CheckRecord>, current_hash: &str) -> CheckState {
115    match latest {
116        None => CheckState::NeverChecked,
117        Some(rec) if rec.entity_hash != current_hash => CheckState::CheckStale,
118        Some(rec) if rec.verdict == "failed" => CheckState::CheckFailed,
119        Some(_) => CheckState::CheckedOk,
120    }
121}
122
123/// The ledger's directory under the workspace store:
124/// `<root>/.memstead/state/checks/`.
125fn checks_dir(workspace_root: &Path) -> PathBuf {
126    workspace_root
127        .join(crate::workspace_store::WORKSPACE_STORE_DIR)
128        .join("state")
129        .join("checks")
130}
131
132/// The ledger file path for a workspace.
133pub fn check_ledger_path(workspace_root: &Path) -> PathBuf {
134    checks_dir(workspace_root).join("checks.jsonl")
135}
136
137/// Append/read handle for a workspace's check ledger.
138#[derive(Debug, Clone)]
139pub struct CheckLedger {
140    path: PathBuf,
141}
142
143impl CheckLedger {
144    pub fn for_workspace(workspace_root: &Path) -> Self {
145        Self {
146            path: check_ledger_path(workspace_root),
147        }
148    }
149
150    /// Append one record. One `write` syscall of one complete line on
151    /// an `O_APPEND` handle — concurrent writers interleave whole
152    /// lines, never tear them. Errors propagate: a check that did not
153    /// persist must refuse at the surface.
154    pub fn record(&self, rec: &CheckRecord) -> std::io::Result<()> {
155        if let Some(dir) = self.path.parent() {
156            std::fs::create_dir_all(dir)?;
157        }
158        let mut line = serde_json::to_string(rec).map_err(std::io::Error::other)?;
159        line.push('\n');
160        let mut f = std::fs::OpenOptions::new()
161            .create(true)
162            .append(true)
163            .open(&self.path)?;
164        f.write_all(line.as_bytes())
165    }
166
167    /// All records, oldest first. A missing ledger is an empty one;
168    /// unparseable lines are skipped (a torn tail must not poison the
169    /// readable history).
170    pub fn all(&self) -> Vec<CheckRecord> {
171        let Ok(content) = std::fs::read_to_string(&self.path) else {
172            return Vec::new();
173        };
174        content
175            .lines()
176            .filter_map(|l| serde_json::from_str(l).ok())
177            .collect()
178    }
179
180    /// The newest record for one entity, if any.
181    pub fn latest_for(&self, entity: &str) -> Option<CheckRecord> {
182        self.all().into_iter().rev().find(|r| r.entity == entity)
183    }
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189    use tempfile::TempDir;
190
191    fn rec(entity: &str, verdict: &str, hash: &str) -> CheckRecord {
192        CheckRecord {
193            ts: 1,
194            entity: entity.to_string(),
195            verdict: verdict.to_string(),
196            method: None,
197            entity_hash: hash.to_string(),
198            actor: "cli".to_string(),
199            client: None,
200            role: "checker".to_string(),
201        }
202    }
203
204    #[test]
205    fn state_derivation_covers_all_four_states() {
206        assert_eq!(derive_state(None, "h1"), CheckState::NeverChecked);
207        let ok = rec("m--e", "ok", "h1");
208        assert_eq!(derive_state(Some(&ok), "h1"), CheckState::CheckedOk);
209        assert_eq!(derive_state(Some(&ok), "h2"), CheckState::CheckStale);
210        let failed = rec("m--e", "failed", "h1");
211        assert_eq!(derive_state(Some(&failed), "h1"), CheckState::CheckFailed);
212        // A failed check on changed content is stale too — the verdict
213        // no longer speaks to current content either way.
214        assert_eq!(derive_state(Some(&failed), "h2"), CheckState::CheckStale);
215    }
216
217    #[test]
218    fn ledger_appends_and_serves_newest_per_entity() {
219        let tmp = TempDir::new().unwrap();
220        let ledger = CheckLedger::for_workspace(tmp.path());
221        assert!(ledger.latest_for("m--a").is_none());
222        ledger.record(&rec("m--a", "failed", "h1")).unwrap();
223        ledger.record(&rec("m--b", "ok", "h9")).unwrap();
224        ledger.record(&rec("m--a", "ok", "h2")).unwrap();
225        let latest = ledger.latest_for("m--a").unwrap();
226        assert_eq!(latest.verdict, "ok");
227        assert_eq!(latest.entity_hash, "h2");
228        // Supersession never erases: all three records remain.
229        assert_eq!(ledger.all().len(), 3);
230    }
231
232    #[test]
233    fn verdict_vocabulary_is_closed() {
234        assert!(Verdict::from_wire("ok").is_some());
235        assert!(Verdict::from_wire("failed").is_some());
236        assert!(Verdict::from_wire("passed").is_none());
237        assert!(Verdict::from_wire("OK").is_none());
238    }
239}