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            // The caller-declared identity rides engine session state
106            // ([`Engine::set_identity`]), same as the role — absence
107            // records as absence (plan 15).
108            identity: self.current_identity().map(str::to_string),
109            // Verification records omit the kind entirely, so a
110            // kind-omitted caller's ledger lines stay byte-identical
111            // to the pre-kind shape.
112            kind: match kind {
113                CheckKind::Verification => None,
114                CheckKind::Conformance => Some(kind.as_str().to_string()),
115            },
116            schema_ref,
117        };
118        ledger
119            .record(&record)
120            .map_err(|e| EngineError::CheckNotRecorded {
121                reason: format!("ledger append failed: {e}"),
122            })?;
123        Ok(record)
124    }
125
126    /// A [`crate::ops::health::CheckStateProvider`]-shaped closure over
127    /// this engine's check ledger — the `transition_requires_checks`
128    /// constraint's window into derived verification state. One ledger
129    /// handle per closure; an engine without a workspace root derives
130    /// every entity as `never_checked`, so a declared gate refuses
131    /// honestly rather than passing unverified.
132    pub(crate) fn check_state_provider(
133        &self,
134    ) -> impl Fn(&crate::entity::Entity) -> CheckState + '_ {
135        let ledger = self.workspace_root().map(CheckLedger::for_workspace);
136        move |entity: &crate::entity::Entity| match &ledger {
137            None => CheckState::NeverChecked,
138            Some(ledger) => derive_state(
139                ledger
140                    .latest_for_kind(&entity.id.0, CheckKind::Verification)
141                    .as_ref(),
142                &entity.content_hash,
143            ),
144        }
145    }
146
147    /// Derive one entity's check state and newest check record.
148    /// Refuses typed on unknown mem/entity; an engine with no
149    /// workspace root has no check store and honestly derives
150    /// `never_checked` (no recorded checks exist).
151    pub fn entity_check_state(
152        &self,
153        mem_name: &str,
154        entity_id: &str,
155    ) -> Result<(CheckState, Option<CheckRecord>), EngineError> {
156        self.find_mount(mem_name)?;
157        let current_hash = self
158            .store
159            .all_entities()
160            .find(|e| e.mem == mem_name && e.id.0 == entity_id)
161            .map(|e| e.content_hash.clone())
162            .ok_or_else(|| EngineError::NotFound {
163                id: entity_id.to_string(),
164            })?;
165        let latest = self
166            .workspace_root()
167            .map(CheckLedger::for_workspace)
168            .and_then(|l| l.latest_for_kind(entity_id, CheckKind::Verification));
169        Ok((derive_state(latest.as_ref(), &current_hash), latest))
170    }
171
172    /// Derive one entity's `conformance` state and newest conformance
173    /// record: hash staleness plus pin staleness (a re-pinned or
174    /// unpinned mem stales the verdict — the prose it judged against
175    /// is no longer the prose in force). Same refusals as
176    /// [`Self::entity_check_state`].
177    pub fn entity_conformance_state(
178        &self,
179        mem_name: &str,
180        entity_id: &str,
181    ) -> Result<(CheckState, Option<CheckRecord>), EngineError> {
182        let mount = self.find_mount(mem_name)?;
183        let current_pin = mount.mount.schema.as_ref().map(|s| s.as_display());
184        let current_hash = self
185            .store
186            .all_entities()
187            .find(|e| e.mem == mem_name && e.id.0 == entity_id)
188            .map(|e| e.content_hash.clone())
189            .ok_or_else(|| EngineError::NotFound {
190                id: entity_id.to_string(),
191            })?;
192        let latest = self
193            .workspace_root()
194            .map(CheckLedger::for_workspace)
195            .and_then(|l| l.latest_for_kind(entity_id, CheckKind::Conformance));
196        Ok((
197            crate::check::derive_state_pinned(
198                latest.as_ref(),
199                &current_hash,
200                current_pin.as_deref(),
201            ),
202            latest,
203        ))
204    }
205}
206
207#[cfg(test)]
208mod tests {
209    use crate::check::{CheckKind, Verdict};
210    use crate::vcs::Actor;
211    use crate::workspace::MountCapability;
212
213    /// A conformance check binds to the mem's schema pin; a mem that
214    /// declares none cannot accept one. Today that refusal arrives as
215    /// the quarantine gate (an unpinned mem quarantines at boot and
216    /// serves nothing), which fires before the pin guard inside
217    /// `record_check`; the guard's own `INVALID_INPUT` remains as
218    /// defense in depth for any future backend that serves unpinned.
219    #[test]
220    fn conformance_refuses_without_a_schema_pin() {
221        let tmp = tempfile::TempDir::new().unwrap();
222        std::fs::write(
223            tmp.path().join("anything.md"),
224            "---\nid: anything\ntitle: Anything\ntype: note\n---\n\nBody.\n",
225        )
226        .unwrap();
227        let mut mount = crate::engine::test_helpers::folder_mount("m", tmp.path().to_path_buf());
228        mount.schema = None;
229        let mut engine = crate::Engine::from_mounts(vec![(
230            mount,
231            Box::new(crate::storage::FilesystemMemWriter::new(
232                tmp.path().to_path_buf(),
233            )) as Box<dyn crate::backend::MemBackend>,
234        )])
235        .unwrap();
236        let err = engine
237            .record_check(
238                "m",
239                "m--anything",
240                Verdict::Ok,
241                CheckKind::Conformance,
242                None,
243                Actor::Cli,
244                None,
245            )
246            .unwrap_err();
247        assert_eq!(err.code(), "MEM_QUARANTINED");
248    }
249
250    /// Criterion 5 complement: a read-only mount refuses a check
251    /// typed (`READ_ONLY_MOUNT`) — capability gating runs before the
252    /// entity lookup, same as every mutation-shaped guard.
253    #[test]
254    fn check_refuses_read_only_mounts_typed() {
255        let tmp = tempfile::TempDir::new().unwrap();
256        let mut mount = crate::engine::test_helpers::folder_mount("ro", tmp.path().to_path_buf());
257        mount.capability = MountCapability::ReadOnly;
258        let mut engine = crate::Engine::from_mounts(vec![(
259            mount,
260            Box::new(crate::storage::FilesystemMemWriter::new(
261                tmp.path().to_path_buf(),
262            )) as Box<dyn crate::backend::MemBackend>,
263        )])
264        .unwrap();
265        let err = engine
266            .record_check(
267                "ro",
268                "ro--anything",
269                Verdict::Ok,
270                crate::check::CheckKind::Verification,
271                None,
272                Actor::Cli,
273                None,
274            )
275            .unwrap_err();
276        assert_eq!(err.code(), "READ_ONLY_MOUNT");
277    }
278}