Skip to main content

memstead_base/ingest/
status.rs

1//! `memstead status` projection view (bundle plan `03-projection-promotion`,
2//! decision D11).
3//!
4//! The `projections` array the status payload carries alongside the graph
5//! counts: one entry per v1 binding, reporting its declared operations, each
6//! source's baseline tokens + resolved change-detection signal, and the
7//! pending/disposed advance counts. Purely read-only — it loads the v1 binding
8//! store, reads the destination mem's `sync_state`, resolves each source's
9//! [`ChangeStrategy`], and reads the durable advance store. No mutation, no
10//! scheduling.
11//!
12//! The `signal` is the *resolved* change-detection strategy or the literal
13//! `"none"` (E1's visible-NoSignal) — never a fabricated token: a
14//! detection-less source renders `"none"`, not a fake green.
15
16use std::collections::BTreeMap;
17use std::path::Path;
18
19use serde::Serialize;
20
21use crate::Engine;
22use crate::binding::CoverageSemantics;
23use crate::ingest::advance::read_advance_store;
24use crate::ingest::cursor::source_moved;
25use crate::ingest::findings::{FindingClass, current_findings};
26use crate::ingest::render::mem_predates_binding;
27use crate::ingest::resolve::{
28    ChangeStrategy, ResolvedSource, resolve_binding_run, resolve_change_strategy,
29};
30use crate::pipeline_store::load_pipeline_configs;
31
32/// One source facet's (or reference mem's) baseline + signal state (D11). Keyed
33/// in [`ProjectionStatus::state`] by the facet-or-refmem name — the same key
34/// space the `sync_state` map uses (`<binding>/<facet>#synced`).
35#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
36pub struct FacetState {
37    /// The `#synced` baseline token, or `None` (rendered `null`) when the
38    /// source has never been synced.
39    pub synced: Option<String>,
40    /// The `#verified` baseline token, or `None` when never verified.
41    pub verified: Option<String>,
42    /// The resolved change-detection strategy — `git` / `mtime` / `graph` — or
43    /// `none` (E1's visible-NoSignal). Never a fabricated token.
44    pub signal: String,
45}
46
47/// The advance counts (D11): how many artifacts a frozen advance slice still
48/// has pending versus how many have been disposed. Both zero when no advance
49/// is in flight for the binding.
50#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
51pub struct AdvanceCounts {
52    /// Undisposed artifacts remaining in the frozen slice.
53    pub pending: usize,
54    /// Artifacts disposed so far.
55    pub disposed: usize,
56}
57
58/// One binding's status entry (D11) — the per-binding drill-down. The dashboard
59/// rollup verdict is workspace-level ([`Rollup`] / [`projection_rollup`]), not a
60/// field here.
61#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
62pub struct ProjectionStatus {
63    /// The canonical binding id `<mem>/<stem>` (D3).
64    pub binding: String,
65    /// The mem this binding writes into.
66    pub destination_mem: String,
67    /// The operations the binding declares — `build` always, plus `sync` /
68    /// `verify` when their blocks are present.
69    pub operations: Vec<String>,
70    /// Per source facet-or-refmem state, keyed by the facet/mem name.
71    pub state: BTreeMap<String, FacetState>,
72    /// Pending / disposed advance counts for the binding.
73    pub advance: AdvanceCounts,
74}
75
76/// Map a resolved [`ChangeStrategy`] to its `signal` string (D11). `None`
77/// renders the literal `"none"` — E1's visible-NoSignal, never a fake token.
78fn signal_of(strategy: ChangeStrategy) -> &'static str {
79    match strategy {
80        ChangeStrategy::None => "none",
81        ChangeStrategy::Git => "git",
82        ChangeStrategy::Mtime => "mtime",
83        ChangeStrategy::Graph => "graph",
84    }
85}
86
87/// Build the `projections` array for `memstead status` (D11) from the v1
88/// binding store rooted at `workspace_root`, reading baselines off `engine`'s
89/// destination-mem `sync_state` and the durable advance store.
90///
91/// Read-only and best-effort: a workspace with no v1 binding store (or one
92/// whose store fails to load — e.g. a not-yet-migrated legacy layout) yields an
93/// empty array rather than failing the whole status call. A binding whose
94/// sources cannot be resolved (dangling facet/medium) contributes its
95/// operations + advance counts with an empty `state` map.
96pub fn projection_status(engine: &Engine, workspace_root: &Path) -> Vec<ProjectionStatus> {
97    let Ok(configs) = load_pipeline_configs(workspace_root) else {
98        return Vec::new();
99    };
100
101    let mut out = Vec::with_capacity(configs.bindings.len());
102    for record in &configs.bindings {
103        let binding_id = format!("{}/{}", record.mem, record.name);
104        let binding = &record.config;
105
106        let mut operations = Vec::new();
107        if binding.operations.build.is_some() {
108            operations.push("build".to_string());
109        }
110        if binding.operations.sync.is_some() {
111            operations.push("sync".to_string());
112        }
113        if binding.operations.verify.is_some() {
114            operations.push("verify".to_string());
115        }
116
117        // Baselines live on the destination mem's config `sync_state` (D4).
118        let sync_state = engine
119            .mem_config_for(&binding.destination_mem)
120            .map(|c| c.sync_state.clone())
121            .unwrap_or_default();
122
123        // Resolve the binding's sources so each facet's change-detection
124        // strategy (its `signal`) is the same one the cursor/brief path uses.
125        let mut state = BTreeMap::new();
126        if let Ok(resolved) = resolve_binding_run(&configs, &binding_id, binding) {
127            for source in &resolved.sources {
128                let (facet, signal) = match source {
129                    ResolvedSource::Primary(p) => (
130                        p.facet_ref.clone(),
131                        signal_of(resolve_change_strategy(p, workspace_root)).to_string(),
132                    ),
133                    // Reference mems are graph-detected by definition (the
134                    // source mem's snapshot token).
135                    ResolvedSource::Reference { mem } => (mem.clone(), "graph".to_string()),
136                };
137                let synced = sync_state
138                    .get(&format!("{binding_id}/{facet}#synced"))
139                    .cloned();
140                let verified = sync_state
141                    .get(&format!("{binding_id}/{facet}#verified"))
142                    .cloned();
143                state.insert(
144                    facet,
145                    FacetState {
146                        synced,
147                        verified,
148                        signal,
149                    },
150                );
151            }
152        }
153
154        // Durable advance store (D7) — absent = nothing in flight (0/0).
155        let advance = match read_advance_store(workspace_root, &record.mem, &record.name) {
156            Ok(Some(s)) => AdvanceCounts {
157                pending: s.pending(),
158                disposed: s.disposed(),
159            },
160            _ => AdvanceCounts {
161                pending: 0,
162                disposed: 0,
163            },
164        };
165
166        out.push(ProjectionStatus {
167            binding: binding_id,
168            destination_mem: binding.destination_mem.clone(),
169            operations,
170            state,
171            advance,
172        });
173    }
174    out
175}
176
177// ---------------------------------------------------------------------------
178// Rollup — the dashboard lead (G1)
179// ---------------------------------------------------------------------------
180
181/// The single dashboard verdict `memstead status` leads with (G1). One verdict
182/// summarising every projection binding; the per-binding numbers
183/// ([`projection_status`]) are the drill-down.
184#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
185#[serde(rename_all = "kebab-case")]
186pub enum RollupVerdict {
187    /// No binding declares open findings and no source has moved past its
188    /// baseline — or there are no bindings at all.
189    Clean,
190    /// Onboarding only: one or more bindings predate their binding (adopt) and
191    /// nothing else needs a maintenance pass. **A pre-binding mem is never a red
192    /// verdict** — 0% anchored is expected onboarding, not a defect (E1).
193    Onboarding,
194    /// One or more bindings carry open findings (drift, unresolvable anchors,
195    /// uncovered artifacts under exhaustive coverage, adjudication backlog) or
196    /// have a source that moved past its `#synced` baseline.
197    ActionNeeded,
198}
199
200impl RollupVerdict {
201    /// Stable wire string.
202    pub fn as_wire(&self) -> &'static str {
203        match self {
204            RollupVerdict::Clean => "clean",
205            RollupVerdict::Onboarding => "onboarding",
206            RollupVerdict::ActionNeeded => "action-needed",
207        }
208    }
209}
210
211/// The dashboard rollup (G1): one verdict, a one-line headline, and up to three
212/// concrete, highest-severity actions derived from the durable findings store
213/// plus freshness. The full per-binding numbers ride [`projection_status`] as
214/// the drill-down.
215#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
216pub struct Rollup {
217    /// The single lead verdict.
218    pub verdict: RollupVerdict,
219    /// A one-line human/agent summary of the workspace's projection health.
220    pub headline: String,
221    /// Up to three concrete next actions, highest-severity first (e.g. "3
222    /// entities describe source that no longer exists — run sync").
223    pub actions: Vec<String>,
224}
225
226impl Default for Rollup {
227    fn default() -> Self {
228        Rollup {
229            verdict: RollupVerdict::Clean,
230            headline: "No projection bindings declared.".to_string(),
231            actions: Vec::new(),
232        }
233    }
234}
235
236/// One candidate action with its severity — the higher, the more urgent. Used
237/// only to rank the top-three actions the rollup surfaces.
238struct Candidate {
239    severity: u8,
240    text: String,
241}
242
243/// Compute the dashboard rollup (G1) for every projection binding in the
244/// workspace: one verdict plus the top-three concrete actions, derived from the
245/// durable findings store and freshness (source movement vs. the `#synced`
246/// baseline). **Read-only** on every mem — it borrows `&Engine` (shared) and
247/// only reads the binding store, the findings store, and the live cursor.
248///
249/// Best-effort like [`projection_status`]: a workspace with no binding store, or
250/// one whose bindings fail to resolve, yields the default clean rollup rather
251/// than failing the whole status call.
252///
253/// A binding that predates its binding (no anchors, never synced) contributes an
254/// **onboarding** action, never a red one — its uncovered artifacts are the
255/// expected first-sync backfill worklist, so pre-binding history alone never
256/// drives an `action-needed` verdict (E1).
257pub fn projection_rollup(engine: &Engine, workspace_root: &Path) -> Rollup {
258    let Ok(configs) = load_pipeline_configs(workspace_root) else {
259        return Rollup::default();
260    };
261    if configs.bindings.is_empty() {
262        return Rollup::default();
263    }
264    let total = configs.bindings.len();
265
266    let mut candidates: Vec<Candidate> = Vec::new();
267    let mut action_bindings = 0usize;
268    let mut onboarding_bindings = 0usize;
269
270    for record in &configs.bindings {
271        let binding_id = format!("{}/{}", record.mem, record.name);
272        let binding = &record.config;
273        let Ok(resolved) = resolve_binding_run(&configs, &binding_id, binding) else {
274            continue;
275        };
276
277        // Adopt (E1): a mem that predates its binding is onboarding, never a red
278        // verdict. Its uncovered artifacts are the backfill worklist, so we skip
279        // the findings/freshness scan that would otherwise read them as defects.
280        if mem_predates_binding(engine, &resolved) {
281            onboarding_bindings += 1;
282            candidates.push(Candidate {
283                severity: 1,
284                text: format!(
285                    "`{binding_id}` predates its binding — 0% anchored is expected; run \
286                     `memstead projection sync {binding_id}` for a first-sync backfill"
287                ),
288            });
289            continue;
290        }
291
292        let mut binding_has_action = false;
293
294        // Freshness: a change-detectable source moved past its `#synced` baseline.
295        if source_moved(engine, &resolved, workspace_root) {
296            binding_has_action = true;
297            candidates.push(Candidate {
298                severity: 4,
299                text: format!(
300                    "`{binding_id}` source moved since the last sync — run `memstead projection \
301                     sync {binding_id}`"
302                ),
303            });
304        }
305
306        // Open findings under the current `(hash(D), source_head)` key.
307        if let Ok((_key, findings)) = current_findings(engine, workspace_root, binding, &resolved) {
308            let mut unresolvable = 0usize;
309            let mut drifted = 0usize;
310            let mut uncovered = 0usize;
311            let mut queued = 0usize;
312            for f in &findings {
313                match f.class {
314                    FindingClass::UnresolvableAnchor => unresolvable += 1,
315                    // An adjudicated content mismatch is drift for the dashboard.
316                    FindingClass::Drifted | FindingClass::Wrong => drifted += 1,
317                    FindingClass::Uncovered => uncovered += 1,
318                    FindingClass::QueuedForAdjudication => queued += 1,
319                }
320            }
321            if unresolvable > 0 {
322                binding_has_action = true;
323                candidates.push(Candidate {
324                    severity: 6,
325                    text: format!(
326                        "{unresolvable} entit{} in `{binding_id}` describe source that no longer \
327                         exists — run `memstead projection sync {binding_id}`",
328                        if unresolvable == 1 { "y" } else { "ies" }
329                    ),
330                });
331            }
332            if drifted > 0 {
333                binding_has_action = true;
334                candidates.push(Candidate {
335                    severity: 5,
336                    text: format!(
337                        "{drifted} anchor(s) in `{binding_id}` drifted from their source — run \
338                         `memstead projection sync {binding_id}`"
339                    ),
340                });
341            }
342            // Uncovered drives an action only under exhaustive coverage — a
343            // curated binding covers a deliberate slice, so uncovered is
344            // information, not a defect (B4).
345            if uncovered > 0 && matches!(binding.coverage_semantics, CoverageSemantics::Exhaustive)
346            {
347                binding_has_action = true;
348                candidates.push(Candidate {
349                    severity: 3,
350                    text: format!(
351                        "{uncovered} in-scope source artifact(s) in `{binding_id}` carry no entity \
352                         — run `memstead projection verify {binding_id}`, then sync"
353                    ),
354                });
355            }
356            if queued > 0 {
357                binding_has_action = true;
358                candidates.push(Candidate {
359                    severity: 2,
360                    text: format!(
361                        "{queued} finding(s) in `{binding_id}` queued for adjudication — run \
362                         `memstead projection verify {binding_id}`"
363                    ),
364                });
365            }
366        }
367
368        if binding_has_action {
369            action_bindings += 1;
370        }
371    }
372
373    // Highest severity first; the stable sort preserves insertion order within a
374    // severity so runs are reproducible.
375    candidates.sort_by_key(|c| std::cmp::Reverse(c.severity));
376    let actions: Vec<String> = candidates.into_iter().take(3).map(|c| c.text).collect();
377
378    let verdict = if action_bindings > 0 {
379        RollupVerdict::ActionNeeded
380    } else if onboarding_bindings > 0 {
381        RollupVerdict::Onboarding
382    } else {
383        RollupVerdict::Clean
384    };
385
386    let headline = match verdict {
387        RollupVerdict::ActionNeeded => format!(
388            "Action needed — {action_bindings} of {total} projection(s) have open findings or a \
389             moved source."
390        ),
391        RollupVerdict::Onboarding => format!(
392            "Onboarding — {onboarding_bindings} of {total} projection(s) predate their binding; a \
393             first-sync backfill is expected, not a defect."
394        ),
395        RollupVerdict::Clean => {
396            format!("All {total} projection(s) are in sync — no open findings, no moved sources.")
397        }
398    };
399
400    Rollup {
401        verdict,
402        headline,
403        actions,
404    }
405}
406
407#[cfg(test)]
408mod tests {
409    use super::*;
410    use crate::binding::{
411        BINDING_VERSION, BindingV1, BuildMode, BuildOperation, CoverageSemantics, Operations,
412        SyncOperation,
413    };
414    use crate::pipeline::{Facet, IngestTrigger, Medium, MediumType, PatternEntry, PatternMode};
415    use crate::pipeline_store::{write_binding, write_facet, write_medium};
416    use crate::storage::FilesystemMemWriter;
417    use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
418    use tempfile::TempDir;
419
420    /// A workspace with one folder mem `engine` (also a git source tree), a v1
421    /// binding `engine/graph` over a `source-tree` facet (codebase / git), and
422    /// a seeded `#synced` baseline — the projection status reports the binding's
423    /// operations, the `git` signal, the synced token, and 0/0 advance.
424    #[test]
425    fn projection_status_reports_operations_signal_and_baseline() {
426        let tmp = TempDir::new().unwrap();
427        let root = tmp.path();
428        // Mem config so `sync_state` can be read/written.
429        std::fs::create_dir_all(root.join(".memstead")).unwrap();
430        std::fs::write(
431            root.join(".memstead").join("config.json"),
432            br#"{"format":1,"schema":"default@1.0.0"}"#,
433        )
434        .unwrap();
435        std::fs::write(
436            root.join(".memstead").join("workspace.toml"),
437            "[workspace]\n",
438        )
439        .unwrap();
440        // A git work tree so the codebase medium resolves the `git` strategy.
441        let out = std::process::Command::new("git")
442            .args(["init", "-q"])
443            .current_dir(root)
444            .output()
445            .unwrap();
446        assert!(out.status.success());
447
448        // The v1 binding + its facet/medium.
449        write_medium(
450            root,
451            "engine",
452            "graph",
453            &Medium {
454                name: "graph".to_string(),
455                medium_type: MediumType::Codebase,
456                pointer: String::new(),
457                change_detection: Some("git".to_string()),
458            },
459        )
460        .unwrap();
461        write_facet(
462            root,
463            "engine",
464            "graph",
465            &Facet {
466                name: "graph".to_string(),
467                medium: "graph".to_string(),
468                scope: vec![PatternEntry {
469                    path: "**/*.rs".to_string(),
470                    mode: PatternMode::Allow,
471                }],
472                engagement: None,
473                preparation: None,
474            },
475        )
476        .unwrap();
477        write_binding(
478            root,
479            "engine",
480            "graph",
481            &BindingV1 {
482                version: BINDING_VERSION,
483                intent: None,
484                source_facets: vec!["graph".to_string()],
485                reference_mems: Vec::new(),
486                destination_mem: "engine".to_string(),
487                deny_paths: Vec::new(),
488                coverage_semantics: CoverageSemantics::Exhaustive,
489                rules: None,
490                prune: None,
491                operations: Operations {
492                    build: Some(BuildOperation {
493                        mode: BuildMode::Discovery,
494                        trigger: IngestTrigger::Loop,
495                        batch_size: 20,
496                        post_actions: None,
497                    }),
498                    sync: Some(SyncOperation {
499                        trigger: IngestTrigger::Manual,
500                        batch_size: 20,
501                    }),
502                    verify: None,
503                },
504            },
505        )
506        .unwrap();
507
508        let mount = Mount {
509            mem: "engine".to_string(),
510            schema: Some("default@1.0.0".parse().unwrap()),
511            storage: MountStorage::Folder {
512                path: root.to_path_buf(),
513            },
514            capability: MountCapability::Write,
515            lifecycle: MountLifecycle::Eager,
516            cross_linkable: false,
517            migration_target: None,
518        };
519        let mut engine = Engine::from_mounts(vec![(
520            mount,
521            Box::new(FilesystemMemWriter::new(root.to_path_buf()))
522                as Box<dyn crate::backend::MemBackend>,
523        )])
524        .unwrap();
525        engine
526            .set_mem_sync_state("engine", "engine/graph/graph#synced", "deadbeef", None)
527            .unwrap();
528
529        let ps = projection_status(&engine, root);
530        assert_eq!(ps.len(), 1);
531        let p = &ps[0];
532        assert_eq!(p.binding, "engine/graph");
533        assert_eq!(p.destination_mem, "engine");
534        assert_eq!(p.operations, vec!["build".to_string(), "sync".to_string()]);
535        let facet = p.state.get("graph").expect("the source facet's state");
536        assert_eq!(facet.signal, "git");
537        assert_eq!(facet.synced.as_deref(), Some("deadbeef"));
538        assert_eq!(facet.verified, None);
539        assert_eq!(
540            p.advance,
541            AdvanceCounts {
542                pending: 0,
543                disposed: 0
544            }
545        );
546    }
547
548    /// A workspace with no v1 binding store yields an empty array — status
549    /// never fails because a workspace declares no projections.
550    #[test]
551    fn projection_status_empty_without_bindings() {
552        let tmp = TempDir::new().unwrap();
553        let root = tmp.path();
554        std::fs::create_dir_all(root.join(".memstead")).unwrap();
555        std::fs::write(
556            root.join(".memstead").join("config.json"),
557            br#"{"format":1,"schema":"default@1.0.0"}"#,
558        )
559        .unwrap();
560        let mount = Mount {
561            mem: "engine".to_string(),
562            schema: Some("default@1.0.0".parse().unwrap()),
563            storage: MountStorage::Folder {
564                path: root.to_path_buf(),
565            },
566            capability: MountCapability::Write,
567            lifecycle: MountLifecycle::Eager,
568            cross_linkable: false,
569            migration_target: None,
570        };
571        let engine = Engine::from_mounts(vec![(
572            mount,
573            Box::new(FilesystemMemWriter::new(root.to_path_buf()))
574                as Box<dyn crate::backend::MemBackend>,
575        )])
576        .unwrap();
577        assert!(projection_status(&engine, root).is_empty());
578    }
579
580    // ---- G1: rollup verdict + top-3 actions -----------------------------
581
582    /// Build the same one-binding `engine/graph` workspace the status test uses,
583    /// returning the engine and root. Seeds **no** `#synced` baseline and **no**
584    /// anchors, so the mem predates its binding (the adopt case) unless the
585    /// caller seeds otherwise.
586    fn one_binding_workspace(tmp: &TempDir) -> Engine {
587        let root = tmp.path();
588        std::fs::create_dir_all(root.join(".memstead")).unwrap();
589        std::fs::write(
590            root.join(".memstead").join("config.json"),
591            br#"{"format":1,"schema":"default@1.0.0"}"#,
592        )
593        .unwrap();
594        std::fs::write(
595            root.join(".memstead").join("workspace.toml"),
596            "[workspace]\n",
597        )
598        .unwrap();
599        let out = std::process::Command::new("git")
600            .args(["init", "-q"])
601            .current_dir(root)
602            .output()
603            .unwrap();
604        assert!(out.status.success());
605
606        write_medium(
607            root,
608            "engine",
609            "graph",
610            &Medium {
611                name: "graph".to_string(),
612                medium_type: MediumType::Codebase,
613                pointer: String::new(),
614                change_detection: Some("git".to_string()),
615            },
616        )
617        .unwrap();
618        write_facet(
619            root,
620            "engine",
621            "graph",
622            &Facet {
623                name: "graph".to_string(),
624                medium: "graph".to_string(),
625                scope: vec![PatternEntry {
626                    path: "**/*.rs".to_string(),
627                    mode: PatternMode::Allow,
628                }],
629                engagement: None,
630                preparation: None,
631            },
632        )
633        .unwrap();
634        write_binding(
635            root,
636            "engine",
637            "graph",
638            &BindingV1 {
639                version: BINDING_VERSION,
640                intent: None,
641                source_facets: vec!["graph".to_string()],
642                reference_mems: Vec::new(),
643                destination_mem: "engine".to_string(),
644                deny_paths: Vec::new(),
645                coverage_semantics: CoverageSemantics::Exhaustive,
646                rules: None,
647                prune: None,
648                operations: Operations {
649                    build: Some(BuildOperation {
650                        mode: BuildMode::Discovery,
651                        trigger: IngestTrigger::Loop,
652                        batch_size: 20,
653                        post_actions: None,
654                    }),
655                    sync: Some(SyncOperation {
656                        trigger: IngestTrigger::Manual,
657                        batch_size: 20,
658                    }),
659                    verify: None,
660                },
661            },
662        )
663        .unwrap();
664
665        let mount = Mount {
666            mem: "engine".to_string(),
667            schema: Some("default@1.0.0".parse().unwrap()),
668            storage: MountStorage::Folder {
669                path: root.to_path_buf(),
670            },
671            capability: MountCapability::Write,
672            lifecycle: MountLifecycle::Eager,
673            cross_linkable: false,
674            migration_target: None,
675        };
676        Engine::from_mounts(vec![(
677            mount,
678            Box::new(FilesystemMemWriter::new(root.to_path_buf()))
679                as Box<dyn crate::backend::MemBackend>,
680        )])
681        .unwrap()
682    }
683
684    /// G1 + E1 — a binding whose mem predates it (no anchors, never synced)
685    /// rolls up to an **onboarding** verdict, never `action-needed`: the
686    /// onboarding action is surfaced and pre-binding history alone drives no red
687    /// verdict (E1's refusal at the dashboard level).
688    #[test]
689    fn rollup_adopt_binding_is_onboarding_not_action_needed() {
690        let tmp = TempDir::new().unwrap();
691        let engine = one_binding_workspace(&tmp);
692        let rollup = projection_rollup(&engine, tmp.path());
693        assert_eq!(rollup.verdict, RollupVerdict::Onboarding);
694        assert_ne!(
695            rollup.verdict,
696            RollupVerdict::ActionNeeded,
697            "pre-binding history alone must never be a red verdict"
698        );
699        assert!(
700            rollup
701                .actions
702                .iter()
703                .any(|a| a.contains("predates its binding")),
704            "the onboarding action is surfaced: {:?}",
705            rollup.actions
706        );
707        assert!(rollup.headline.contains("Onboarding"));
708    }
709
710    /// G1 — a workspace with no bindings rolls up to the default **clean**
711    /// verdict with no actions.
712    #[test]
713    fn rollup_empty_without_bindings_is_clean() {
714        let tmp = TempDir::new().unwrap();
715        let root = tmp.path();
716        std::fs::create_dir_all(root.join(".memstead")).unwrap();
717        std::fs::write(
718            root.join(".memstead").join("config.json"),
719            br#"{"format":1,"schema":"default@1.0.0"}"#,
720        )
721        .unwrap();
722        let mount = Mount {
723            mem: "engine".to_string(),
724            schema: Some("default@1.0.0".parse().unwrap()),
725            storage: MountStorage::Folder {
726                path: root.to_path_buf(),
727            },
728            capability: MountCapability::Write,
729            lifecycle: MountLifecycle::Eager,
730            cross_linkable: false,
731            migration_target: None,
732        };
733        let engine = Engine::from_mounts(vec![(
734            mount,
735            Box::new(FilesystemMemWriter::new(root.to_path_buf()))
736                as Box<dyn crate::backend::MemBackend>,
737        )])
738        .unwrap();
739        let rollup = projection_rollup(&engine, root);
740        assert_eq!(rollup.verdict, RollupVerdict::Clean);
741        assert!(rollup.actions.is_empty());
742        assert!(rollup.headline.contains("No projection bindings"));
743    }
744}