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    /// Derive one entity's check state and newest check record.
161    /// Refuses typed on unknown mem/entity; an engine with no
162    /// workspace root has no check store and honestly derives
163    /// `never_checked` (no recorded checks exist).
164    pub fn entity_check_state(
165        &self,
166        mem_name: &str,
167        entity_id: &str,
168    ) -> Result<(CheckState, Option<CheckRecord>), EngineError> {
169        self.find_mount(mem_name)?;
170        let current_hash = self
171            .store
172            .all_entities()
173            .find(|e| e.mem == mem_name && e.id.0 == entity_id)
174            .map(|e| e.content_hash.clone())
175            .ok_or_else(|| EngineError::NotFound {
176                id: entity_id.to_string(),
177            })?;
178        let latest = self
179            .workspace_root()
180            .map(CheckLedger::for_workspace)
181            .and_then(|l| l.latest_for_kind(entity_id, CheckKind::Verification));
182        Ok((derive_state(latest.as_ref(), &current_hash), latest))
183    }
184
185    /// Derive one entity's `conformance` state and newest conformance
186    /// record: hash staleness plus pin staleness (a re-pinned or
187    /// unpinned mem stales the verdict — the prose it judged against
188    /// is no longer the prose in force). Same refusals as
189    /// [`Self::entity_check_state`].
190    pub fn entity_conformance_state(
191        &self,
192        mem_name: &str,
193        entity_id: &str,
194    ) -> Result<(CheckState, Option<CheckRecord>), EngineError> {
195        let mount = self.find_mount(mem_name)?;
196        let current_pin = mount.mount.schema.as_ref().map(|s| s.as_display());
197        let current_hash = self
198            .store
199            .all_entities()
200            .find(|e| e.mem == mem_name && e.id.0 == entity_id)
201            .map(|e| e.content_hash.clone())
202            .ok_or_else(|| EngineError::NotFound {
203                id: entity_id.to_string(),
204            })?;
205        let latest = self
206            .workspace_root()
207            .map(CheckLedger::for_workspace)
208            .and_then(|l| l.latest_for_kind(entity_id, CheckKind::Conformance));
209        Ok((
210            crate::check::derive_state_pinned(
211                latest.as_ref(),
212                &current_hash,
213                current_pin.as_deref(),
214            ),
215            latest,
216        ))
217    }
218}
219
220#[cfg(test)]
221mod tests {
222    use crate::check::{CheckKind, Verdict};
223    use crate::vcs::Actor;
224    use crate::workspace::MountCapability;
225
226    /// A conformance check binds to the mem's schema pin; a mem that
227    /// declares none cannot accept one. Today that refusal arrives as
228    /// the quarantine gate (an unpinned mem quarantines at boot and
229    /// serves nothing), which fires before the pin guard inside
230    /// `record_check`; the guard's own `INVALID_INPUT` remains as
231    /// defense in depth for any future backend that serves unpinned.
232    #[test]
233    fn conformance_refuses_without_a_schema_pin() {
234        let tmp = tempfile::TempDir::new().unwrap();
235        std::fs::write(
236            tmp.path().join("anything.md"),
237            "---\nid: anything\ntitle: Anything\ntype: note\n---\n\nBody.\n",
238        )
239        .unwrap();
240        let mut mount = crate::engine::test_helpers::folder_mount("m", tmp.path().to_path_buf());
241        mount.schema = None;
242        let mut engine = crate::Engine::from_mounts(vec![(
243            mount,
244            Box::new(crate::storage::FilesystemMemWriter::new(
245                tmp.path().to_path_buf(),
246            )) as Box<dyn crate::backend::MemBackend>,
247        )])
248        .unwrap();
249        let err = engine
250            .record_check(
251                "m",
252                "m--anything",
253                Verdict::Ok,
254                CheckKind::Conformance,
255                None,
256                Actor::Cli,
257                None,
258            )
259            .unwrap_err();
260        assert_eq!(err.code(), "MEM_QUARANTINED");
261    }
262
263    /// Criterion 5 complement: a read-only mount refuses a check
264    /// typed (`READ_ONLY_MOUNT`) — capability gating runs before the
265    /// entity lookup, same as every mutation-shaped guard.
266    #[test]
267    fn check_refuses_read_only_mounts_typed() {
268        let tmp = tempfile::TempDir::new().unwrap();
269        let mut mount = crate::engine::test_helpers::folder_mount("ro", tmp.path().to_path_buf());
270        mount.capability = MountCapability::ReadOnly;
271        let mut engine = crate::Engine::from_mounts(vec![(
272            mount,
273            Box::new(crate::storage::FilesystemMemWriter::new(
274                tmp.path().to_path_buf(),
275            )) as Box<dyn crate::backend::MemBackend>,
276        )])
277        .unwrap();
278        let err = engine
279            .record_check(
280                "ro",
281                "ro--anything",
282                Verdict::Ok,
283                crate::check::CheckKind::Verification,
284                None,
285                Actor::Cli,
286                None,
287            )
288            .unwrap_err();
289        assert_eq!(err.code(), "READ_ONLY_MOUNT");
290    }
291}