Skip to main content

memstead_base/engine/
check_ops.rs

1//! The check operation and derived check state (agent-trust plan 14).
2//!
3//! `record_check` is the engine-recorded act of verification: it
4//! appends one [`crate::check::CheckRecord`] — verdict, method note,
5//! the entity's `content_hash` at check time, and plan-13 provenance
6//! (actor, client, declared role) — to the workspace check ledger.
7//! Checking mutates nothing: no entity write, no mem commit, no
8//! `content_hash` change. That non-mutation is load-bearing — it is
9//! what makes check-staleness derivable by hash comparison.
10//!
11//! `entity_check_state` derives never-checked | checked-ok |
12//! check-failed | check-stale from the newest record against the
13//! entity's current hash; the derivation lives in
14//! [`crate::check::derive_state`] so surfaces and health share one
15//! implementation.
16
17use crate::check::{CheckLedger, CheckRecord, CheckState, Verdict, derive_state};
18use crate::vcs::{Actor, ClientId};
19
20use super::{Engine, error::EngineError};
21
22impl Engine {
23    /// Record a check of one entity. Refuses typed on unknown mem
24    /// (quarantine included), unknown entity, read-only mounts, and
25    /// on any persistence failure (`CHECK_NOT_RECORDED`) — recording
26    /// is never best-effort, because a caller who believes an
27    /// unrecorded check landed is the exact dishonesty this tier
28    /// removes. The declared role rides engine session state
29    /// ([`Engine::set_role`]), same as every mutation.
30    pub fn record_check(
31        &mut self,
32        mem_name: &str,
33        entity_id: &str,
34        verdict: Verdict,
35        method: Option<&str>,
36        actor: Actor,
37        client: Option<&ClientId>,
38    ) -> Result<CheckRecord, EngineError> {
39        let mount_idx = self
40            .mounts
41            .iter()
42            .position(|m| m.mount.mem == mem_name)
43            .ok_or_else(|| self.unknown_mem_error(mem_name))?;
44        if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
45            return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
46        }
47        let entity_hash = self
48            .store
49            .all_entities()
50            .find(|e| e.mem == mem_name && e.id.0 == entity_id)
51            .map(|e| e.content_hash.clone())
52            .ok_or_else(|| EngineError::NotFound {
53                id: entity_id.to_string(),
54            })?;
55        let Some(root) = self.workspace_root() else {
56            return Err(EngineError::CheckNotRecorded {
57                reason: "engine has no workspace root — no durable check store".to_string(),
58            });
59        };
60        let ledger = CheckLedger::for_workspace(root);
61        let record = CheckRecord {
62            ts: std::time::SystemTime::now()
63                .duration_since(std::time::UNIX_EPOCH)
64                .map(|d| d.as_secs())
65                .unwrap_or(0),
66            entity: entity_id.to_string(),
67            verdict: verdict.as_str().to_string(),
68            method: method
69                .map(str::trim)
70                .filter(|m| !m.is_empty())
71                .map(str::to_string),
72            entity_hash,
73            actor: actor.as_trailer().to_string(),
74            client: client.map(|c| format!("{}@{}", c.name, c.version)),
75            role: self
76                .current_role()
77                .as_trailer()
78                .unwrap_or("unspecified")
79                .to_string(),
80        };
81        ledger
82            .record(&record)
83            .map_err(|e| EngineError::CheckNotRecorded {
84                reason: format!("ledger append failed: {e}"),
85            })?;
86        Ok(record)
87    }
88
89    /// Derive one entity's check state and newest check record.
90    /// Refuses typed on unknown mem/entity; an engine with no
91    /// workspace root has no check store and honestly derives
92    /// `never_checked` (no recorded checks exist).
93    pub fn entity_check_state(
94        &self,
95        mem_name: &str,
96        entity_id: &str,
97    ) -> Result<(CheckState, Option<CheckRecord>), EngineError> {
98        self.find_mount(mem_name)?;
99        let current_hash = self
100            .store
101            .all_entities()
102            .find(|e| e.mem == mem_name && e.id.0 == entity_id)
103            .map(|e| e.content_hash.clone())
104            .ok_or_else(|| EngineError::NotFound {
105                id: entity_id.to_string(),
106            })?;
107        let latest = self
108            .workspace_root()
109            .map(CheckLedger::for_workspace)
110            .and_then(|l| l.latest_for(entity_id));
111        Ok((derive_state(latest.as_ref(), &current_hash), latest))
112    }
113}
114
115#[cfg(test)]
116mod tests {
117    use crate::check::Verdict;
118    use crate::vcs::Actor;
119    use crate::workspace::MountCapability;
120
121    /// Criterion 5 complement: a read-only mount refuses a check
122    /// typed (`READ_ONLY_MOUNT`) — capability gating runs before the
123    /// entity lookup, same as every mutation-shaped guard.
124    #[test]
125    fn check_refuses_read_only_mounts_typed() {
126        let tmp = tempfile::TempDir::new().unwrap();
127        let mut mount = crate::engine::test_helpers::folder_mount("ro", tmp.path().to_path_buf());
128        mount.capability = MountCapability::ReadOnly;
129        let mut engine = crate::Engine::from_mounts(vec![(
130            mount,
131            Box::new(crate::storage::FilesystemMemWriter::new(
132                tmp.path().to_path_buf(),
133            )) as Box<dyn crate::backend::MemBackend>,
134        )])
135        .unwrap();
136        let err = engine
137            .record_check("ro", "ro--anything", Verdict::Ok, None, Actor::Cli, None)
138            .unwrap_err();
139        assert_eq!(err.code(), "READ_ONLY_MOUNT");
140    }
141}