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