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        let mounted = &mut self.mounts[mount_idx];
106        let mut config = mounted.mem_config.clone().ok_or_else(|| {
107            EngineError::InvalidInput(format!(
108                "mem '{mem_name}' has no loaded MemConfig — cannot set a review mark \
109                 (initialize the mem via `memstead init` or `memstead mem create` first)"
110            ))
111        })?;
112
113        let previous = config.review_mark.clone();
114        config.review_mark = target.map(str::to_string);
115
116        let mut bytes = serde_json::to_vec_pretty(&config).map_err(|e| {
117            EngineError::InvalidInput(format!("could not serialize mem config: {e}"))
118        })?;
119        bytes.push(b'\n');
120        mounted.backend.write_mem_config_with_note(&bytes, note)?;
121        mounted.mem_config = Some(config);
122
123        // Refresh the head cursor so the next drift probe doesn't
124        // surface MEM_RELOADED for the commit we just produced.
125        let new_head = mounted.backend.current_head().ok().flatten();
126        if let Some(sha) = new_head {
127            mounted.last_known_head = Some(sha);
128        }
129
130        Ok(SetReviewMarkOutcome {
131            mem: mem_name.to_string(),
132            mark: target.map(str::to_string),
133            previous,
134            warnings,
135        })
136    }
137
138    /// The accumulated per-entity delta from the mem's review mark to
139    /// its current head — exactly the envelopes `changes_since`
140    /// reports for the mark's cursor. A markless mem refuses with
141    /// `REVIEW_MARK_NOT_SET` (marklessness is known from the roster; a
142    /// silent empty answer would equate "no mark" with "no changes").
143    pub fn review_mark_diff(
144        &self,
145        mem_name: &str,
146        rename_similarity: Option<f32>,
147    ) -> Result<crate::ops::ChangesReport, EngineError> {
148        let mount = self
149            .mounts
150            .iter()
151            .find(|m| m.mount.mem == mem_name)
152            .ok_or_else(|| self.unknown_mem_error(mem_name))?;
153        let mark = mount
154            .mem_config
155            .as_ref()
156            .and_then(|c| c.review_mark.clone())
157            .ok_or_else(|| EngineError::ReviewMarkNotSet {
158                mem: mem_name.to_string(),
159            })?;
160        self.changes_since(mem_name, &mark, rename_similarity)
161    }
162
163    /// Backend-vocabulary validation for an explicit mark target.
164    fn validate_review_cursor(
165        &self,
166        mount_idx: usize,
167        mem_name: &str,
168        target: &str,
169    ) -> Result<(), EngineError> {
170        use crate::workspace::MountStorage;
171        let invalid = || EngineError::InvalidChangesCursor {
172            mem: mem_name.to_string(),
173            since: target.to_string(),
174        };
175        match &self.mounts[mount_idx].mount.storage {
176            MountStorage::Folder { .. } => {
177                // Folder cursors are the changelog's RFC3339-millis
178                // timestamps; format-level validation (existence is not
179                // required — any parseable instant is a legal `since`).
180                if crate::filesystem::changelog::parse_rfc3339_utc(target).is_none() {
181                    return Err(invalid());
182                }
183                Ok(())
184            }
185            MountStorage::GitBranch { .. } => {
186                // A git-branch cursor must resolve to a known commit —
187                // `changes_since` is the authoritative resolver and
188                // already refuses unknown SHAs with INVALID_CURSOR.
189                self.changes_since(mem_name, target, None).map(|_| ())
190            }
191            MountStorage::Archive { .. } | MountStorage::InMemory => {
192                // Unreachable through set (capability gate refuses
193                // first); refuse defensively for direct callers.
194                Err(invalid())
195            }
196        }
197    }
198}
199
200#[cfg(test)]
201mod tests {
202    use crate::storage::MemWriter;
203
204    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";
205
206    fn folder_engine(tmp: &tempfile::TempDir) -> crate::Engine {
207        let dir = tmp.path().join("specs");
208        if !dir.exists() {
209            std::fs::create_dir_all(&dir).unwrap();
210            let writer = crate::storage::FilesystemMemWriter::new(dir.clone());
211            MemWriter::write_entity(&writer, std::path::Path::new("seed.md"), SEED.as_bytes())
212                .unwrap();
213            MemWriter::commit(&writer, "seed", &crate::vcs::CommitContext::internal()).unwrap();
214            crate::backend::MemBackend::append_provenance(
215                &writer,
216                &crate::provenance::Provenance::new(
217                    std::time::SystemTime::now(),
218                    crate::provenance::ProvenanceKind::Create,
219                    Some("specs--seed".into()),
220                    crate::vcs::Actor::Cli,
221                    None,
222                    None,
223                ),
224            )
225            .unwrap();
226            // A config file so set_review_mark has a MemConfig to carry
227            // the mark (mirrors an initialized mem).
228            let config = memstead_schema::config::MemConfig {
229                name: None,
230                title: None,
231                subject: None,
232                version: None,
233                description: None,
234                authors: None,
235                schema: Some("default@1.0.0".parse().unwrap()),
236                write_guidance: Default::default(),
237                process_mem: None,
238                rules: None,
239                publish: None,
240                language: None,
241                read_mems: Default::default(),
242                community: None,
243                vcs: None,
244                unregistered_at: None,
245                sync_state: Default::default(),
246                review_mark: None,
247                mutation_stamp: None,
248                extra: Default::default(),
249            };
250            let meta = dir.join(memstead_schema::MEM_META_DIR);
251            std::fs::create_dir_all(&meta).unwrap();
252            std::fs::write(
253                meta.join("config.json"),
254                serde_json::to_vec_pretty(&config).unwrap(),
255            )
256            .unwrap();
257        }
258        let mount = crate::Mount {
259            mem: "specs".to_string(),
260            schema: Some(memstead_schema::SchemaRef::new(
261                "default",
262                semver::Version::new(1, 0, 0),
263            )),
264            storage: crate::MountStorage::Folder { path: dir.clone() },
265            capability: crate::MountCapability::Write,
266            lifecycle: crate::MountLifecycle::Eager,
267            cross_linkable: false,
268            migration_target: None,
269        };
270        let backend =
271            Box::new(crate::storage::FilesystemMemWriter::new(dir)) as Box<dyn crate::MemBackend>;
272        crate::Engine::from_mounts(vec![(mount, backend)]).unwrap()
273    }
274
275    fn head(engine: &crate::Engine) -> String {
276        engine
277            .review_marks()
278            .into_iter()
279            .find(|s| s.mem == "specs")
280            .and_then(|s| s.head)
281            .expect("folder head cursor")
282    }
283
284    #[test]
285    fn mark_lifecycle_persists_validates_and_never_gates() {
286        let tmp = tempfile::TempDir::new().unwrap();
287        let mut engine = folder_engine(&tmp);
288
289        // Markless start: ordinary state, listed as such.
290        let status = &engine.review_marks()[0];
291        assert_eq!(status.mem, "specs");
292        assert!(status.mark.is_none());
293        assert!(status.writable);
294
295        // Invalid cursor refuses typed, mark untouched.
296        let err = engine
297            .set_review_mark("specs", Some("not-a-timestamp"), None)
298            .unwrap_err();
299        assert_eq!(err.code(), "INVALID_CURSOR");
300        assert!(engine.review_marks()[0].mark.is_none());
301        // Unknown mem refuses typed.
302        let err = engine.set_review_mark("ghost", None, None).unwrap_err();
303        assert_eq!(err.code(), "UNKNOWN_MEM");
304
305        // Markless diff refuses typed — never a silent empty answer.
306        let err = engine.review_mark_diff("specs", None).unwrap_err();
307        assert_eq!(err.code(), "REVIEW_MARK_NOT_SET");
308
309        // Set to the reviewed head; diff-since is now empty.
310        let reviewed = head(&engine);
311        let outcome = engine
312            .set_review_mark("specs", Some(&reviewed), Some("reviewed everything"))
313            .unwrap();
314        assert_eq!(outcome.mark.as_deref(), Some(reviewed.as_str()));
315        assert!(outcome.previous.is_none());
316        let diff = engine.review_mark_diff("specs", None).unwrap();
317        assert!(diff.changes.is_empty(), "reviewed head → empty: {diff:?}");
318
319        // A mutation past the mark succeeds with no mark-related
320        // warning (marks never gate) — and diff-since accumulates it,
321        // matching changes_since for the mark's cursor.
322        let outcome = engine
323            .create_entity(
324                crate::CreateEntityArgs {
325                    mem: "specs".to_string(),
326                    title: "Past The Mark".to_string(),
327                    entity_type: "spec".to_string(),
328                    sections: [
329                        ("identity".to_string(), "x".to_string()),
330                        ("purpose".to_string(), "y".to_string()),
331                    ]
332                    .into_iter()
333                    .collect(),
334                    metadata: Default::default(),
335                    relations: Vec::new(),
336                    anchors: Vec::new(),
337                    dry_run: false,
338                },
339                crate::vcs::Actor::App,
340                None,
341                Some("agent work"),
342            )
343            .unwrap();
344        assert!(
345            outcome
346                .warnings
347                .iter()
348                .all(|w| !format!("{w:?}").to_lowercase().contains("mark")),
349            "marks never gate or warn: {:?}",
350            outcome.warnings
351        );
352        let diff = engine.review_mark_diff("specs", None).unwrap();
353        assert_eq!(diff.changes.len(), 1, "{diff:?}");
354        let direct = engine.changes_since("specs", &reviewed, None).unwrap();
355        assert_eq!(
356            serde_json::to_value(&diff.changes).unwrap(),
357            serde_json::to_value(&direct.changes).unwrap(),
358            "diff-since must equal changes_since at the mark"
359        );
360
361        // Persistence across engine restarts (separate instance, same
362        // workspace) — the sibling-visibility half of criterion 1.
363        drop(engine);
364        let mut second = folder_engine(&tmp);
365        assert_eq!(
366            second.review_marks()[0].mark.as_deref(),
367            Some(reviewed.as_str()),
368            "the mark is mem-repo state"
369        );
370
371        // Clear returns to markless.
372        let outcome = second.set_review_mark("specs", None, None).unwrap();
373        assert_eq!(outcome.previous.as_deref(), Some(reviewed.as_str()));
374        assert!(second.review_marks()[0].mark.is_none());
375    }
376
377    #[test]
378    fn noteless_set_under_require_notes_warns_and_commits() {
379        let tmp = tempfile::TempDir::new().unwrap();
380        let mut engine = folder_engine(&tmp);
381        engine.set_settings(crate::WorkspaceSettings {
382            mutations: crate::workspace::MutationsSection {
383                require_notes: Some(true),
384            },
385            ..Default::default()
386        });
387        let reviewed = head(&engine);
388        let outcome = engine
389            .set_review_mark("specs", Some(&reviewed), None)
390            .unwrap();
391        assert!(
392            outcome.warnings.iter().any(
393                |w| matches!(w, crate::ops::WarningHint::NoteMissing { tool } if tool == "set_review_mark")
394            ),
395            "warn-and-commit: {:?}",
396            outcome.warnings
397        );
398        assert_eq!(outcome.mark.as_deref(), Some(reviewed.as_str()));
399    }
400
401    #[test]
402    fn published_projection_strips_the_mark() {
403        // The PublishedMemConfig allowlist strips everything it does
404        // not name — pin that the mark stays out (criterion 7's
405        // structural half; the export round-trip rides the exporter's
406        // own tests).
407        let mut config = memstead_schema::config::MemConfig {
408            name: None,
409            title: None,
410            subject: None,
411            version: Some(semver::Version::new(1, 0, 0)),
412            description: None,
413            authors: None,
414            schema: Some("default@1.0.0".parse().unwrap()),
415            write_guidance: Default::default(),
416            process_mem: None,
417            rules: None,
418            publish: None,
419            language: None,
420            read_mems: Default::default(),
421            community: None,
422            vcs: None,
423            unregistered_at: None,
424            sync_state: Default::default(),
425            review_mark: None,
426            mutation_stamp: None,
427            extra: Default::default(),
428        };
429        config.review_mark = Some("deadbeef".to_string());
430        let published = memstead_schema::config::published_config_from(&config, "specs").unwrap();
431        let json = serde_json::to_string(&published).unwrap();
432        assert!(
433            !json.contains("reviewMark") && !json.contains("deadbeef"),
434            "published config must strip the mark: {json}"
435        );
436    }
437
438    #[test]
439    fn overview_roster_carries_the_mark_and_its_indicator() {
440        // The agents' cold-start read (overview `## Mems`) is a
441        // per-mem summary surface: a set mark rides it with the
442        // mark≠head indicator, a markless mem stays unmarked (ordinary
443        // state, never flagged).
444        let tmp = tempfile::TempDir::new().unwrap();
445        let mut engine = folder_engine(&tmp);
446        let overview_md = |engine: &mut crate::Engine| {
447            crate::overview::compose_overview(
448                engine,
449                crate::overview::OverviewArgs {
450                    include: &[],
451                    mem: None,
452                    rebuild: false,
453                    token_budget: 8000,
454                    operator_mode: false,
455                    suppress_lifecycle: false,
456                },
457                crate::overview::Surface::Mcp,
458            )
459            .unwrap()
460            .markdown
461        };
462
463        // Markless: no mark line anywhere.
464        let md = overview_md(&mut engine);
465        assert!(
466            !md.contains("Review mark"),
467            "markless roster must not mention marks: {md}"
468        );
469
470        // Mark at head: the line appears, indicator says at-mark.
471        let reviewed = head(&engine);
472        engine
473            .set_review_mark("specs", Some(&reviewed), Some("reviewed"))
474            .unwrap();
475        let md = overview_md(&mut engine);
476        assert!(
477            md.contains(&format!("**Review mark:** `{reviewed}`")),
478            "roster must carry the mark value: {md}"
479        );
480        assert!(
481            md.contains("head is at the mark"),
482            "at-mark indicator missing: {md}"
483        );
484
485        // Head moves past the mark: the indicator flips and names the
486        // composition path (changes_since with the mark's cursor).
487        engine
488            .create_entity(
489                crate::CreateEntityArgs {
490                    mem: "specs".to_string(),
491                    title: "Past The Mark Roster".to_string(),
492                    entity_type: "spec".to_string(),
493                    sections: [
494                        ("identity".to_string(), "x".to_string()),
495                        ("purpose".to_string(), "y".to_string()),
496                    ]
497                    .into_iter()
498                    .collect(),
499                    metadata: Default::default(),
500                    relations: Vec::new(),
501                    anchors: Vec::new(),
502                    dry_run: false,
503                },
504                crate::vcs::Actor::App,
505                None,
506                Some("agent work"),
507            )
508            .unwrap();
509        let md = overview_md(&mut engine);
510        assert!(
511            md.contains("head has moved past the mark") && md.contains("changes_since"),
512            "unreviewed indicator missing: {md}"
513        );
514    }
515}