Skip to main content

memstead_base/engine/
review.rs

1//! Review marks — one per-mem pointer to the last human-approved
2//! state (the operator's review model: diffs accumulate against it,
3//! approving moves it, ignoring it entirely is first-class).
4//!
5//! The mark's value vocabulary is deliberately the existing
6//! backend-opaque `changes_since` cursor (git-branch: commit SHA;
7//! folder: changelog RFC3339-millis timestamp) — no second
8//! state-naming scheme. Storage is mem-repo state via
9//! `MemConfig.review_mark` (the `sync_state` precedent): it rides
10//! reloads, survives cache wipes, is visible to every sibling process
11//! opening the workspace, and is stripped from published archives by
12//! the `PublishedMemConfig` allowlist.
13//!
14//! Marks never gate: no mutation path consults them. `set` takes an
15//! explicit target only — never an implicit "now", because writers may
16//! have advanced the mem mid-review.
17
18use serde::Serialize;
19
20use super::{Engine, EngineError};
21
22/// One mem's review-mark status, alongside its current head so a
23/// single list call answers "what has un-reviewed changes".
24#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
25pub struct ReviewMarkStatus {
26    pub mem: String,
27    /// The last human-approved state; `None` is the ordinary markless
28    /// state.
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub mark: Option<String>,
31    /// Current head cursor (`None` for backends without one).
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub head: Option<String>,
34    /// Whether the mem is writable (marks on read-only mounts are
35    /// visible but not settable).
36    pub writable: bool,
37}
38
39/// Successful outcome of [`Engine::set_review_mark`].
40#[derive(Debug, Clone, Serialize)]
41pub struct SetReviewMarkOutcome {
42    pub mem: String,
43    /// The mark after this call (`None` = cleared).
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub mark: Option<String>,
46    /// The mark before this call.
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub previous: Option<String>,
49    /// Typed non-fatal issues (NOTE_MISSING under require-notes,
50    /// MEM_RELOADED from the pre-write drift probe).
51    pub warnings: Vec<crate::ops::WarningHint>,
52}
53
54impl Engine {
55    /// Every mem's review mark (or its absence) with the current head.
56    /// Markless mems are ordinary entries, never errors.
57    pub fn review_marks(&self) -> Vec<ReviewMarkStatus> {
58        self.mounts
59            .iter()
60            .map(|m| ReviewMarkStatus {
61                mem: m.mount.mem.clone(),
62                mark: m.mem_config.as_ref().and_then(|c| c.review_mark.clone()),
63                head: m.backend.current_head().ok().flatten(),
64                writable: m.mount.capability == crate::workspace::MountCapability::Write,
65            })
66            .collect()
67    }
68
69    /// Set (or clear, with `target: None`) a mem's review mark to an
70    /// explicitly named state. The target is validated against the
71    /// backend's cursor vocabulary before anything is written —
72    /// git-branch cursors must resolve to a known commit, folder
73    /// cursors must parse as the changelog's RFC3339 timestamp shape —
74    /// and an invalid target refuses with `INVALID_CURSOR`, leaving
75    /// the mark untouched. Provenance (note gating, warn-and-commit)
76    /// mirrors `set_mem_sync_state`; the config write commits with
77    /// the caller's note.
78    pub fn set_review_mark(
79        &mut self,
80        mem_name: &str,
81        target: Option<&str>,
82        note: Option<&str>,
83    ) -> Result<SetReviewMarkOutcome, EngineError> {
84        let mount_idx = self
85            .mounts
86            .iter()
87            .position(|m| m.mount.mem == mem_name)
88            .ok_or_else(|| self.unknown_mem_error(mem_name))?;
89        if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
90            return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
91        }
92
93        // Validate the explicit target against the backend's cursor
94        // vocabulary BEFORE any write. Clearing needs no validation.
95        if let Some(target) = target {
96            self.validate_review_cursor(mount_idx, mem_name, target)?;
97        }
98
99        // Same posture as every other commit-producing mutation.
100        let mut warnings = self.reload_if_stale(Some(mem_name));
101        if let Some(w) = self.note_missing_warning("set_review_mark", note) {
102            warnings.push(w);
103        }
104
105        // `previous` comes from the config this write lands on, not the
106        // cache: a sibling reviewer may have moved the mark since boot, and
107        // reporting the cached value would name a mark nobody is at.
108        let seen: std::cell::RefCell<Option<String>> = std::cell::RefCell::new(None);
109        let target_owned = target.map(str::to_string);
110        let (_, intervened) = self.write_mem_config_merged(
111            mount_idx,
112            mem_name,
113            note,
114            &|c: &mut memstead_schema::config::MemConfig| {
115                *seen.borrow_mut() = c.review_mark.clone();
116                c.review_mark = target_owned.clone();
117            },
118        )?;
119        let previous = seen.into_inner();
120        if !intervened.is_empty() {
121            warnings.push(crate::ops::WarningHint::ConfigWriteIntervened {
122                mem: mem_name.to_string(),
123                fields: intervened,
124            });
125        }
126
127        Ok(SetReviewMarkOutcome {
128            mem: mem_name.to_string(),
129            mark: target.map(str::to_string),
130            previous,
131            warnings,
132        })
133    }
134
135    /// The accumulated per-entity delta from the mem's review mark to
136    /// its current head — exactly the envelopes `changes_since`
137    /// reports for the mark's cursor. A markless mem refuses with
138    /// `REVIEW_MARK_NOT_SET` (marklessness is known from the roster; a
139    /// silent empty answer would equate "no mark" with "no changes").
140    pub fn review_mark_diff(
141        &self,
142        mem_name: &str,
143        rename_similarity: Option<f32>,
144    ) -> Result<crate::ops::ChangesReport, EngineError> {
145        let mount = self
146            .mounts
147            .iter()
148            .find(|m| m.mount.mem == mem_name)
149            .ok_or_else(|| self.unknown_mem_error(mem_name))?;
150        let mark = mount
151            .mem_config
152            .as_ref()
153            .and_then(|c| c.review_mark.clone())
154            .ok_or_else(|| EngineError::ReviewMarkNotSet {
155                mem: mem_name.to_string(),
156            })?;
157        self.changes_since(mem_name, &mark, rename_similarity)
158    }
159
160    /// Backend-vocabulary validation for an explicit mark target.
161    fn validate_review_cursor(
162        &self,
163        mount_idx: usize,
164        mem_name: &str,
165        target: &str,
166    ) -> Result<(), EngineError> {
167        use crate::workspace::MountStorage;
168        let invalid = || EngineError::InvalidChangesCursor {
169            mem: mem_name.to_string(),
170            since: target.to_string(),
171        };
172        match &self.mounts[mount_idx].mount.storage {
173            MountStorage::Folder { .. } => {
174                // Folder cursors are the changelog's RFC3339-millis
175                // timestamps; format-level validation (existence is not
176                // required — any parseable instant is a legal `since`).
177                if crate::filesystem::changelog::parse_rfc3339_utc(target).is_none() {
178                    return Err(invalid());
179                }
180                Ok(())
181            }
182            MountStorage::GitBranch { .. } => {
183                // A git-branch cursor must resolve to a known commit —
184                // `changes_since` is the authoritative resolver and
185                // already refuses unknown SHAs with INVALID_CURSOR.
186                self.changes_since(mem_name, target, None).map(|_| ())
187            }
188            MountStorage::Archive { .. } | MountStorage::InMemory => {
189                // Unreachable through set (capability gate refuses
190                // first); refuse defensively for direct callers.
191                Err(invalid())
192            }
193        }
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use crate::storage::MemWriter;
200
201    const SEED: &str = "---\ntype: spec\ncreated_date: 2026-01-01\nlast_modified: 2026-01-01\nlevel: M0\n---\n# Seed\n\n## Identity\n\nSeed.\n";
202
203    fn folder_engine(tmp: &tempfile::TempDir) -> crate::Engine {
204        let dir = tmp.path().join("specs");
205        if !dir.exists() {
206            std::fs::create_dir_all(&dir).unwrap();
207            let writer = crate::storage::FilesystemMemWriter::new(dir.clone());
208            MemWriter::write_entity(&writer, std::path::Path::new("seed.md"), SEED.as_bytes())
209                .unwrap();
210            MemWriter::commit(&writer, "seed", &crate::vcs::CommitContext::internal()).unwrap();
211            crate::backend::MemBackend::append_provenance(
212                &writer,
213                &crate::provenance::Provenance::new(
214                    std::time::SystemTime::now(),
215                    crate::provenance::ProvenanceKind::Create,
216                    Some("specs--seed".into()),
217                    crate::vcs::Actor::Cli,
218                    None,
219                    None,
220                ),
221            )
222            .unwrap();
223            // A config file so set_review_mark has a MemConfig to carry
224            // the mark (mirrors an initialized mem).
225            let config = memstead_schema::config::MemConfig {
226                format: None,
227                name: None,
228                title: None,
229                subject: None,
230                version: None,
231                description: None,
232                authors: None,
233                schema: Some("default@1.0.0".parse().unwrap()),
234                write_guidance: Default::default(),
235                process_mem: None,
236                rules: None,
237                publish: None,
238                language: None,
239                read_mems: Default::default(),
240                community: None,
241                vcs: None,
242                unregistered_at: None,
243                sync_state: Default::default(),
244                review_mark: None,
245                mutation_stamp: None,
246                extra: Default::default(),
247            };
248            let meta = dir.join(memstead_schema::MEM_META_DIR);
249            std::fs::create_dir_all(&meta).unwrap();
250            std::fs::write(
251                meta.join("config.json"),
252                serde_json::to_vec_pretty(&config).unwrap(),
253            )
254            .unwrap();
255        }
256        let mount = crate::Mount {
257            mem: "specs".to_string(),
258            schema: Some(memstead_schema::SchemaRef::new(
259                "default",
260                semver::Version::new(1, 0, 0),
261            )),
262            storage: crate::MountStorage::Folder { path: dir.clone() },
263            capability: crate::MountCapability::Write,
264            lifecycle: crate::MountLifecycle::Eager,
265            cross_linkable: false,
266            migration_target: None,
267        };
268        let backend =
269            Box::new(crate::storage::FilesystemMemWriter::new(dir)) as Box<dyn crate::MemBackend>;
270        crate::Engine::from_mounts(vec![(mount, backend)]).unwrap()
271    }
272
273    fn head(engine: &crate::Engine) -> String {
274        engine
275            .review_marks()
276            .into_iter()
277            .find(|s| s.mem == "specs")
278            .and_then(|s| s.head)
279            .expect("folder head cursor")
280    }
281
282    #[test]
283    fn mark_lifecycle_persists_validates_and_never_gates() {
284        let tmp = tempfile::TempDir::new().unwrap();
285        let mut engine = folder_engine(&tmp);
286
287        // Markless start: ordinary state, listed as such.
288        let status = &engine.review_marks()[0];
289        assert_eq!(status.mem, "specs");
290        assert!(status.mark.is_none());
291        assert!(status.writable);
292
293        // Invalid cursor refuses typed, mark untouched.
294        let err = engine
295            .set_review_mark("specs", Some("not-a-timestamp"), None)
296            .unwrap_err();
297        assert_eq!(err.code(), "INVALID_CURSOR");
298        assert!(engine.review_marks()[0].mark.is_none());
299        // Unknown mem refuses typed.
300        let err = engine.set_review_mark("ghost", None, None).unwrap_err();
301        assert_eq!(err.code(), "UNKNOWN_MEM");
302
303        // Markless diff refuses typed — never a silent empty answer.
304        let err = engine.review_mark_diff("specs", None).unwrap_err();
305        assert_eq!(err.code(), "REVIEW_MARK_NOT_SET");
306
307        // Set to the reviewed head; diff-since is now empty.
308        let reviewed = head(&engine);
309        let outcome = engine
310            .set_review_mark("specs", Some(&reviewed), Some("reviewed everything"))
311            .unwrap();
312        assert_eq!(outcome.mark.as_deref(), Some(reviewed.as_str()));
313        assert!(outcome.previous.is_none());
314        let diff = engine.review_mark_diff("specs", None).unwrap();
315        assert!(diff.changes.is_empty(), "reviewed head → empty: {diff:?}");
316
317        // A mutation past the mark succeeds with no mark-related
318        // warning (marks never gate) — and diff-since accumulates it,
319        // matching changes_since for the mark's cursor.
320        let outcome = engine
321            .create_entity(
322                crate::CreateEntityArgs {
323                    mem: "specs".to_string(),
324                    title: "Past The Mark".to_string(),
325                    entity_type: "spec".to_string(),
326                    sections: [
327                        ("identity".to_string(), "x".to_string()),
328                        ("purpose".to_string(), "y".to_string()),
329                    ]
330                    .into_iter()
331                    .collect(),
332                    metadata: Default::default(),
333                    relations: Vec::new(),
334                    anchors: Vec::new(),
335                    dry_run: false,
336                },
337                crate::vcs::Actor::App,
338                None,
339                Some("agent work"),
340            )
341            .unwrap();
342        assert!(
343            outcome
344                .warnings
345                .iter()
346                .all(|w| !format!("{w:?}").to_lowercase().contains("mark")),
347            "marks never gate or warn: {:?}",
348            outcome.warnings
349        );
350        let diff = engine.review_mark_diff("specs", None).unwrap();
351        assert_eq!(diff.changes.len(), 1, "{diff:?}");
352        let direct = engine.changes_since("specs", &reviewed, None).unwrap();
353        assert_eq!(
354            serde_json::to_value(&diff.changes).unwrap(),
355            serde_json::to_value(&direct.changes).unwrap(),
356            "diff-since must equal changes_since at the mark"
357        );
358
359        // Persistence across engine restarts (separate instance, same
360        // workspace) — the sibling-visibility half of criterion 1.
361        drop(engine);
362        let mut second = folder_engine(&tmp);
363        assert_eq!(
364            second.review_marks()[0].mark.as_deref(),
365            Some(reviewed.as_str()),
366            "the mark is mem-repo state"
367        );
368
369        // Clear returns to markless.
370        let outcome = second.set_review_mark("specs", None, None).unwrap();
371        assert_eq!(outcome.previous.as_deref(), Some(reviewed.as_str()));
372        assert!(second.review_marks()[0].mark.is_none());
373    }
374
375    #[test]
376    fn noteless_set_under_require_notes_warns_and_commits() {
377        let tmp = tempfile::TempDir::new().unwrap();
378        let mut engine = folder_engine(&tmp);
379        engine.set_settings(crate::WorkspaceSettings {
380            mutations: crate::workspace::MutationsSection {
381                require_notes: Some(true),
382            },
383            ..Default::default()
384        });
385        let reviewed = head(&engine);
386        let outcome = engine
387            .set_review_mark("specs", Some(&reviewed), None)
388            .unwrap();
389        assert!(
390            outcome.warnings.iter().any(
391                |w| matches!(w, crate::ops::WarningHint::NoteMissing { tool } if tool == "set_review_mark")
392            ),
393            "warn-and-commit: {:?}",
394            outcome.warnings
395        );
396        assert_eq!(outcome.mark.as_deref(), Some(reviewed.as_str()));
397    }
398
399    #[test]
400    fn published_projection_strips_the_mark() {
401        // The PublishedMemConfig allowlist strips everything it does
402        // not name — pin that the mark stays out (criterion 7's
403        // structural half; the export round-trip rides the exporter's
404        // own tests).
405        let mut config = memstead_schema::config::MemConfig {
406            format: None,
407            name: None,
408            title: None,
409            subject: None,
410            version: Some(semver::Version::new(1, 0, 0)),
411            description: None,
412            authors: None,
413            schema: Some("default@1.0.0".parse().unwrap()),
414            write_guidance: Default::default(),
415            process_mem: None,
416            rules: None,
417            publish: None,
418            language: None,
419            read_mems: Default::default(),
420            community: None,
421            vcs: None,
422            unregistered_at: None,
423            sync_state: Default::default(),
424            review_mark: None,
425            mutation_stamp: None,
426            extra: Default::default(),
427        };
428        config.review_mark = Some("deadbeef".to_string());
429        let published = memstead_schema::config::published_config_from(&config, "specs").unwrap();
430        let json = serde_json::to_string(&published).unwrap();
431        assert!(
432            !json.contains("reviewMark") && !json.contains("deadbeef"),
433            "published config must strip the mark: {json}"
434        );
435    }
436
437    #[test]
438    fn overview_roster_carries_the_mark_and_its_indicator() {
439        // The agents' cold-start read (overview `## Mems`) is a
440        // per-mem summary surface: a set mark rides it with the
441        // mark≠head indicator, a markless mem stays unmarked (ordinary
442        // state, never flagged).
443        let tmp = tempfile::TempDir::new().unwrap();
444        let mut engine = folder_engine(&tmp);
445        let overview_md = |engine: &mut crate::Engine| {
446            crate::overview::compose_overview(
447                engine,
448                crate::overview::OverviewArgs {
449                    include: &[],
450                    mem: None,
451                    rebuild: false,
452                    token_budget: 8000,
453                    operator_mode: false,
454                    suppress_lifecycle: false,
455                },
456                crate::overview::Surface::Mcp,
457            )
458            .unwrap()
459            .markdown
460        };
461
462        // Markless: no mark line anywhere.
463        let md = overview_md(&mut engine);
464        assert!(
465            !md.contains("Review mark"),
466            "markless roster must not mention marks: {md}"
467        );
468
469        // Mark at head: the line appears, indicator says at-mark.
470        let reviewed = head(&engine);
471        engine
472            .set_review_mark("specs", Some(&reviewed), Some("reviewed"))
473            .unwrap();
474        let md = overview_md(&mut engine);
475        assert!(
476            md.contains(&format!("**Review mark:** `{reviewed}`")),
477            "roster must carry the mark value: {md}"
478        );
479        assert!(
480            md.contains("head is at the mark"),
481            "at-mark indicator missing: {md}"
482        );
483
484        // Head moves past the mark: the indicator flips and names the
485        // composition path (changes_since with the mark's cursor).
486        engine
487            .create_entity(
488                crate::CreateEntityArgs {
489                    mem: "specs".to_string(),
490                    title: "Past The Mark Roster".to_string(),
491                    entity_type: "spec".to_string(),
492                    sections: [
493                        ("identity".to_string(), "x".to_string()),
494                        ("purpose".to_string(), "y".to_string()),
495                    ]
496                    .into_iter()
497                    .collect(),
498                    metadata: Default::default(),
499                    relations: Vec::new(),
500                    anchors: Vec::new(),
501                    dry_run: false,
502                },
503                crate::vcs::Actor::App,
504                None,
505                Some("agent work"),
506            )
507            .unwrap();
508        let md = overview_md(&mut engine);
509        assert!(
510            md.contains("head has moved past the mark") && md.contains("changes_since"),
511            "unreviewed indicator missing: {md}"
512        );
513    }
514}