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        self.record_check_with(
49            mem_name,
50            entity_id,
51            verdict,
52            &crate::check::RecordKind::Engine(kind),
53            method,
54            None,
55            actor,
56            client,
57        )
58    }
59
60    /// [`Self::record_check`] with the full record shape: a kind that may
61    /// be a caller-declared foreign `x-<name>` kind (recorded verbatim,
62    /// never interpreted — it stamps no schema pin and moves no state),
63    /// and an optional structured finding (validated before anything is
64    /// appended; a malformed one refuses `INVALID_CHECK_FINDING`).
65    #[allow(clippy::too_many_arguments)]
66    pub fn record_check_with(
67        &mut self,
68        mem_name: &str,
69        entity_id: &str,
70        verdict: Verdict,
71        kind: &crate::check::RecordKind,
72        method: Option<&str>,
73        finding: Option<crate::check::CheckFinding>,
74        actor: Actor,
75        client: Option<&ClientId>,
76    ) -> Result<CheckRecord, EngineError> {
77        if let Some(f) = &finding {
78            f.validate()
79                .map_err(|reason| EngineError::InvalidCheckFinding { reason })?;
80        }
81        let mount_idx = self
82            .mounts
83            .iter()
84            .position(|m| m.mount.mem == mem_name)
85            .ok_or_else(|| self.unknown_mem_error(mem_name))?;
86        if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
87            return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
88        }
89        let schema_ref = match kind.engine_kind() {
90            None | Some(CheckKind::Verification) => None,
91            Some(CheckKind::Conformance) => Some(
92                self.mounts[mount_idx]
93                    .mount
94                    .schema
95                    .as_ref()
96                    .map(|s| s.as_display())
97                    .ok_or_else(|| {
98                        EngineError::InvalidInput(format!(
99                            "a conformance check binds to the mem's schema pin, and mem \
100                             `{mem_name}` declares none"
101                        ))
102                    })?,
103            ),
104        };
105        let entity_hash = self
106            .store
107            .all_entities()
108            .find(|e| e.mem == mem_name && e.id.0 == entity_id)
109            .map(|e| e.content_hash.clone())
110            .ok_or_else(|| EngineError::NotFound {
111                id: entity_id.to_string(),
112            })?;
113        let Some(root) = self.workspace_root() else {
114            return Err(EngineError::CheckNotRecorded {
115                reason: "engine has no workspace root — no durable check store".to_string(),
116            });
117        };
118        let ledger = CheckLedger::for_workspace(root);
119        let record = CheckRecord {
120            ts: std::time::SystemTime::now()
121                .duration_since(std::time::UNIX_EPOCH)
122                .map(|d| d.as_secs())
123                .unwrap_or(0),
124            entity: entity_id.to_string(),
125            verdict: verdict.as_str().to_string(),
126            method: method
127                .map(str::trim)
128                .filter(|m| !m.is_empty())
129                .map(str::to_string),
130            entity_hash,
131            actor: actor.as_trailer().to_string(),
132            client: client.map(|c| format!("{}@{}", c.name, c.version)),
133            role: self
134                .current_role()
135                .as_trailer()
136                .unwrap_or("unspecified")
137                .to_string(),
138            // The caller-declared identity rides engine session state
139            // ([`Engine::set_identity`]), same as the role — absence
140            // records as absence (plan 15).
141            identity: self.current_identity().map(str::to_string),
142            // Verification records omit the kind entirely, so a
143            // kind-omitted caller's ledger lines stay byte-identical
144            // to the pre-kind shape.
145            kind: match kind {
146                crate::check::RecordKind::Engine(CheckKind::Verification) => None,
147                other => Some(other.as_wire().to_string()),
148            },
149            schema_ref,
150            finding,
151        };
152        ledger
153            .record(&record)
154            .map_err(|e| EngineError::CheckNotRecorded {
155                reason: format!("ledger append failed: {e}"),
156            })?;
157        Ok(record)
158    }
159
160    /// A [`crate::ops::health::CheckStateProvider`]-shaped closure over
161    /// this engine's check ledger — the `transition_requires_checks`
162    /// constraint's window into derived verification state. One ledger
163    /// handle per closure; an engine without a workspace root derives
164    /// every entity as `never_checked`, so a declared gate refuses
165    /// honestly rather than passing unverified.
166    pub(crate) fn check_state_provider(
167        &self,
168    ) -> impl Fn(&crate::entity::Entity) -> CheckState + '_ {
169        let ledger = self.workspace_root().map(CheckLedger::for_workspace);
170        move |entity: &crate::entity::Entity| match &ledger {
171            None => CheckState::NeverChecked,
172            Some(ledger) => derive_state(
173                ledger
174                    .latest_for_kind(&entity.id.0, CheckKind::Verification)
175                    .as_ref(),
176                &entity.content_hash,
177            ),
178        }
179    }
180
181    /// Derive one entity's check state and newest check record.
182    /// Refuses typed on unknown mem/entity; an engine with no
183    /// workspace root has no check store and honestly derives
184    /// `never_checked` (no recorded checks exist).
185    pub fn entity_check_state(
186        &self,
187        mem_name: &str,
188        entity_id: &str,
189    ) -> Result<(CheckState, Option<CheckRecord>), EngineError> {
190        self.find_mount(mem_name)?;
191        let current_hash = self
192            .store
193            .all_entities()
194            .find(|e| e.mem == mem_name && e.id.0 == entity_id)
195            .map(|e| e.content_hash.clone())
196            .ok_or_else(|| EngineError::NotFound {
197                id: entity_id.to_string(),
198            })?;
199        let latest = self
200            .workspace_root()
201            .map(CheckLedger::for_workspace)
202            .and_then(|l| l.latest_for_kind(entity_id, CheckKind::Verification));
203        Ok((derive_state(latest.as_ref(), &current_hash), latest))
204    }
205
206    /// Derive one entity's `conformance` state and newest conformance
207    /// record: hash staleness plus pin staleness (a re-pinned or
208    /// unpinned mem stales the verdict — the prose it judged against
209    /// is no longer the prose in force). Same refusals as
210    /// [`Self::entity_check_state`].
211    pub fn entity_conformance_state(
212        &self,
213        mem_name: &str,
214        entity_id: &str,
215    ) -> Result<(CheckState, Option<CheckRecord>), EngineError> {
216        let mount = self.find_mount(mem_name)?;
217        let current_pin = mount.mount.schema.as_ref().map(|s| s.as_display());
218        let current_hash = self
219            .store
220            .all_entities()
221            .find(|e| e.mem == mem_name && e.id.0 == entity_id)
222            .map(|e| e.content_hash.clone())
223            .ok_or_else(|| EngineError::NotFound {
224                id: entity_id.to_string(),
225            })?;
226        let latest = self
227            .workspace_root()
228            .map(CheckLedger::for_workspace)
229            .and_then(|l| l.latest_for_kind(entity_id, CheckKind::Conformance));
230        Ok((
231            crate::check::derive_state_pinned(
232                latest.as_ref(),
233                &current_hash,
234                current_pin.as_deref(),
235            ),
236            latest,
237        ))
238    }
239}
240
241#[cfg(test)]
242mod tests {
243    use crate::check::{CheckKind, Verdict};
244    use crate::vcs::Actor;
245    use crate::workspace::MountCapability;
246
247    /// A conformance check binds to the mem's schema pin; a mem that
248    /// declares none cannot accept one. Today that refusal arrives as
249    /// the quarantine gate (an unpinned mem quarantines at boot and
250    /// serves nothing), which fires before the pin guard inside
251    /// `record_check`; the guard's own `INVALID_INPUT` remains as
252    /// defense in depth for any future backend that serves unpinned.
253    #[test]
254    fn conformance_refuses_without_a_schema_pin() {
255        let tmp = tempfile::TempDir::new().unwrap();
256        std::fs::write(
257            tmp.path().join("anything.md"),
258            "---\nid: anything\ntitle: Anything\ntype: note\n---\n\nBody.\n",
259        )
260        .unwrap();
261        let mut mount = crate::engine::test_helpers::folder_mount("m", tmp.path().to_path_buf());
262        mount.schema = None;
263        let mut engine = crate::Engine::from_mounts(vec![(
264            mount,
265            Box::new(crate::storage::FilesystemMemWriter::new(
266                tmp.path().to_path_buf(),
267            )) as Box<dyn crate::backend::MemBackend>,
268        )])
269        .unwrap();
270        let err = engine
271            .record_check(
272                "m",
273                "m--anything",
274                Verdict::Ok,
275                CheckKind::Conformance,
276                None,
277                Actor::Cli,
278                None,
279            )
280            .unwrap_err();
281        assert_eq!(err.code(), "MEM_QUARANTINED");
282    }
283
284    /// Criterion 5 complement: a read-only mount refuses a check
285    /// typed (`READ_ONLY_MOUNT`) — capability gating runs before the
286    /// entity lookup, same as every mutation-shaped guard.
287    #[test]
288    fn check_refuses_read_only_mounts_typed() {
289        let tmp = tempfile::TempDir::new().unwrap();
290        let mut mount = crate::engine::test_helpers::folder_mount("ro", tmp.path().to_path_buf());
291        mount.capability = MountCapability::ReadOnly;
292        let mut engine = crate::Engine::from_mounts(vec![(
293            mount,
294            Box::new(crate::storage::FilesystemMemWriter::new(
295                tmp.path().to_path_buf(),
296            )) as Box<dyn crate::backend::MemBackend>,
297        )])
298        .unwrap();
299        let err = engine
300            .record_check(
301                "ro",
302                "ro--anything",
303                Verdict::Ok,
304                crate::check::CheckKind::Verification,
305                None,
306                Actor::Cli,
307                None,
308            )
309            .unwrap_err();
310        assert_eq!(err.code(), "READ_ONLY_MOUNT");
311    }
312}