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::{CheckKind, 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    ///
31    /// `kind` selects the closed check-kind vocabulary. A
32    /// `conformance` record is bound to the mem's schema pin, stamped
33    /// HERE from the mount — never caller-supplied, so a verdict
34    /// cannot claim a prose version the caller never read; a mem with
35    /// no pin refuses (`INVALID_INPUT`), because a semantic judgment
36    /// against no schema binds to nothing.
37    #[allow(clippy::too_many_arguments)] // the record's own fields, no natural grouping
38    pub fn record_check(
39        &mut self,
40        mem_name: &str,
41        entity_id: &str,
42        verdict: Verdict,
43        kind: CheckKind,
44        method: Option<&str>,
45        actor: Actor,
46        client: Option<&ClientId>,
47    ) -> Result<CheckRecord, EngineError> {
48        let mount_idx = self
49            .mounts
50            .iter()
51            .position(|m| m.mount.mem == mem_name)
52            .ok_or_else(|| self.unknown_mem_error(mem_name))?;
53        if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
54            return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
55        }
56        let schema_ref = match kind {
57            CheckKind::Verification => None,
58            CheckKind::Conformance => Some(
59                self.mounts[mount_idx]
60                    .mount
61                    .schema
62                    .as_ref()
63                    .map(|s| s.as_display())
64                    .ok_or_else(|| {
65                        EngineError::InvalidInput(format!(
66                            "a conformance check binds to the mem's schema pin, and mem \
67                             `{mem_name}` declares none"
68                        ))
69                    })?,
70            ),
71        };
72        let entity_hash = self
73            .store
74            .all_entities()
75            .find(|e| e.mem == mem_name && e.id.0 == entity_id)
76            .map(|e| e.content_hash.clone())
77            .ok_or_else(|| EngineError::NotFound {
78                id: entity_id.to_string(),
79            })?;
80        let Some(root) = self.workspace_root() else {
81            return Err(EngineError::CheckNotRecorded {
82                reason: "engine has no workspace root — no durable check store".to_string(),
83            });
84        };
85        let ledger = CheckLedger::for_workspace(root);
86        let record = CheckRecord {
87            ts: std::time::SystemTime::now()
88                .duration_since(std::time::UNIX_EPOCH)
89                .map(|d| d.as_secs())
90                .unwrap_or(0),
91            entity: entity_id.to_string(),
92            verdict: verdict.as_str().to_string(),
93            method: method
94                .map(str::trim)
95                .filter(|m| !m.is_empty())
96                .map(str::to_string),
97            entity_hash,
98            actor: actor.as_trailer().to_string(),
99            client: client.map(|c| format!("{}@{}", c.name, c.version)),
100            role: self
101                .current_role()
102                .as_trailer()
103                .unwrap_or("unspecified")
104                .to_string(),
105            // Verification records omit the kind entirely, so a
106            // kind-omitted caller's ledger lines stay byte-identical
107            // to the pre-kind shape.
108            kind: match kind {
109                CheckKind::Verification => None,
110                CheckKind::Conformance => Some(kind.as_str().to_string()),
111            },
112            schema_ref,
113        };
114        ledger
115            .record(&record)
116            .map_err(|e| EngineError::CheckNotRecorded {
117                reason: format!("ledger append failed: {e}"),
118            })?;
119        Ok(record)
120    }
121
122    /// Derive one entity's check state and newest check record.
123    /// Refuses typed on unknown mem/entity; an engine with no
124    /// workspace root has no check store and honestly derives
125    /// `never_checked` (no recorded checks exist).
126    pub fn entity_check_state(
127        &self,
128        mem_name: &str,
129        entity_id: &str,
130    ) -> Result<(CheckState, Option<CheckRecord>), EngineError> {
131        self.find_mount(mem_name)?;
132        let current_hash = self
133            .store
134            .all_entities()
135            .find(|e| e.mem == mem_name && e.id.0 == entity_id)
136            .map(|e| e.content_hash.clone())
137            .ok_or_else(|| EngineError::NotFound {
138                id: entity_id.to_string(),
139            })?;
140        let latest = self
141            .workspace_root()
142            .map(CheckLedger::for_workspace)
143            .and_then(|l| l.latest_for_kind(entity_id, CheckKind::Verification));
144        Ok((derive_state(latest.as_ref(), &current_hash), latest))
145    }
146
147    /// Derive one entity's `conformance` state and newest conformance
148    /// record: hash staleness plus pin staleness (a re-pinned or
149    /// unpinned mem stales the verdict — the prose it judged against
150    /// is no longer the prose in force). Same refusals as
151    /// [`Self::entity_check_state`].
152    pub fn entity_conformance_state(
153        &self,
154        mem_name: &str,
155        entity_id: &str,
156    ) -> Result<(CheckState, Option<CheckRecord>), EngineError> {
157        let mount = self.find_mount(mem_name)?;
158        let current_pin = mount.mount.schema.as_ref().map(|s| s.as_display());
159        let current_hash = self
160            .store
161            .all_entities()
162            .find(|e| e.mem == mem_name && e.id.0 == entity_id)
163            .map(|e| e.content_hash.clone())
164            .ok_or_else(|| EngineError::NotFound {
165                id: entity_id.to_string(),
166            })?;
167        let latest = self
168            .workspace_root()
169            .map(CheckLedger::for_workspace)
170            .and_then(|l| l.latest_for_kind(entity_id, CheckKind::Conformance));
171        Ok((
172            crate::check::derive_state_pinned(
173                latest.as_ref(),
174                &current_hash,
175                current_pin.as_deref(),
176            ),
177            latest,
178        ))
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use crate::check::{CheckKind, Verdict};
185    use crate::vcs::Actor;
186    use crate::workspace::MountCapability;
187
188    /// A conformance check binds to the mem's schema pin; a mem that
189    /// declares none cannot accept one. Today that refusal arrives as
190    /// the quarantine gate (an unpinned mem quarantines at boot and
191    /// serves nothing), which fires before the pin guard inside
192    /// `record_check`; the guard's own `INVALID_INPUT` remains as
193    /// defense in depth for any future backend that serves unpinned.
194    #[test]
195    fn conformance_refuses_without_a_schema_pin() {
196        let tmp = tempfile::TempDir::new().unwrap();
197        std::fs::write(
198            tmp.path().join("anything.md"),
199            "---\nid: anything\ntitle: Anything\ntype: note\n---\n\nBody.\n",
200        )
201        .unwrap();
202        let mut mount = crate::engine::test_helpers::folder_mount("m", tmp.path().to_path_buf());
203        mount.schema = None;
204        let mut engine = crate::Engine::from_mounts(vec![(
205            mount,
206            Box::new(crate::storage::FilesystemMemWriter::new(
207                tmp.path().to_path_buf(),
208            )) as Box<dyn crate::backend::MemBackend>,
209        )])
210        .unwrap();
211        let err = engine
212            .record_check(
213                "m",
214                "m--anything",
215                Verdict::Ok,
216                CheckKind::Conformance,
217                None,
218                Actor::Cli,
219                None,
220            )
221            .unwrap_err();
222        assert_eq!(err.code(), "MEM_QUARANTINED");
223    }
224
225    /// Criterion 5 complement: a read-only mount refuses a check
226    /// typed (`READ_ONLY_MOUNT`) — capability gating runs before the
227    /// entity lookup, same as every mutation-shaped guard.
228    #[test]
229    fn check_refuses_read_only_mounts_typed() {
230        let tmp = tempfile::TempDir::new().unwrap();
231        let mut mount = crate::engine::test_helpers::folder_mount("ro", tmp.path().to_path_buf());
232        mount.capability = MountCapability::ReadOnly;
233        let mut engine = crate::Engine::from_mounts(vec![(
234            mount,
235            Box::new(crate::storage::FilesystemMemWriter::new(
236                tmp.path().to_path_buf(),
237            )) as Box<dyn crate::backend::MemBackend>,
238        )])
239        .unwrap();
240        let err = engine
241            .record_check(
242                "ro",
243                "ro--anything",
244                Verdict::Ok,
245                crate::check::CheckKind::Verification,
246                None,
247                Actor::Cli,
248                None,
249            )
250            .unwrap_err();
251        assert_eq!(err.code(), "READ_ONLY_MOUNT");
252    }
253}