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 v2 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 v2 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, ResolvedIngest, 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/// Open-finding counts by class for one binding — the drill-down's share
59/// of the scan the rollup aggregates. All zero when the binding is clean
60/// or onboarding (onboarding skips the findings scan by design: its
61/// uncovered artifacts are the backfill worklist, not defects).
62#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
63pub struct FindingCounts {
64    /// Entities describing source that no longer exists.
65    pub unresolvable: usize,
66    /// Anchors drifted from source (adjudicated mismatches included).
67    pub drifted: usize,
68    /// In-scope source artifacts carrying no entity.
69    pub uncovered: usize,
70    /// Findings queued for adjudication.
71    pub queued: usize,
72}
73
74/// One binding's status entry (D11) — the per-binding drill-down, carrying
75/// the SAME resolution the workspace rollup aggregates (verdict, moved
76/// source, finding counts) so consumers never re-derive it client-side.
77/// The workspace-level lead stays [`Rollup`] / [`projection_rollup`].
78#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
79pub struct ProjectionStatus {
80    /// The canonical binding id `<mem>/<stem>` (D3).
81    pub binding: String,
82    /// The mem this binding writes into.
83    pub destination_mem: String,
84    /// The operations the binding declares — `build` always, plus `sync` /
85    /// `verify` when their blocks are present.
86    pub operations: Vec<String>,
87    /// Per source facet-or-refmem state, keyed by the facet/mem name.
88    pub state: BTreeMap<String, FacetState>,
89    /// Pending / disposed advance counts for the binding.
90    pub advance: AdvanceCounts,
91    /// This binding's own verdict, by the rollup's exact rules:
92    /// `onboarding` when the mem predates its binding (never red);
93    /// `action-needed` on open findings that count as actions or a moved
94    /// source; `clean` otherwise.
95    pub verdict: RollupVerdict,
96    /// True when a change-detectable source moved past its `#synced`
97    /// baseline. Always false for onboarding bindings (scan skipped).
98    pub source_moved: bool,
99    /// Open findings under the current key, by class.
100    pub findings: FindingCounts,
101}
102
103/// The shared per-binding scan both [`projection_status`] and
104/// [`projection_rollup`] resolve from — one truth, two projections.
105struct BindingResolution {
106    onboarding: bool,
107    source_moved: bool,
108    findings: FindingCounts,
109    /// Whether the findings/moved state counts as an action under the
110    /// rollup's rules (uncovered only under exhaustive coverage).
111    has_action: bool,
112}
113
114impl BindingResolution {
115    fn verdict(&self) -> RollupVerdict {
116        if self.onboarding {
117            RollupVerdict::Onboarding
118        } else if self.has_action {
119            RollupVerdict::ActionNeeded
120        } else {
121            RollupVerdict::Clean
122        }
123    }
124}
125
126fn resolve_binding_status(
127    engine: &Engine,
128    workspace_root: &Path,
129    binding: &crate::binding::Binding,
130    resolved: &ResolvedIngest,
131) -> BindingResolution {
132    if mem_predates_binding(engine, resolved) {
133        return BindingResolution {
134            onboarding: true,
135            source_moved: false,
136            findings: FindingCounts::default(),
137            has_action: false,
138        };
139    }
140    let source_moved = source_moved(engine, resolved, workspace_root);
141    let mut findings = FindingCounts::default();
142    if let Ok((_key, list)) = current_findings(engine, workspace_root, binding, resolved) {
143        for f in &list {
144            match f.class {
145                FindingClass::UnresolvableAnchor => findings.unresolvable += 1,
146                FindingClass::Drifted | FindingClass::Wrong => findings.drifted += 1,
147                FindingClass::Uncovered => findings.uncovered += 1,
148                FindingClass::QueuedForAdjudication => findings.queued += 1,
149            }
150        }
151    }
152    let uncovered_counts = findings.uncovered > 0
153        && matches!(
154            crate::binding::effective_coverage_semantics(binding).value,
155            CoverageSemantics::Exhaustive
156        );
157    let has_action = source_moved
158        || findings.unresolvable > 0
159        || findings.drifted > 0
160        || uncovered_counts
161        || findings.queued > 0;
162    BindingResolution {
163        onboarding: false,
164        source_moved,
165        findings,
166        has_action,
167    }
168}
169
170/// Map a resolved [`ChangeStrategy`] to its `signal` string (D11). `None`
171/// renders the literal `"none"` — E1's visible-NoSignal, never a fake token.
172fn signal_of(strategy: ChangeStrategy) -> &'static str {
173    match strategy {
174        ChangeStrategy::None => "none",
175        ChangeStrategy::Git => "git",
176        ChangeStrategy::Mtime => "mtime",
177        ChangeStrategy::Graph => "graph",
178    }
179}
180
181/// Build the `projections` array for `memstead status` (D11) from the v1
182/// binding store rooted at `workspace_root`, reading baselines off `engine`'s
183/// destination-mem `sync_state` and the durable advance store.
184///
185/// Read-only and best-effort: a workspace with no v2 binding store (or one
186/// whose store fails to load — e.g. a not-yet-migrated legacy layout) yields an
187/// empty array rather than failing the whole status call. A binding whose
188/// sources cannot be resolved (dangling facet/medium) contributes its
189/// operations + advance counts with an empty `state` map.
190pub fn projection_status(engine: &Engine, workspace_root: &Path) -> Vec<ProjectionStatus> {
191    let Ok(configs) = load_pipeline_configs(workspace_root) else {
192        return Vec::new();
193    };
194
195    let mut out = Vec::with_capacity(configs.bindings.len());
196    for record in &configs.bindings {
197        let binding_id = format!("{}/{}", record.mem, record.name);
198        let binding = &record.config;
199
200        let mut operations = Vec::new();
201        if binding.operations.build.is_some() {
202            operations.push("build".to_string());
203        }
204        if binding.operations.sync.is_some() {
205            operations.push("sync".to_string());
206        }
207        if binding.operations.verify.is_some() {
208            operations.push("verify".to_string());
209        }
210
211        // Baselines live on the destination mem's config `sync_state` (D4).
212        let sync_state = engine
213            .mem_config_for(&binding.destination_mem)
214            .map(|c| c.sync_state.clone())
215            .unwrap_or_default();
216
217        // Resolve the binding's sources so each facet's change-detection
218        // strategy (its `signal`) is the same one the cursor/brief path uses.
219        let mut state = BTreeMap::new();
220        let mut resolution: Option<BindingResolution> = None;
221        if let Ok(resolved) = resolve_binding_run(&binding_id, binding) {
222            resolution = Some(resolve_binding_status(
223                engine,
224                workspace_root,
225                binding,
226                &resolved,
227            ));
228            for source in &resolved.sources {
229                let (facet, signal) = match source {
230                    ResolvedSource::Primary(p) => (
231                        p.name.clone(),
232                        signal_of(resolve_change_strategy(p, workspace_root)).to_string(),
233                    ),
234                    // Reference mems are graph-detected by definition (the
235                    // source mem's snapshot token).
236                    ResolvedSource::Reference { mem } => (mem.clone(), "graph".to_string()),
237                };
238                let synced = sync_state
239                    .get(&format!("{binding_id}/{facet}#synced"))
240                    .cloned();
241                let verified = sync_state
242                    .get(&format!("{binding_id}/{facet}#verified"))
243                    .cloned();
244                state.insert(
245                    facet,
246                    FacetState {
247                        synced,
248                        verified,
249                        signal,
250                    },
251                );
252            }
253        }
254
255        // Durable advance store (D7) — absent = nothing in flight (0/0).
256        let advance = match read_advance_store(workspace_root, &record.mem, &record.name) {
257            Ok(Some(s)) => AdvanceCounts {
258                pending: s.pending(),
259                disposed: s.disposed(),
260            },
261            _ => AdvanceCounts {
262                pending: 0,
263                disposed: 0,
264            },
265        };
266
267        let (verdict, source_moved, findings) = match &resolution {
268            Some(r) => (r.verdict(), r.source_moved, r.findings),
269            None => (RollupVerdict::Clean, false, FindingCounts::default()),
270        };
271        out.push(ProjectionStatus {
272            binding: binding_id,
273            destination_mem: binding.destination_mem.clone(),
274            operations,
275            state,
276            advance,
277            verdict,
278            source_moved,
279            findings,
280        });
281    }
282    out
283}
284
285// ---------------------------------------------------------------------------
286// Rollup — the dashboard lead (G1)
287// ---------------------------------------------------------------------------
288
289/// The single dashboard verdict `memstead status` leads with (G1). One verdict
290/// summarising every projection binding; the per-binding numbers
291/// ([`projection_status`]) are the drill-down.
292#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
293#[serde(rename_all = "kebab-case")]
294pub enum RollupVerdict {
295    /// No binding declares open findings and no source has moved past its
296    /// baseline. Reached only when there was something to examine.
297    Clean,
298    /// There were no projection bindings to examine, so this rollup asserts
299    /// nothing (04/04, criterion 5). It used to answer `clean` here, which a
300    /// reader takes as a general all-clear over a workspace it never looked
301    /// at. A verdict that is only sometimes emitted is still read as general
302    /// when it is, so the empty case gets its own word rather than borrowing
303    /// the reassuring one.
304    NothingDeclared,
305    /// Onboarding only: one or more bindings predate their binding (adopt) and
306    /// nothing else needs a maintenance pass. **A pre-binding mem is never a red
307    /// verdict** — 0% anchored is expected onboarding, not a defect (E1).
308    Onboarding,
309    /// One or more bindings carry open findings (drift, unresolvable anchors,
310    /// uncovered artifacts under exhaustive coverage, adjudication backlog) or
311    /// have a source that moved past its `#synced` baseline.
312    ActionNeeded,
313}
314
315impl RollupVerdict {
316    /// Stable wire string.
317    pub fn as_wire(&self) -> &'static str {
318        match self {
319            RollupVerdict::Clean => "clean",
320            RollupVerdict::NothingDeclared => "nothing-declared",
321            RollupVerdict::Onboarding => "onboarding",
322            RollupVerdict::ActionNeeded => "action-needed",
323        }
324    }
325}
326
327/// The dashboard rollup (G1): one verdict, a one-line headline, and up to three
328/// concrete, highest-severity actions derived from the durable findings store
329/// plus freshness. The full per-binding numbers ride [`projection_status`] as
330/// the drill-down.
331#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
332pub struct Rollup {
333    /// The single lead verdict.
334    pub verdict: RollupVerdict,
335    /// What the verdict answers for, in words, so it cannot be read as a
336    /// claim about the workspace at large (04/04, criterion 5). A verdict
337    /// without its subject is the bundle's own failure class: a surface
338    /// stating a fact broader than the one it established.
339    pub subject: String,
340    /// A one-line human/agent summary of the workspace's projection health.
341    pub headline: String,
342    /// Up to three concrete next actions, highest-severity first (e.g. "3
343    /// entities describe source that no longer exists — run sync").
344    pub actions: Vec<String>,
345}
346
347impl Default for Rollup {
348    fn default() -> Self {
349        Rollup {
350            verdict: RollupVerdict::NothingDeclared,
351            subject: "no projection bindings".to_string(),
352            headline: "No projection bindings are declared, so this says nothing about the \
353                       workspace beyond that."
354                .to_string(),
355            actions: Vec::new(),
356        }
357    }
358}
359
360/// One candidate action with its severity — the higher, the more urgent. Used
361/// only to rank the top-three actions the rollup surfaces.
362struct Candidate {
363    severity: u8,
364    text: String,
365}
366
367/// Compute the dashboard rollup (G1) for every projection binding in the
368/// workspace: one verdict plus the top-three concrete actions, derived from the
369/// durable findings store and freshness (source movement vs. the `#synced`
370/// baseline). **Read-only** on every mem — it borrows `&Engine` (shared) and
371/// only reads the binding store, the findings store, and the live cursor.
372///
373/// Best-effort like [`projection_status`]: a workspace with no binding store, or
374/// one whose bindings fail to resolve, yields the default clean rollup rather
375/// than failing the whole status call.
376///
377/// A binding that predates its binding (no anchors, never synced) contributes an
378/// **onboarding** action, never a red one — its uncovered artifacts are the
379/// expected first-sync backfill worklist, so pre-binding history alone never
380/// drives an `action-needed` verdict (E1).
381pub fn projection_rollup(engine: &Engine, workspace_root: &Path) -> Rollup {
382    let Ok(configs) = load_pipeline_configs(workspace_root) else {
383        return Rollup::default();
384    };
385    if configs.bindings.is_empty() {
386        return Rollup::default();
387    }
388    let total = configs.bindings.len();
389
390    let mut candidates: Vec<Candidate> = Vec::new();
391    let mut action_bindings = 0usize;
392    let mut onboarding_bindings = 0usize;
393
394    for record in &configs.bindings {
395        let binding_id = format!("{}/{}", record.mem, record.name);
396        let binding = &record.config;
397        let Ok(resolved) = resolve_binding_run(&binding_id, binding) else {
398            continue;
399        };
400
401        // The SAME per-binding scan projection_status serves (one truth).
402        let resolution = resolve_binding_status(engine, workspace_root, binding, &resolved);
403
404        // Adopt (E1): a mem that predates its binding is onboarding, never a red
405        // verdict. Its uncovered artifacts are the backfill worklist, so we skip
406        // the findings/freshness scan that would otherwise read them as defects.
407        if resolution.onboarding {
408            onboarding_bindings += 1;
409            candidates.push(Candidate {
410                severity: 1,
411                text: format!(
412                    "`{binding_id}` predates its binding — 0% anchored is expected; run \
413                     `memstead projection brief {binding_id} --sync` for a first-sync backfill"
414                ),
415            });
416            continue;
417        }
418
419        // Freshness: a change-detectable source moved past its `#synced` baseline.
420        if resolution.source_moved {
421            candidates.push(Candidate {
422                severity: 4,
423                text: format!(
424                    "`{binding_id}` source moved since the last sync — run `memstead projection \
425                     sync {binding_id}`"
426                ),
427            });
428        }
429
430        let FindingCounts {
431            unresolvable,
432            drifted,
433            uncovered,
434            queued,
435        } = resolution.findings;
436        if unresolvable > 0 {
437            candidates.push(Candidate {
438                severity: 6,
439                text: format!(
440                    "{unresolvable} entit{} in `{binding_id}` describe source that no longer \
441                     exists — run `memstead projection brief {binding_id} --sync`",
442                    if unresolvable == 1 { "y" } else { "ies" }
443                ),
444            });
445        }
446        if drifted > 0 {
447            candidates.push(Candidate {
448                severity: 5,
449                text: format!(
450                    "{drifted} anchor(s) in `{binding_id}` drifted from their source — run \
451                     `memstead projection brief {binding_id} --sync`"
452                ),
453            });
454        }
455        // Uncovered drives an action only under exhaustive coverage — a
456        // curated binding covers a deliberate slice, so uncovered is
457        // information, not a defect (B4). (`has_action` already encodes
458        // this rule; the candidate mirrors it.)
459        if uncovered > 0
460            && matches!(
461                crate::binding::effective_coverage_semantics(binding).value,
462                CoverageSemantics::Exhaustive
463            )
464        {
465            candidates.push(Candidate {
466                severity: 3,
467                text: format!(
468                    "{uncovered} in-scope source artifact(s) in `{binding_id}` carry no entity \
469                     — run `memstead projection verify {binding_id}`, then sync"
470                ),
471            });
472        }
473        if queued > 0 {
474            candidates.push(Candidate {
475                severity: 2,
476                text: format!(
477                    "{queued} finding(s) in `{binding_id}` queued for adjudication — run \
478                     `memstead projection verify {binding_id}`"
479                ),
480            });
481        }
482
483        if resolution.has_action {
484            action_bindings += 1;
485        }
486    }
487
488    // Highest severity first; the stable sort preserves insertion order within a
489    // severity so runs are reproducible.
490    candidates.sort_by_key(|c| std::cmp::Reverse(c.severity));
491    let actions: Vec<String> = candidates.into_iter().take(3).map(|c| c.text).collect();
492
493    let verdict = if action_bindings > 0 {
494        RollupVerdict::ActionNeeded
495    } else if onboarding_bindings > 0 {
496        RollupVerdict::Onboarding
497    } else {
498        RollupVerdict::Clean
499    };
500
501    let headline = match verdict {
502        RollupVerdict::ActionNeeded => format!(
503            "Action needed — {action_bindings} of {total} projection(s) have open findings or a \
504             moved source."
505        ),
506        RollupVerdict::Onboarding => format!(
507            "Onboarding — {onboarding_bindings} of {total} projection(s) predate their binding; a \
508             first-sync backfill is expected, not a defect."
509        ),
510        RollupVerdict::Clean => {
511            format!("All {total} projection(s) are in sync — no open findings, no moved sources.")
512        }
513        // Unreachable: the empty case returns `Rollup::default()` above,
514        // before any binding is examined.
515        RollupVerdict::NothingDeclared => "No projection bindings were examined.".to_string(),
516    };
517
518    Rollup {
519        verdict,
520        subject: format!("{total} projection binding(s)"),
521        headline,
522        actions,
523    }
524}
525
526#[cfg(test)]
527mod tests {
528    use super::*;
529    use crate::binding::{
530        BINDING_VERSION, Binding, BuildMode, BuildOperation, Operations, SyncOperation,
531    };
532    use crate::pipeline::{IngestTrigger, MediumType, PatternEntry, PatternMode};
533    use crate::pipeline_store::write_binding;
534    use crate::storage::FilesystemMemWriter;
535    use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
536    use tempfile::TempDir;
537
538    /// A workspace with one folder mem `engine` (also a git source tree), a v1
539    /// binding `engine/graph` over a `source-tree` facet (codebase / git), and
540    /// a seeded `#synced` baseline — the projection status reports the binding's
541    /// operations, the `git` signal, the synced token, and 0/0 advance.
542    #[test]
543    fn projection_status_reports_operations_signal_and_baseline() {
544        let tmp = TempDir::new().unwrap();
545        let root = tmp.path();
546        // Mem config so `sync_state` can be read/written.
547        std::fs::create_dir_all(root.join(".memstead")).unwrap();
548        std::fs::write(
549            root.join(".memstead").join("config.json"),
550            br#"{"format":1,"schema":"default@1.0.0"}"#,
551        )
552        .unwrap();
553        std::fs::write(
554            root.join(".memstead").join("workspace.toml"),
555            "[workspace]\n",
556        )
557        .unwrap();
558        // A git work tree so the codebase medium resolves the `git` strategy.
559        let out = std::process::Command::new("git")
560            .args(["init", "-q"])
561            .current_dir(root)
562            .output()
563            .unwrap();
564        assert!(out.status.success());
565
566        // The v2 binding with its inline source.
567        write_binding(
568            root,
569            "engine",
570            "graph",
571            &Binding {
572                version: BINDING_VERSION,
573                intent: None,
574                sources: vec![crate::pipeline::Source {
575                    name: "graph".to_string(),
576                    medium_type: MediumType::Codebase,
577                    pointer: String::new(),
578                    change_detection: Some("git".to_string()),
579                    scope: vec![PatternEntry {
580                        path: "**/*.rs".to_string(),
581                        mode: PatternMode::Allow,
582                    }],
583                    engagement: None,
584                    preparation: None,
585                }],
586                reference_mems: Vec::new(),
587                destination_mem: "engine".to_string(),
588                deny_paths: Vec::new(),
589                coverage_semantics: None,
590                rules: None,
591                prune: None,
592                operations: Operations {
593                    build: Some(BuildOperation {
594                        mode: BuildMode::Discovery,
595                        trigger: IngestTrigger::Loop,
596                        batch_size: 20,
597                        post_actions: None,
598                    }),
599                    sync: Some(SyncOperation {
600                        trigger: IngestTrigger::Manual,
601                        batch_size: 20,
602                    }),
603                    verify: None,
604                },
605            },
606        )
607        .unwrap();
608
609        let mount = Mount {
610            mem: "engine".to_string(),
611            schema: Some("default@1.0.0".parse().unwrap()),
612            storage: MountStorage::Folder {
613                path: root.to_path_buf(),
614            },
615            capability: MountCapability::Write,
616            lifecycle: MountLifecycle::Eager,
617            cross_linkable: false,
618            migration_target: None,
619        };
620        let mut engine = Engine::from_mounts(vec![(
621            mount,
622            Box::new(FilesystemMemWriter::new(root.to_path_buf()))
623                as Box<dyn crate::backend::MemBackend>,
624        )])
625        .unwrap();
626        engine
627            .set_mem_sync_state("engine", "engine/graph/graph#synced", "deadbeef", None)
628            .unwrap();
629
630        let ps = projection_status(&engine, root);
631        assert_eq!(ps.len(), 1);
632        let p = &ps[0];
633        assert_eq!(p.binding, "engine/graph");
634        assert_eq!(p.destination_mem, "engine");
635        assert_eq!(p.operations, vec!["build".to_string(), "sync".to_string()]);
636        let facet = p.state.get("graph").expect("the source facet's state");
637        assert_eq!(facet.signal, "git");
638        assert_eq!(facet.synced.as_deref(), Some("deadbeef"));
639        assert_eq!(facet.verified, None);
640        assert_eq!(
641            p.advance,
642            AdvanceCounts {
643                pending: 0,
644                disposed: 0
645            }
646        );
647    }
648
649    /// A workspace with no v2 binding store yields an empty array — status
650    /// never fails because a workspace declares no projections.
651    #[test]
652    fn projection_status_empty_without_bindings() {
653        let tmp = TempDir::new().unwrap();
654        let root = tmp.path();
655        std::fs::create_dir_all(root.join(".memstead")).unwrap();
656        std::fs::write(
657            root.join(".memstead").join("config.json"),
658            br#"{"format":1,"schema":"default@1.0.0"}"#,
659        )
660        .unwrap();
661        let mount = Mount {
662            mem: "engine".to_string(),
663            schema: Some("default@1.0.0".parse().unwrap()),
664            storage: MountStorage::Folder {
665                path: root.to_path_buf(),
666            },
667            capability: MountCapability::Write,
668            lifecycle: MountLifecycle::Eager,
669            cross_linkable: false,
670            migration_target: None,
671        };
672        let engine = Engine::from_mounts(vec![(
673            mount,
674            Box::new(FilesystemMemWriter::new(root.to_path_buf()))
675                as Box<dyn crate::backend::MemBackend>,
676        )])
677        .unwrap();
678        assert!(projection_status(&engine, root).is_empty());
679    }
680
681    // ---- G1: rollup verdict + top-3 actions -----------------------------
682
683    /// Build the same one-binding `engine/graph` workspace the status test uses,
684    /// returning the engine and root. Seeds **no** `#synced` baseline and **no**
685    /// anchors, so the mem predates its binding (the adopt case) unless the
686    /// caller seeds otherwise.
687    fn one_binding_workspace(tmp: &TempDir) -> Engine {
688        let root = tmp.path();
689        std::fs::create_dir_all(root.join(".memstead")).unwrap();
690        std::fs::write(
691            root.join(".memstead").join("config.json"),
692            br#"{"format":1,"schema":"default@1.0.0"}"#,
693        )
694        .unwrap();
695        std::fs::write(
696            root.join(".memstead").join("workspace.toml"),
697            "[workspace]\n",
698        )
699        .unwrap();
700        let out = std::process::Command::new("git")
701            .args(["init", "-q"])
702            .current_dir(root)
703            .output()
704            .unwrap();
705        assert!(out.status.success());
706
707        write_binding(
708            root,
709            "engine",
710            "graph",
711            &Binding {
712                version: BINDING_VERSION,
713                intent: None,
714                sources: vec![crate::pipeline::Source {
715                    name: "graph".to_string(),
716                    medium_type: MediumType::Codebase,
717                    pointer: String::new(),
718                    change_detection: Some("git".to_string()),
719                    scope: vec![PatternEntry {
720                        path: "**/*.rs".to_string(),
721                        mode: PatternMode::Allow,
722                    }],
723                    engagement: None,
724                    preparation: None,
725                }],
726                reference_mems: Vec::new(),
727                destination_mem: "engine".to_string(),
728                deny_paths: Vec::new(),
729                coverage_semantics: None,
730                rules: None,
731                prune: None,
732                operations: Operations {
733                    build: Some(BuildOperation {
734                        mode: BuildMode::Discovery,
735                        trigger: IngestTrigger::Loop,
736                        batch_size: 20,
737                        post_actions: None,
738                    }),
739                    sync: Some(SyncOperation {
740                        trigger: IngestTrigger::Manual,
741                        batch_size: 20,
742                    }),
743                    verify: None,
744                },
745            },
746        )
747        .unwrap();
748
749        let mount = Mount {
750            mem: "engine".to_string(),
751            schema: Some("default@1.0.0".parse().unwrap()),
752            storage: MountStorage::Folder {
753                path: root.to_path_buf(),
754            },
755            capability: MountCapability::Write,
756            lifecycle: MountLifecycle::Eager,
757            cross_linkable: false,
758            migration_target: None,
759        };
760        Engine::from_mounts(vec![(
761            mount,
762            Box::new(FilesystemMemWriter::new(root.to_path_buf()))
763                as Box<dyn crate::backend::MemBackend>,
764        )])
765        .unwrap()
766    }
767
768    /// G1 + E1 — a binding whose mem predates it (no anchors, never synced)
769    /// rolls up to an **onboarding** verdict, never `action-needed`: the
770    /// onboarding action is surfaced and pre-binding history alone drives no
771    /// red verdict (E1's refusal at the dashboard level). The per-binding
772    /// drill-down carries the SAME resolution the rollup
773    /// aggregates: an adopt (pre-binding) mem reads `onboarding` on its own
774    /// entry — never red, no moved flag, zero finding counts — and the
775    /// workspace rollup agrees (one truth, two projections).
776    #[test]
777    fn projection_status_carries_the_per_binding_verdict() {
778        let tmp = TempDir::new().unwrap();
779        let engine = one_binding_workspace(&tmp);
780        let statuses = projection_status(&engine, tmp.path());
781        assert_eq!(statuses.len(), 1);
782        let s = &statuses[0];
783        assert_eq!(s.verdict, RollupVerdict::Onboarding);
784        assert!(!s.source_moved, "onboarding skips the freshness scan");
785        assert_eq!(s.findings, FindingCounts::default());
786        // Wire shape: the verdict serializes kebab-case like the rollup's.
787        let json = serde_json::to_value(s).unwrap();
788        assert_eq!(json["verdict"], "onboarding");
789        assert_eq!(json["source_moved"], false);
790        assert_eq!(json["findings"]["unresolvable"], 0);
791        // And the workspace rollup resolves from the same scan.
792        let rollup = projection_rollup(&engine, tmp.path());
793        assert_eq!(rollup.verdict, RollupVerdict::Onboarding);
794    }
795
796    #[test]
797    fn rollup_adopt_binding_is_onboarding_not_action_needed() {
798        let tmp = TempDir::new().unwrap();
799        let engine = one_binding_workspace(&tmp);
800        let rollup = projection_rollup(&engine, tmp.path());
801        assert_eq!(rollup.verdict, RollupVerdict::Onboarding);
802        assert_ne!(
803            rollup.verdict,
804            RollupVerdict::ActionNeeded,
805            "pre-binding history alone must never be a red verdict"
806        );
807        assert!(
808            rollup
809                .actions
810                .iter()
811                .any(|a| a.contains("predates its binding")),
812            "the onboarding action is surfaced: {:?}",
813            rollup.actions
814        );
815        assert!(rollup.headline.contains("Onboarding"));
816    }
817
818    /// A workspace with no bindings does NOT roll up to `clean`.
819    ///
820    /// It used to (G1's original rule), and that is what 04/04's criterion 5
821    /// changes: a reader takes `clean` as an all-clear over the workspace,
822    /// and this rollup never looked at one. The empty case says
823    /// `nothing-declared` and names its subject instead.
824    #[test]
825    fn rollup_without_bindings_asserts_nothing_rather_than_clean() {
826        let tmp = TempDir::new().unwrap();
827        let root = tmp.path();
828        std::fs::create_dir_all(root.join(".memstead")).unwrap();
829        std::fs::write(
830            root.join(".memstead").join("config.json"),
831            br#"{"format":1,"schema":"default@1.0.0"}"#,
832        )
833        .unwrap();
834        let mount = Mount {
835            mem: "engine".to_string(),
836            schema: Some("default@1.0.0".parse().unwrap()),
837            storage: MountStorage::Folder {
838                path: root.to_path_buf(),
839            },
840            capability: MountCapability::Write,
841            lifecycle: MountLifecycle::Eager,
842            cross_linkable: false,
843            migration_target: None,
844        };
845        let engine = Engine::from_mounts(vec![(
846            mount,
847            Box::new(FilesystemMemWriter::new(root.to_path_buf()))
848                as Box<dyn crate::backend::MemBackend>,
849        )])
850        .unwrap();
851        let rollup = projection_rollup(&engine, root);
852        assert_eq!(rollup.verdict, RollupVerdict::NothingDeclared);
853        assert_eq!(rollup.subject, "no projection bindings");
854        assert!(
855            rollup.headline.contains("says nothing about the workspace"),
856            "the headline must not read as an all-clear: {}",
857            rollup.headline
858        );
859        assert!(rollup.actions.is_empty());
860        assert!(rollup.headline.contains("No projection bindings"));
861    }
862}