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 — or there are no bindings at all.
297    Clean,
298    /// Onboarding only: one or more bindings predate their binding (adopt) and
299    /// nothing else needs a maintenance pass. **A pre-binding mem is never a red
300    /// verdict** — 0% anchored is expected onboarding, not a defect (E1).
301    Onboarding,
302    /// One or more bindings carry open findings (drift, unresolvable anchors,
303    /// uncovered artifacts under exhaustive coverage, adjudication backlog) or
304    /// have a source that moved past its `#synced` baseline.
305    ActionNeeded,
306}
307
308impl RollupVerdict {
309    /// Stable wire string.
310    pub fn as_wire(&self) -> &'static str {
311        match self {
312            RollupVerdict::Clean => "clean",
313            RollupVerdict::Onboarding => "onboarding",
314            RollupVerdict::ActionNeeded => "action-needed",
315        }
316    }
317}
318
319/// The dashboard rollup (G1): one verdict, a one-line headline, and up to three
320/// concrete, highest-severity actions derived from the durable findings store
321/// plus freshness. The full per-binding numbers ride [`projection_status`] as
322/// the drill-down.
323#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
324pub struct Rollup {
325    /// The single lead verdict.
326    pub verdict: RollupVerdict,
327    /// A one-line human/agent summary of the workspace's projection health.
328    pub headline: String,
329    /// Up to three concrete next actions, highest-severity first (e.g. "3
330    /// entities describe source that no longer exists — run sync").
331    pub actions: Vec<String>,
332}
333
334impl Default for Rollup {
335    fn default() -> Self {
336        Rollup {
337            verdict: RollupVerdict::Clean,
338            headline: "No projection bindings declared.".to_string(),
339            actions: Vec::new(),
340        }
341    }
342}
343
344/// One candidate action with its severity — the higher, the more urgent. Used
345/// only to rank the top-three actions the rollup surfaces.
346struct Candidate {
347    severity: u8,
348    text: String,
349}
350
351/// Compute the dashboard rollup (G1) for every projection binding in the
352/// workspace: one verdict plus the top-three concrete actions, derived from the
353/// durable findings store and freshness (source movement vs. the `#synced`
354/// baseline). **Read-only** on every mem — it borrows `&Engine` (shared) and
355/// only reads the binding store, the findings store, and the live cursor.
356///
357/// Best-effort like [`projection_status`]: a workspace with no binding store, or
358/// one whose bindings fail to resolve, yields the default clean rollup rather
359/// than failing the whole status call.
360///
361/// A binding that predates its binding (no anchors, never synced) contributes an
362/// **onboarding** action, never a red one — its uncovered artifacts are the
363/// expected first-sync backfill worklist, so pre-binding history alone never
364/// drives an `action-needed` verdict (E1).
365pub fn projection_rollup(engine: &Engine, workspace_root: &Path) -> Rollup {
366    let Ok(configs) = load_pipeline_configs(workspace_root) else {
367        return Rollup::default();
368    };
369    if configs.bindings.is_empty() {
370        return Rollup::default();
371    }
372    let total = configs.bindings.len();
373
374    let mut candidates: Vec<Candidate> = Vec::new();
375    let mut action_bindings = 0usize;
376    let mut onboarding_bindings = 0usize;
377
378    for record in &configs.bindings {
379        let binding_id = format!("{}/{}", record.mem, record.name);
380        let binding = &record.config;
381        let Ok(resolved) = resolve_binding_run(&binding_id, binding) else {
382            continue;
383        };
384
385        // The SAME per-binding scan projection_status serves (one truth).
386        let resolution = resolve_binding_status(engine, workspace_root, binding, &resolved);
387
388        // Adopt (E1): a mem that predates its binding is onboarding, never a red
389        // verdict. Its uncovered artifacts are the backfill worklist, so we skip
390        // the findings/freshness scan that would otherwise read them as defects.
391        if resolution.onboarding {
392            onboarding_bindings += 1;
393            candidates.push(Candidate {
394                severity: 1,
395                text: format!(
396                    "`{binding_id}` predates its binding — 0% anchored is expected; run \
397                     `memstead projection sync {binding_id}` for a first-sync backfill"
398                ),
399            });
400            continue;
401        }
402
403        // Freshness: a change-detectable source moved past its `#synced` baseline.
404        if resolution.source_moved {
405            candidates.push(Candidate {
406                severity: 4,
407                text: format!(
408                    "`{binding_id}` source moved since the last sync — run `memstead projection \
409                     sync {binding_id}`"
410                ),
411            });
412        }
413
414        let FindingCounts {
415            unresolvable,
416            drifted,
417            uncovered,
418            queued,
419        } = resolution.findings;
420        if unresolvable > 0 {
421            candidates.push(Candidate {
422                severity: 6,
423                text: format!(
424                    "{unresolvable} entit{} in `{binding_id}` describe source that no longer \
425                     exists — run `memstead projection sync {binding_id}`",
426                    if unresolvable == 1 { "y" } else { "ies" }
427                ),
428            });
429        }
430        if drifted > 0 {
431            candidates.push(Candidate {
432                severity: 5,
433                text: format!(
434                    "{drifted} anchor(s) in `{binding_id}` drifted from their source — run \
435                     `memstead projection sync {binding_id}`"
436                ),
437            });
438        }
439        // Uncovered drives an action only under exhaustive coverage — a
440        // curated binding covers a deliberate slice, so uncovered is
441        // information, not a defect (B4). (`has_action` already encodes
442        // this rule; the candidate mirrors it.)
443        if uncovered > 0
444            && matches!(
445                crate::binding::effective_coverage_semantics(binding).value,
446                CoverageSemantics::Exhaustive
447            )
448        {
449            candidates.push(Candidate {
450                severity: 3,
451                text: format!(
452                    "{uncovered} in-scope source artifact(s) in `{binding_id}` carry no entity \
453                     — run `memstead projection verify {binding_id}`, then sync"
454                ),
455            });
456        }
457        if queued > 0 {
458            candidates.push(Candidate {
459                severity: 2,
460                text: format!(
461                    "{queued} finding(s) in `{binding_id}` queued for adjudication — run \
462                     `memstead projection verify {binding_id}`"
463                ),
464            });
465        }
466
467        if resolution.has_action {
468            action_bindings += 1;
469        }
470    }
471
472    // Highest severity first; the stable sort preserves insertion order within a
473    // severity so runs are reproducible.
474    candidates.sort_by_key(|c| std::cmp::Reverse(c.severity));
475    let actions: Vec<String> = candidates.into_iter().take(3).map(|c| c.text).collect();
476
477    let verdict = if action_bindings > 0 {
478        RollupVerdict::ActionNeeded
479    } else if onboarding_bindings > 0 {
480        RollupVerdict::Onboarding
481    } else {
482        RollupVerdict::Clean
483    };
484
485    let headline = match verdict {
486        RollupVerdict::ActionNeeded => format!(
487            "Action needed — {action_bindings} of {total} projection(s) have open findings or a \
488             moved source."
489        ),
490        RollupVerdict::Onboarding => format!(
491            "Onboarding — {onboarding_bindings} of {total} projection(s) predate their binding; a \
492             first-sync backfill is expected, not a defect."
493        ),
494        RollupVerdict::Clean => {
495            format!("All {total} projection(s) are in sync — no open findings, no moved sources.")
496        }
497    };
498
499    Rollup {
500        verdict,
501        headline,
502        actions,
503    }
504}
505
506#[cfg(test)]
507mod tests {
508    use super::*;
509    use crate::binding::{
510        BINDING_VERSION, Binding, BuildMode, BuildOperation, Operations, SyncOperation,
511    };
512    use crate::pipeline::{IngestTrigger, MediumType, PatternEntry, PatternMode};
513    use crate::pipeline_store::write_binding;
514    use crate::storage::FilesystemMemWriter;
515    use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
516    use tempfile::TempDir;
517
518    /// A workspace with one folder mem `engine` (also a git source tree), a v1
519    /// binding `engine/graph` over a `source-tree` facet (codebase / git), and
520    /// a seeded `#synced` baseline — the projection status reports the binding's
521    /// operations, the `git` signal, the synced token, and 0/0 advance.
522    #[test]
523    fn projection_status_reports_operations_signal_and_baseline() {
524        let tmp = TempDir::new().unwrap();
525        let root = tmp.path();
526        // Mem config so `sync_state` can be read/written.
527        std::fs::create_dir_all(root.join(".memstead")).unwrap();
528        std::fs::write(
529            root.join(".memstead").join("config.json"),
530            br#"{"format":1,"schema":"default@1.0.0"}"#,
531        )
532        .unwrap();
533        std::fs::write(
534            root.join(".memstead").join("workspace.toml"),
535            "[workspace]\n",
536        )
537        .unwrap();
538        // A git work tree so the codebase medium resolves the `git` strategy.
539        let out = std::process::Command::new("git")
540            .args(["init", "-q"])
541            .current_dir(root)
542            .output()
543            .unwrap();
544        assert!(out.status.success());
545
546        // The v2 binding with its inline source.
547        write_binding(
548            root,
549            "engine",
550            "graph",
551            &Binding {
552                version: BINDING_VERSION,
553                intent: None,
554                sources: vec![crate::pipeline::Source {
555                    name: "graph".to_string(),
556                    medium_type: MediumType::Codebase,
557                    pointer: String::new(),
558                    change_detection: Some("git".to_string()),
559                    scope: vec![PatternEntry {
560                        path: "**/*.rs".to_string(),
561                        mode: PatternMode::Allow,
562                    }],
563                    engagement: None,
564                    preparation: None,
565                }],
566                reference_mems: Vec::new(),
567                destination_mem: "engine".to_string(),
568                deny_paths: Vec::new(),
569                coverage_semantics: None,
570                rules: None,
571                prune: None,
572                operations: Operations {
573                    build: Some(BuildOperation {
574                        mode: BuildMode::Discovery,
575                        trigger: IngestTrigger::Loop,
576                        batch_size: 20,
577                        post_actions: None,
578                    }),
579                    sync: Some(SyncOperation {
580                        trigger: IngestTrigger::Manual,
581                        batch_size: 20,
582                    }),
583                    verify: None,
584                },
585            },
586        )
587        .unwrap();
588
589        let mount = Mount {
590            mem: "engine".to_string(),
591            schema: Some("default@1.0.0".parse().unwrap()),
592            storage: MountStorage::Folder {
593                path: root.to_path_buf(),
594            },
595            capability: MountCapability::Write,
596            lifecycle: MountLifecycle::Eager,
597            cross_linkable: false,
598            migration_target: None,
599        };
600        let mut engine = Engine::from_mounts(vec![(
601            mount,
602            Box::new(FilesystemMemWriter::new(root.to_path_buf()))
603                as Box<dyn crate::backend::MemBackend>,
604        )])
605        .unwrap();
606        engine
607            .set_mem_sync_state("engine", "engine/graph/graph#synced", "deadbeef", None)
608            .unwrap();
609
610        let ps = projection_status(&engine, root);
611        assert_eq!(ps.len(), 1);
612        let p = &ps[0];
613        assert_eq!(p.binding, "engine/graph");
614        assert_eq!(p.destination_mem, "engine");
615        assert_eq!(p.operations, vec!["build".to_string(), "sync".to_string()]);
616        let facet = p.state.get("graph").expect("the source facet's state");
617        assert_eq!(facet.signal, "git");
618        assert_eq!(facet.synced.as_deref(), Some("deadbeef"));
619        assert_eq!(facet.verified, None);
620        assert_eq!(
621            p.advance,
622            AdvanceCounts {
623                pending: 0,
624                disposed: 0
625            }
626        );
627    }
628
629    /// A workspace with no v2 binding store yields an empty array — status
630    /// never fails because a workspace declares no projections.
631    #[test]
632    fn projection_status_empty_without_bindings() {
633        let tmp = TempDir::new().unwrap();
634        let root = tmp.path();
635        std::fs::create_dir_all(root.join(".memstead")).unwrap();
636        std::fs::write(
637            root.join(".memstead").join("config.json"),
638            br#"{"format":1,"schema":"default@1.0.0"}"#,
639        )
640        .unwrap();
641        let mount = Mount {
642            mem: "engine".to_string(),
643            schema: Some("default@1.0.0".parse().unwrap()),
644            storage: MountStorage::Folder {
645                path: root.to_path_buf(),
646            },
647            capability: MountCapability::Write,
648            lifecycle: MountLifecycle::Eager,
649            cross_linkable: false,
650            migration_target: None,
651        };
652        let engine = Engine::from_mounts(vec![(
653            mount,
654            Box::new(FilesystemMemWriter::new(root.to_path_buf()))
655                as Box<dyn crate::backend::MemBackend>,
656        )])
657        .unwrap();
658        assert!(projection_status(&engine, root).is_empty());
659    }
660
661    // ---- G1: rollup verdict + top-3 actions -----------------------------
662
663    /// Build the same one-binding `engine/graph` workspace the status test uses,
664    /// returning the engine and root. Seeds **no** `#synced` baseline and **no**
665    /// anchors, so the mem predates its binding (the adopt case) unless the
666    /// caller seeds otherwise.
667    fn one_binding_workspace(tmp: &TempDir) -> Engine {
668        let root = tmp.path();
669        std::fs::create_dir_all(root.join(".memstead")).unwrap();
670        std::fs::write(
671            root.join(".memstead").join("config.json"),
672            br#"{"format":1,"schema":"default@1.0.0"}"#,
673        )
674        .unwrap();
675        std::fs::write(
676            root.join(".memstead").join("workspace.toml"),
677            "[workspace]\n",
678        )
679        .unwrap();
680        let out = std::process::Command::new("git")
681            .args(["init", "-q"])
682            .current_dir(root)
683            .output()
684            .unwrap();
685        assert!(out.status.success());
686
687        write_binding(
688            root,
689            "engine",
690            "graph",
691            &Binding {
692                version: BINDING_VERSION,
693                intent: None,
694                sources: vec![crate::pipeline::Source {
695                    name: "graph".to_string(),
696                    medium_type: MediumType::Codebase,
697                    pointer: String::new(),
698                    change_detection: Some("git".to_string()),
699                    scope: vec![PatternEntry {
700                        path: "**/*.rs".to_string(),
701                        mode: PatternMode::Allow,
702                    }],
703                    engagement: None,
704                    preparation: None,
705                }],
706                reference_mems: Vec::new(),
707                destination_mem: "engine".to_string(),
708                deny_paths: Vec::new(),
709                coverage_semantics: None,
710                rules: None,
711                prune: None,
712                operations: Operations {
713                    build: Some(BuildOperation {
714                        mode: BuildMode::Discovery,
715                        trigger: IngestTrigger::Loop,
716                        batch_size: 20,
717                        post_actions: None,
718                    }),
719                    sync: Some(SyncOperation {
720                        trigger: IngestTrigger::Manual,
721                        batch_size: 20,
722                    }),
723                    verify: None,
724                },
725            },
726        )
727        .unwrap();
728
729        let mount = Mount {
730            mem: "engine".to_string(),
731            schema: Some("default@1.0.0".parse().unwrap()),
732            storage: MountStorage::Folder {
733                path: root.to_path_buf(),
734            },
735            capability: MountCapability::Write,
736            lifecycle: MountLifecycle::Eager,
737            cross_linkable: false,
738            migration_target: None,
739        };
740        Engine::from_mounts(vec![(
741            mount,
742            Box::new(FilesystemMemWriter::new(root.to_path_buf()))
743                as Box<dyn crate::backend::MemBackend>,
744        )])
745        .unwrap()
746    }
747
748    /// G1 + E1 — a binding whose mem predates it (no anchors, never synced)
749    /// rolls up to an **onboarding** verdict, never `action-needed`: the
750    /// onboarding action is surfaced and pre-binding history alone drives no
751    /// red verdict (E1's refusal at the dashboard level). The per-binding
752    /// drill-down carries the SAME resolution the rollup
753    /// aggregates: an adopt (pre-binding) mem reads `onboarding` on its own
754    /// entry — never red, no moved flag, zero finding counts — and the
755    /// workspace rollup agrees (one truth, two projections).
756    #[test]
757    fn projection_status_carries_the_per_binding_verdict() {
758        let tmp = TempDir::new().unwrap();
759        let engine = one_binding_workspace(&tmp);
760        let statuses = projection_status(&engine, tmp.path());
761        assert_eq!(statuses.len(), 1);
762        let s = &statuses[0];
763        assert_eq!(s.verdict, RollupVerdict::Onboarding);
764        assert!(!s.source_moved, "onboarding skips the freshness scan");
765        assert_eq!(s.findings, FindingCounts::default());
766        // Wire shape: the verdict serializes kebab-case like the rollup's.
767        let json = serde_json::to_value(s).unwrap();
768        assert_eq!(json["verdict"], "onboarding");
769        assert_eq!(json["source_moved"], false);
770        assert_eq!(json["findings"]["unresolvable"], 0);
771        // And the workspace rollup resolves from the same scan.
772        let rollup = projection_rollup(&engine, tmp.path());
773        assert_eq!(rollup.verdict, RollupVerdict::Onboarding);
774    }
775
776    #[test]
777    fn rollup_adopt_binding_is_onboarding_not_action_needed() {
778        let tmp = TempDir::new().unwrap();
779        let engine = one_binding_workspace(&tmp);
780        let rollup = projection_rollup(&engine, tmp.path());
781        assert_eq!(rollup.verdict, RollupVerdict::Onboarding);
782        assert_ne!(
783            rollup.verdict,
784            RollupVerdict::ActionNeeded,
785            "pre-binding history alone must never be a red verdict"
786        );
787        assert!(
788            rollup
789                .actions
790                .iter()
791                .any(|a| a.contains("predates its binding")),
792            "the onboarding action is surfaced: {:?}",
793            rollup.actions
794        );
795        assert!(rollup.headline.contains("Onboarding"));
796    }
797
798    /// G1 — a workspace with no bindings rolls up to the default **clean**
799    /// verdict with no actions.
800    #[test]
801    fn rollup_empty_without_bindings_is_clean() {
802        let tmp = TempDir::new().unwrap();
803        let root = tmp.path();
804        std::fs::create_dir_all(root.join(".memstead")).unwrap();
805        std::fs::write(
806            root.join(".memstead").join("config.json"),
807            br#"{"format":1,"schema":"default@1.0.0"}"#,
808        )
809        .unwrap();
810        let mount = Mount {
811            mem: "engine".to_string(),
812            schema: Some("default@1.0.0".parse().unwrap()),
813            storage: MountStorage::Folder {
814                path: root.to_path_buf(),
815            },
816            capability: MountCapability::Write,
817            lifecycle: MountLifecycle::Eager,
818            cross_linkable: false,
819            migration_target: None,
820        };
821        let engine = Engine::from_mounts(vec![(
822            mount,
823            Box::new(FilesystemMemWriter::new(root.to_path_buf()))
824                as Box<dyn crate::backend::MemBackend>,
825        )])
826        .unwrap();
827        let rollup = projection_rollup(&engine, root);
828        assert_eq!(rollup.verdict, RollupVerdict::Clean);
829        assert!(rollup.actions.is_empty());
830        assert!(rollup.headline.contains("No projection bindings"));
831    }
832}