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                name: None,
227                title: None,
228                subject: None,
229                version: None,
230                description: None,
231                authors: None,
232                schema: Some("default@1.0.0".parse().unwrap()),
233                write_guidance: Default::default(),
234                process_mem: None,
235                rules: None,
236                publish: None,
237                language: None,
238                read_mems: Default::default(),
239                community: None,
240                vcs: None,
241                unregistered_at: None,
242                sync_state: Default::default(),
243                review_mark: None,
244                mutation_stamp: None,
245                extra: Default::default(),
246            };
247            let meta = dir.join(memstead_schema::MEM_META_DIR);
248            std::fs::create_dir_all(&meta).unwrap();
249            std::fs::write(
250                meta.join("config.json"),
251                serde_json::to_vec_pretty(&config).unwrap(),
252            )
253            .unwrap();
254        }
255        let mount = crate::Mount {
256            mem: "specs".to_string(),
257            schema: Some(memstead_schema::SchemaRef::new(
258                "default",
259                semver::Version::new(1, 0, 0),
260            )),
261            storage: crate::MountStorage::Folder { path: dir.clone() },
262            capability: crate::MountCapability::Write,
263            lifecycle: crate::MountLifecycle::Eager,
264            cross_linkable: false,
265            migration_target: None,
266        };
267        let backend =
268            Box::new(crate::storage::FilesystemMemWriter::new(dir)) as Box<dyn crate::MemBackend>;
269        crate::Engine::from_mounts(vec![(mount, backend)]).unwrap()
270    }
271
272    fn head(engine: &crate::Engine) -> String {
273        engine
274            .review_marks()
275            .into_iter()
276            .find(|s| s.mem == "specs")
277            .and_then(|s| s.head)
278            .expect("folder head cursor")
279    }
280
281    #[test]
282    fn mark_lifecycle_persists_validates_and_never_gates() {
283        let tmp = tempfile::TempDir::new().unwrap();
284        let mut engine = folder_engine(&tmp);
285
286        // Markless start: ordinary state, listed as such.
287        let status = &engine.review_marks()[0];
288        assert_eq!(status.mem, "specs");
289        assert!(status.mark.is_none());
290        assert!(status.writable);
291
292        // Invalid cursor refuses typed, mark untouched.
293        let err = engine
294            .set_review_mark("specs", Some("not-a-timestamp"), None)
295            .unwrap_err();
296        assert_eq!(err.code(), "INVALID_CURSOR");
297        assert!(engine.review_marks()[0].mark.is_none());
298        // Unknown mem refuses typed.
299        let err = engine.set_review_mark("ghost", None, None).unwrap_err();
300        assert_eq!(err.code(), "UNKNOWN_MEM");
301
302        // Markless diff refuses typed — never a silent empty answer.
303        let err = engine.review_mark_diff("specs", None).unwrap_err();
304        assert_eq!(err.code(), "REVIEW_MARK_NOT_SET");
305
306        // Set to the reviewed head; diff-since is now empty.
307        let reviewed = head(&engine);
308        let outcome = engine
309            .set_review_mark("specs", Some(&reviewed), Some("reviewed everything"))
310            .unwrap();
311        assert_eq!(outcome.mark.as_deref(), Some(reviewed.as_str()));
312        assert!(outcome.previous.is_none());
313        let diff = engine.review_mark_diff("specs", None).unwrap();
314        assert!(diff.changes.is_empty(), "reviewed head → empty: {diff:?}");
315
316        // A mutation past the mark succeeds with no mark-related
317        // warning (marks never gate) — and diff-since accumulates it,
318        // matching changes_since for the mark's cursor.
319        let outcome = engine
320            .create_entity(
321                crate::CreateEntityArgs {
322                    mem: "specs".to_string(),
323                    title: "Past The Mark".to_string(),
324                    entity_type: "spec".to_string(),
325                    sections: [
326                        ("identity".to_string(), "x".to_string()),
327                        ("purpose".to_string(), "y".to_string()),
328                    ]
329                    .into_iter()
330                    .collect(),
331                    metadata: Default::default(),
332                    relations: Vec::new(),
333                    anchors: Vec::new(),
334                    dry_run: false,
335                },
336                crate::vcs::Actor::App,
337                None,
338                Some("agent work"),
339            )
340            .unwrap();
341        assert!(
342            outcome
343                .warnings
344                .iter()
345                .all(|w| !format!("{w:?}").to_lowercase().contains("mark")),
346            "marks never gate or warn: {:?}",
347            outcome.warnings
348        );
349        let diff = engine.review_mark_diff("specs", None).unwrap();
350        assert_eq!(diff.changes.len(), 1, "{diff:?}");
351        let direct = engine.changes_since("specs", &reviewed, None).unwrap();
352        assert_eq!(
353            serde_json::to_value(&diff.changes).unwrap(),
354            serde_json::to_value(&direct.changes).unwrap(),
355            "diff-since must equal changes_since at the mark"
356        );
357
358        // Persistence across engine restarts (separate instance, same
359        // workspace) — the sibling-visibility half of criterion 1.
360        drop(engine);
361        let mut second = folder_engine(&tmp);
362        assert_eq!(
363            second.review_marks()[0].mark.as_deref(),
364            Some(reviewed.as_str()),
365            "the mark is mem-repo state"
366        );
367
368        // Clear returns to markless.
369        let outcome = second.set_review_mark("specs", None, None).unwrap();
370        assert_eq!(outcome.previous.as_deref(), Some(reviewed.as_str()));
371        assert!(second.review_marks()[0].mark.is_none());
372    }
373
374    #[test]
375    fn noteless_set_under_require_notes_warns_and_commits() {
376        let tmp = tempfile::TempDir::new().unwrap();
377        let mut engine = folder_engine(&tmp);
378        engine.set_settings(crate::WorkspaceSettings {
379            mutations: crate::workspace::MutationsSection {
380                require_notes: Some(true),
381            },
382            ..Default::default()
383        });
384        let reviewed = head(&engine);
385        let outcome = engine
386            .set_review_mark("specs", Some(&reviewed), None)
387            .unwrap();
388        assert!(
389            outcome.warnings.iter().any(
390                |w| matches!(w, crate::ops::WarningHint::NoteMissing { tool } if tool == "set_review_mark")
391            ),
392            "warn-and-commit: {:?}",
393            outcome.warnings
394        );
395        assert_eq!(outcome.mark.as_deref(), Some(reviewed.as_str()));
396    }
397
398    #[test]
399    fn published_projection_strips_the_mark() {
400        // The PublishedMemConfig allowlist strips everything it does
401        // not name — pin that the mark stays out (criterion 7's
402        // structural half; the export round-trip rides the exporter's
403        // own tests).
404        let mut config = memstead_schema::config::MemConfig {
405            name: None,
406            title: None,
407            subject: None,
408            version: Some(semver::Version::new(1, 0, 0)),
409            description: None,
410            authors: None,
411            schema: Some("default@1.0.0".parse().unwrap()),
412            write_guidance: Default::default(),
413            process_mem: None,
414            rules: None,
415            publish: None,
416            language: None,
417            read_mems: Default::default(),
418            community: None,
419            vcs: None,
420            unregistered_at: None,
421            sync_state: Default::default(),
422            review_mark: None,
423            mutation_stamp: None,
424            extra: Default::default(),
425        };
426        config.review_mark = Some("deadbeef".to_string());
427        let published = memstead_schema::config::published_config_from(&config, "specs").unwrap();
428        let json = serde_json::to_string(&published).unwrap();
429        assert!(
430            !json.contains("reviewMark") && !json.contains("deadbeef"),
431            "published config must strip the mark: {json}"
432        );
433    }
434
435    #[test]
436    fn overview_roster_carries_the_mark_and_its_indicator() {
437        // The agents' cold-start read (overview `## Mems`) is a
438        // per-mem summary surface: a set mark rides it with the
439        // mark≠head indicator, a markless mem stays unmarked (ordinary
440        // state, never flagged).
441        let tmp = tempfile::TempDir::new().unwrap();
442        let mut engine = folder_engine(&tmp);
443        let overview_md = |engine: &mut crate::Engine| {
444            crate::overview::compose_overview(
445                engine,
446                crate::overview::OverviewArgs {
447                    include: &[],
448                    mem: None,
449                    rebuild: false,
450                    token_budget: 8000,
451                    operator_mode: false,
452                    suppress_lifecycle: false,
453                },
454                crate::overview::Surface::Mcp,
455            )
456            .unwrap()
457            .markdown
458        };
459
460        // Markless: no mark line anywhere.
461        let md = overview_md(&mut engine);
462        assert!(
463            !md.contains("Review mark"),
464            "markless roster must not mention marks: {md}"
465        );
466
467        // Mark at head: the line appears, indicator says at-mark.
468        let reviewed = head(&engine);
469        engine
470            .set_review_mark("specs", Some(&reviewed), Some("reviewed"))
471            .unwrap();
472        let md = overview_md(&mut engine);
473        assert!(
474            md.contains(&format!("**Review mark:** `{reviewed}`")),
475            "roster must carry the mark value: {md}"
476        );
477        assert!(
478            md.contains("head is at the mark"),
479            "at-mark indicator missing: {md}"
480        );
481
482        // Head moves past the mark: the indicator flips and names the
483        // composition path (changes_since with the mark's cursor).
484        engine
485            .create_entity(
486                crate::CreateEntityArgs {
487                    mem: "specs".to_string(),
488                    title: "Past The Mark Roster".to_string(),
489                    entity_type: "spec".to_string(),
490                    sections: [
491                        ("identity".to_string(), "x".to_string()),
492                        ("purpose".to_string(), "y".to_string()),
493                    ]
494                    .into_iter()
495                    .collect(),
496                    metadata: Default::default(),
497                    relations: Vec::new(),
498                    anchors: Vec::new(),
499                    dry_run: false,
500                },
501                crate::vcs::Actor::App,
502                None,
503                Some("agent work"),
504            )
505            .unwrap();
506        let md = overview_md(&mut engine);
507        assert!(
508            md.contains("head has moved past the mark") && md.contains("changes_since"),
509            "unreviewed indicator missing: {md}"
510        );
511    }
512}