Skip to main content

memstead_base/ingest/
cursor.rs

1//! Source-cursor driver — assemble a [`SourceCursor`] from live workspace
2//! state, so the brief's changed-slice preface can steer a pass at what moved.
3//!
4//! Engine-side port of the plugin's `computeSourceCursor` (`inject.mjs`). For
5//! each of a binding's source facets it resolves the change-detection
6//! strategy, reads the durable baseline from the **destination** mem's
7//! `sync_state` (keyed `"<binding-id>/<facet-or-refmem>#synced"`, D4), computes
8//! the changed slice against the source's current state, and unions the
9//! per-facet slices.
10//!
11//! Strategies:
12//!   - **git** — diff the stored commit id against the source tree's current
13//!     `HEAD` (subprocess `git rev-parse` / `git diff --name-status`), with
14//!     the facet scope + ingest `deny_paths` pushed down as `:(glob)` /
15//!     `:(glob,exclude)` pathspecs.
16//!   - **graph** — diff the source mem's snapshot token via the engine's own
17//!     [`Engine::changes_since`]; reference mems are graph-detected too.
18//!   - **mtime** — enumerate the facet's files (minus the facet scope's own
19//!     denies *and* the ingest `deny_paths`, applied identically to the git
20//!     strategy's exclude pathspecs — see [`enumerate_facet_files`]), compute a
21//!     stat-map digest, memoise it under `.memstead.cache/ingest/source-cursor/`,
22//!     and diff the current digest against the memoised baseline via the pure
23//!     [`super::slice::mtime_slice_outcome`] core (precise, incl. deletions).
24//!
25//! **Deny invariance.** Ingest `deny_paths` are enforced identically by every
26//! strategy that reads a file tree — git, mtime, and refinement's enumeration,
27//! plus both token computations (`current_primary_token` / [`source_moved`]).
28//! A file matching a `deny_paths` entry appears in no changed slice, no
29//! refinement batch, and never influences the mtime digest or the
30//! `source_moved` token. The **graph** strategy is exempt *by definition*:
31//! `deny_paths` entries are file-path globs, but a graph source's artifacts are
32//! entities (entity-granular), so a file-path glob can never select one. This
33//! exemption is designed, not an omission.
34//!
35//! **One deny dialect.** A `deny_paths` entry is a **workspace-relative glob**,
36//! resolved by [`super::check_path::DenyOracle`] — the glob match plus the
37//! literal-base directory-prefix rule plus the malformed-entry fallback — the
38//! same resolver the path-check command answers with. The git strategy pushes
39//! what git's own pathspec dialect can express and filters its diff through
40//! the oracle afterwards, so the two strategies agree even on the entries
41//! pathspecs cannot express.
42//!
43//! Note that a `deny_paths` entry's resolution root is the WORKSPACE, while a
44//! facet-scope entry's is its source's `pointer` (since 2026-08-27). Those are
45//! two namespaces for two questions, not two dialects for one: an ingest deny
46//! spans every source in the binding, so it has no single pointer to be
47//! relative to. The plugin's
48//! PreToolUse deny hook enforces the *identical* dialect against the ingest
49//! agent's Read/Glob/Grep by asking the engine itself: `projection
50//! check-path` answers through [`super::check_path::check_deny_paths`], which
51//! reads the active binding's record fresh on every call (the pointer channel
52//! is [`super::check_path::write_active_binding_file`], published on
53//! consuming brief renders). A deny entry that selects **no file** in
54//! the project tree is surfaced as a rendered brief warning
55//! ([`SourceCursor::dead_denies`]) rather than silently no-op'ing — catching
56//! typos and un-migrated legacy bare names, never a hard error.
57//!
58//! **One empty-scope semantic.** A facet with **no allow patterns** is
59//! *unscoped* — and that is a **typed refusal**, identical on every file-tree
60//! strategy: git, mtime, and refinement all decline to diff or enumerate the
61//! whole medium (a `facet_unscoped` check gates it). No strategy silently emits an
62//! empty slice, enumeration, or batch for an unscoped facet; instead the source
63//! contributes [`NoSignalReason::Unscoped`], which renders in the brief. A
64//! facet that genuinely wants the whole medium writes `**/*`. This is a
65//! different field from the ingest's `deny_paths`: an **empty `deny_paths`**
66//! list is valid and means "no denies" — it never trips the unscoped refusal.
67//!
68//! **Visible no-signal.** Every source contributes a per-source outcome. A
69//! genuinely-unchanged source (baseline present, nothing moved) stays silent —
70//! the only documented silence, preserving the "brief is byte-identical to a
71//! plain roam when nothing moved" property. Every other no-signal condition —
72//! unscoped facet, `signal:none`, git failure / unknown baseline, missing graph
73//! snapshot — is collected as a [`NoSignalNote`] and rendered distinguishably.
74//!
75//! Load-bearing invariant: the new baseline `token` is only *collected* here
76//! (into `write_commands` / `reseed`); it is recorded by the engine's
77//! `set_mem_sync_state` writer when `projection advance` completes a full pass
78//! (D7). The driver never writes it.
79
80use std::collections::{BTreeMap, BTreeSet};
81use std::path::{Component, Path, PathBuf};
82use std::process::Command;
83
84use globset::{Glob, GlobSet, GlobSetBuilder};
85
86use crate::Engine;
87use crate::pipeline::{MediumType, PatternMode};
88
89use super::brief::{DeliveredUnit, DeliverySequence, NoSignalNote, SourceCursor, SyncCommand};
90use super::change_detection::{
91    StatMap, compute_stat_map, digest_stat_map, parse_digest_token, serialize_digest_token,
92};
93use super::resolve::{
94    ChangeStrategy, ResolvedIngest, ResolvedSource, find_git_root, resolve_change_strategy,
95};
96use super::slice::{
97    NoSignalReason, Slice, SliceOutcome, graph_slice_outcome, is_git_token, mtime_slice_outcome,
98};
99use crate::pipeline::Source;
100
101/// Lexically normalize a path — resolve `.` and `..` without touching the
102/// filesystem (no symlink resolution), matching Node's `path.resolve` on an
103/// already-absolute path.
104pub(super) fn normalize_lexical(path: &Path) -> PathBuf {
105    let mut out: Vec<Component> = Vec::new();
106    for comp in path.components() {
107        match comp {
108            Component::CurDir => {}
109            Component::ParentDir => match out.last() {
110                Some(Component::Normal(_)) => {
111                    out.pop();
112                }
113                Some(Component::RootDir | Component::Prefix(_)) => {}
114                _ => out.push(comp),
115            },
116            other => out.push(other),
117        }
118    }
119    out.iter().collect()
120}
121
122/// The relative path from `from` to `to` (both normalized), matching Node's
123/// `path.relative`.
124pub(super) fn relative_path(from: &Path, to: &Path) -> PathBuf {
125    let from = normalize_lexical(from);
126    let to = normalize_lexical(to);
127    let from_comps: Vec<Component> = from.components().collect();
128    let to_comps: Vec<Component> = to.components().collect();
129    let mut common = 0;
130    while common < from_comps.len()
131        && common < to_comps.len()
132        && from_comps[common] == to_comps[common]
133    {
134        common += 1;
135    }
136    let mut result = PathBuf::new();
137    for _ in common..from_comps.len() {
138        result.push("..");
139    }
140    for comp in &to_comps[common..] {
141        result.push(comp.as_os_str());
142    }
143    result
144}
145
146/// The medium pointer resolved to an absolute base directory. Public
147/// so init-time surfaces (CLI `projection init`) can resolve a medium
148/// base exactly as the strategies do — e.g. to warn when it falls
149/// outside the workspace root.
150pub fn medium_base(pointer: &str, workspace_root: &Path) -> PathBuf {
151    if pointer.is_empty() {
152        workspace_root.to_path_buf()
153    } else {
154        normalize_lexical(&workspace_root.join(pointer))
155    }
156}
157
158/// The relative path from `from` to `to`, lexically normalized — public so a
159/// caller holding two absolute paths (a workspace root and a source tree, say)
160/// can express one as a medium pointer against the other, the exact inverse of
161/// [`medium_base`].
162pub fn relative_to(from: &Path, to: &Path) -> PathBuf {
163    relative_path(from, to)
164}
165
166/// The honest caveat for a medium base that resolves outside the workspace
167/// root, or `None` when it does not — the single wording every front door
168/// that scaffolds a binding prints, so the layout split is named once, at the
169/// layout decision, in the same terms everywhere.
170///
171/// The shape is supported: enumeration, change detection, sync, and anchor
172/// resolution all work on it (measured on the dogfood's own out-of-root
173/// bindings, where zero anchors orphan). What degrades rides the message —
174/// `../…` artifact ids and a layout that must stay fixed — together with the
175/// recipe that avoids it. Only path-namespace mediums can be out-of-root;
176/// every other medium type yields `None`.
177pub fn out_of_root_layout_warning(
178    pointer: &str,
179    workspace_root: &Path,
180    medium_type: crate::pipeline::MediumType,
181) -> Option<String> {
182    use crate::pipeline::MediumType;
183    if !matches!(medium_type, MediumType::Codebase | MediumType::Filesystem) {
184        return None;
185    }
186    let base = medium_base(pointer, workspace_root);
187    // Canonicalize both sides when possible so symlinked roots (macOS /tmp)
188    // don't false-positive; fall back to the lexical forms for not-yet-existing
189    // paths.
190    let canon_base = std::fs::canonicalize(&base).unwrap_or(base);
191    let canon_root =
192        std::fs::canonicalize(workspace_root).unwrap_or_else(|_| workspace_root.to_path_buf());
193    if canon_base.starts_with(&canon_root) {
194        return None;
195    }
196    Some(format!(
197        "medium base '{}' resolves outside the workspace root '{}': supported — \
198         enumeration, change detection, and anchor resolution all work on this shape — \
199         but artifact ids render as workspace-relative '../…' chains and the \
200         workspace-to-source relative layout must stay fixed (moving either side \
201         breaks the pointer). To avoid the '../…' ids, root the workspace at the \
202         common parent directory containing every source tree.",
203        canon_base.display(),
204        canon_root.display()
205    ))
206}
207
208/// Workspace-relative deny globs excluding the engine's own state from
209/// every strategy's input set. Unconditional and non-configurable: a
210/// binding can never legitimately model `.memstead/`,
211/// `.memstead.cache/`, or a mount's resolved storage location as
212/// source artifacts — an allow glob covering them does not admit them.
213/// The dot-directories key on their *names* (the names are the
214/// contract, and a foreign workspace's `.memstead/` is still engine
215/// state); the mount storage locations key on their *resolved* paths
216/// because their directory names are configurable. Fail-open on an
217/// unreadable mount list: the name-based excludes stay in force.
218fn engine_state_denies(workspace_root: &Path) -> Vec<String> {
219    use crate::workspace_store::{FileWorkspaceStore, WorkspaceStoreAdapter};
220
221    let mut denies: Vec<String> = vec![
222        ".memstead/**".to_string(),
223        ".memstead.cache/**".to_string(),
224        "**/.memstead/**".to_string(),
225        "**/.memstead.cache/**".to_string(),
226    ];
227    if let Ok(ws) = FileWorkspaceStore.load(workspace_root) {
228        for mount in &ws.mounts {
229            let dir: Option<PathBuf> = match &mount.storage {
230                crate::workspace::MountStorage::GitBranch { gitdir, .. } => {
231                    gitdir.parent().map(Path::to_path_buf)
232                }
233                crate::workspace::MountStorage::Folder { path } => Some(path.clone()),
234                crate::workspace::MountStorage::Archive { path, .. } => {
235                    // A sealed archive is one file, not a tree.
236                    let rel = relative_path(workspace_root, &normalize_lexical(path));
237                    denies.push(rel.to_string_lossy().to_string());
238                    None
239                }
240                // No on-disk footprint to exclude.
241                crate::workspace::MountStorage::InMemory => None,
242            };
243            if let Some(dir) = dir {
244                let rel = relative_path(workspace_root, &normalize_lexical(&dir));
245                // A collapsed single-mem folder workspace stores the mem
246                // AT the workspace root — excluding `**` there would
247                // empty every denominator; skip it.
248                if !rel.as_os_str().is_empty() {
249                    denies.push(format!("{}/**", rel.to_string_lossy()));
250                }
251            }
252        }
253    }
254    denies
255}
256
257/// Whether `sha` names a commit that exists in the repo at `git_root`.
258/// `git cat-file -e <sha>^{commit}` — exit 0 iff present and a commit.
259fn commit_exists(git_root: &Path, sha: &str) -> bool {
260    Command::new("git")
261        .args(["cat-file", "-e", &format!("{sha}^{{commit}}")])
262        .current_dir(git_root)
263        .output()
264        .map(|o| o.status.success())
265        .unwrap_or(false)
266}
267
268/// `git rev-parse HEAD` in `git_root`, or `None` on any failure.
269fn git_head(git_root: &Path) -> Option<String> {
270    let out = Command::new("git")
271        .args(["rev-parse", "HEAD"])
272        .current_dir(git_root)
273        .output()
274        .ok()?;
275    if !out.status.success() {
276        return None;
277    }
278    let sha = String::from_utf8_lossy(&out.stdout).trim().to_string();
279    (!sha.is_empty()).then_some(sha)
280}
281
282/// Translate a pattern into a git pathspec relative to `git_root`, with
283/// `:(glob)` magic (or `:(glob,exclude)` for a deny).
284///
285/// `base` is the namespace the pattern is written in: the medium base for a
286/// facet scope pattern (source-relative), the workspace root for an
287/// ingest-level deny (workspace-relative). One join, named by the caller,
288/// rather than one hardcoded root that silently disagrees with the walk.
289///
290/// A `**`-prefixed pattern joins at `base` like every other pattern — the
291/// pointer join is what confines the diff to the medium subtree. Emitted
292/// verbatim (git-root-relative), a scope glob like `**/*.md` matched the
293/// WHOLE repository, so a sub-tree-pointed binding's changed slice presented
294/// artifacts its enumeration correctly kept out of `S(D)` and its `exclude`
295/// gate refused as out-of-scope — the two axes provably disagreed
296/// (drift-benchmark runs 03 and 06 on `plugin/graph`). `base` sits inside
297/// `git_root` by construction on the allow path (the root is found by walking
298/// up from `base`), so the joined form cannot escape the repo there; the
299/// escape fallback below keeps the historical repo-wide reading for a deny
300/// whose namespace root lies outside this repo, which for an exclude is the
301/// conservative direction.
302fn to_git_pathspec(pattern: &str, git_root: &Path, base: &Path, exclude: bool) -> String {
303    let magic = if exclude {
304        ":(glob,exclude)"
305    } else {
306        ":(glob)"
307    };
308    let resolved = normalize_lexical(&base.join(pattern));
309    let git_rel = relative_path(git_root, &resolved);
310    if pattern.starts_with("**")
311        && git_rel
312            .components()
313            .next()
314            .is_some_and(|c| c == Component::ParentDir)
315    {
316        return format!("{magic}{pattern}");
317    }
318    format!("{magic}{}", git_rel.to_string_lossy())
319}
320
321/// Like [`to_git_pathspec`], but `None` when the pattern resolves *outside*
322/// `git_root` (its git-relative path escapes with a leading `..`). Git fatals
323/// on an out-of-tree pathspec, so a cross-repo deny must be dropped from the
324/// diff rather than pushed — it can match nothing in this repo regardless.
325fn in_repo_pathspec(pattern: &str, git_root: &Path, base: &Path, exclude: bool) -> Option<String> {
326    // Prefix-free glob — same verbatim re-anchoring as `to_git_pathspec`.
327    if pattern.starts_with("**") {
328        return Some(to_git_pathspec(pattern, git_root, base, exclude));
329    }
330    let resolved = normalize_lexical(&base.join(pattern));
331    let git_rel = relative_path(git_root, &resolved);
332    if git_rel
333        .components()
334        .next()
335        .is_some_and(|c| c == Component::ParentDir)
336    {
337        return None;
338    }
339    let magic = if exclude {
340        ":(glob,exclude)"
341    } else {
342        ":(glob)"
343    };
344    Some(format!("{magic}{}", git_rel.to_string_lossy()))
345}
346
347/// Build a [`GlobSet`] from glob patterns, or `None` if any pattern is
348/// malformed. The namespace the patterns are written in is the caller's
349/// business — scope patterns are source-relative, ingest denies
350/// workspace-relative.
351pub(crate) fn build_glob_set(patterns: &[&str]) -> Option<GlobSet> {
352    build_glob_set_reporting(patterns).0
353}
354
355/// [`build_glob_set`], plus the patterns that would not compile.
356///
357/// All-or-nothing was the old contract and it degraded silently in both
358/// directions: one malformed allow emptied the whole enumeration, and one
359/// malformed deny disabled every deny and inflated the set. Now the valid
360/// patterns still compile and the malformed ones come back by name, so the
361/// caller can state the partiality instead of computing over it.
362/// `validate_binding` refuses a malformed scope pattern outright; this path
363/// carries records written before that gate existed.
364pub(crate) fn build_glob_set_reporting(patterns: &[&str]) -> (Option<GlobSet>, Vec<String>) {
365    let mut builder = GlobSetBuilder::new();
366    let mut malformed = Vec::new();
367    let mut any = false;
368    for pattern in patterns {
369        match Glob::new(pattern) {
370            Ok(g) => {
371                builder.add(g);
372                any = true;
373            }
374            Err(_) => malformed.push((*pattern).to_string()),
375        }
376    }
377    let set = if any { builder.build().ok() } else { None };
378    (set, malformed)
379}
380
381/// Whether a primary source's facet declares **no allow patterns** — an
382/// *unscoped* facet. This is the single condition behind the uniform
383/// empty-scope refusal ([`NoSignalReason::Unscoped`]): neither git nor mtime
384/// diffs or enumerates the whole medium for such a facet, and refinement emits
385/// no batch for it. It is orthogonal to the ingest's `deny_paths` — an empty
386/// deny list is not an unscoped facet.
387fn facet_unscoped(source: &Source) -> bool {
388    !source.scope.iter().any(|r| r.mode == PatternMode::Allow)
389}
390
391/// Enumerate the workspace-relative file paths a primary source's facet scope
392/// selects — the `mtime` strategy's input set. Mirrors the plugin's
393/// `enumerateFacetFiles`: the path-shaped mediums (`codebase` / `filesystem` /
394/// `git` — a git source's artifacts are paths pinned at a commit, so the walk
395/// is identical and only the anchor namespace differs); the facet's
396/// allow globs minus its deny globs, evaluated over the medium's directory
397/// tree. Returns a sorted, de-duplicated list. An unscoped facet (no allows)
398/// yields an empty list here — but callers must not treat that as signal: the
399/// strategy layer (`compute_mtime_slice` / `current_primary_token`) refuses
400/// an unscoped facet via `facet_unscoped` *before* enumerating, so the empty
401/// list is only ever reached for a genuinely-empty scoped enumeration.
402///
403/// ## Scope patterns are SOURCE-relative
404///
405/// A facet's own `scope` patterns resolve against the source's **pointer** —
406/// the same base the walk starts from, and the same convention the anchor
407/// artifact decision already ratified for every other surface around a
408/// binding. It is also what the rendered brief teaches, printing the pointer
409/// and the allow list directly beneath it.
410///
411/// Until 2026-08-27 the walk honoured the pointer and then matched each
412/// candidate by its *workspace*-relative path, with no join. The two readings
413/// coincide only for an empty pointer (the shape the scaffolder writes, and
414/// the only shape the enumeration test covered), which is why the defect stayed
415/// invisible: under a non-empty pointer a prefix-anchored pattern or a bare
416/// literal matched nothing, a `**`-prefixed pattern matched regardless, and a
417/// scope mixing the two produced a silently truncated denominator that every
418/// coverage figure was then computed over. [`scope_migration_notes`] names the
419/// patterns a binding still has in the old dialect.
420///
421/// `deny_paths` are the ingest-level denies (`ResolvedIngest::deny_paths`),
422/// applied on top of the facet's own scope denies. These stay
423/// **workspace-relative** — deliberately, and it is not a second dialect for
424/// the same thing: an ingest deny spans every source in the binding, so it has
425/// no single pointer to be relative to, and the git strategy pushes the same
426/// entries down as workspace-rooted `:(glob,exclude)` pathspecs. Scope is
427/// per-source and source-relative; ingest denies are per-binding and
428/// workspace-relative. Passing `&[]` yields the facet-scope-only behaviour.
429pub fn enumerate_facet_files(
430    source: &Source,
431    deny_paths: &[String],
432    workspace_root: &Path,
433) -> Vec<String> {
434    enumerate_facet_files_reported(source, deny_paths, workspace_root).files
435}
436
437/// [`enumerate_facet_files`], carrying what the enumeration could not speak
438/// for: patterns that would not compile, and patterns still in the retired
439/// workspace-relative dialect. Any surface that reduces the set to a figure
440/// must consult [`ScopeEnumeration::is_partial`] first.
441pub fn enumerate_facet_files_reported(
442    source: &Source,
443    deny_paths: &[String],
444    workspace_root: &Path,
445) -> ScopeEnumeration {
446    if !matches!(
447        source.medium_type,
448        MediumType::Codebase | MediumType::Filesystem | MediumType::Git
449    ) {
450        return ScopeEnumeration::default();
451    }
452    let legacy_dialect = scope_migration_notes(source);
453    let mut allows: Vec<&str> = Vec::new();
454    let mut scope_denies: Vec<&str> = Vec::new();
455    for rule in &source.scope {
456        match rule.mode {
457            PatternMode::Allow => allows.push(&rule.path),
458            PatternMode::Deny => scope_denies.push(&rule.path),
459        }
460    }
461    // Ingest deny_paths deny on top of the facet's own denies, in the
462    // workspace-relative grammar — the same entries the git strategy resolves
463    // as exclude pathspecs, so deny enforcement is strategy-invariant. They are
464    // matched against the candidate's workspace-relative path, NOT its
465    // source-relative one: an ingest deny spans every source in the binding.
466    let mut ws_denies: Vec<&str> = deny_paths.iter().map(String::as_str).collect();
467    // Engine self-exclusion — unconditional, below configuration; the
468    // git strategy pushes the same set as exclude pathspecs so the
469    // denominator stays strategy-invariant.
470    let forced = engine_state_denies(workspace_root);
471    for f in &forced {
472        ws_denies.push(f);
473    }
474    if allows.is_empty() {
475        return ScopeEnumeration {
476            legacy_dialect,
477            ..Default::default()
478        };
479    }
480    // Malformed patterns no longer take the whole set with them: the valid
481    // ones compile, the bad ones come back by name, and the caller states the
482    // partiality. A malformed ALLOW leaves the denominator short; a malformed
483    // DENY leaves it long. Either way the figure over it is not the answer.
484    let (allow_set, mut malformed) = build_glob_set_reporting(&allows);
485    let (scope_deny_set, deny_malformed) = build_glob_set_reporting(&scope_denies);
486    malformed.extend(deny_malformed);
487    // The ingest-level denies go through the SAME resolver the path-check
488    // command answers with — glob plus the literal-base directory-prefix rule
489    // plus the malformed-entry fallback — so a path denied at the hook can
490    // never be counted in the denominator. Sharing the glob library alone left
491    // the two disagreeing on exactly those extra rules.
492    let ws_oracle = super::check_path::DenyOracle::new(
493        &ws_denies
494            .iter()
495            .map(|d| (*d).to_string())
496            .collect::<Vec<_>>(),
497    );
498    let Some(allow_set) = allow_set else {
499        return ScopeEnumeration {
500            malformed,
501            legacy_dialect,
502            ..Default::default()
503        };
504    };
505
506    // Walk the medium's directory tree. A candidate is matched twice, in two
507    // namespaces: the facet's own scope patterns against its SOURCE-relative
508    // path (relative to the medium base below), the ingest-level denies
509    // against its workspace-relative one. The returned paths stay
510    // workspace-relative — that is the namespace every consumer of this list
511    // speaks. VCS internals are never source artifacts — they are pruned here
512    // so `.git/**` plumbing cannot enter `S(D)`, matching the git strategy
513    // (whose diffs never name `.git` files).
514    let base = medium_base(&source.pointer, workspace_root);
515    // Directory pruning by allow-prefix: a directory is entered only when
516    // some allow pattern could still match beneath it. Each pattern
517    // contributes its longest literal leading segment run (up to the first
518    // segment carrying a glob metacharacter); a directory whose segments
519    // diverge from every pattern's literal prefix can contain no match, so
520    // the walk never descends into it. A pattern that begins with a glob
521    // segment (`**/*.rs`) contributes the empty prefix and keeps the full
522    // walk — behaviour is unchanged for unanchored scopes. This is what
523    // keeps a broad-pointer facet (`..` with `dev/**/*.md`) from stat-walking
524    // every build tree and node_modules in the repository on each
525    // enumeration (status tokens, verify S(D), sync briefs all pass here).
526    let allow_prefixes: Vec<Vec<String>> = allows.iter().map(|p| glob_literal_prefix(p)).collect();
527    let mut out: Vec<String> = Vec::new();
528    let mut stack = vec![base.clone()];
529    while let Some(dir) = stack.pop() {
530        let Ok(entries) = std::fs::read_dir(&dir) else {
531            continue;
532        };
533        for entry in entries.flatten() {
534            let Ok(file_type) = entry.file_type() else {
535                continue;
536            };
537            let path = entry.path();
538            if file_type.is_dir() {
539                let skip = path.file_name().and_then(|n| n.to_str()).is_some_and(|n| {
540                    // VCS internals and engine state are never source
541                    // artifacts — pruning here saves the walk; the
542                    // forced deny globs enforce the same exclusion for
543                    // anything that still slips into a candidate list.
544                    VCS_INTERNAL_DIRS.contains(&n) || n == ".memstead" || n == ".memstead.cache"
545                });
546                if !skip {
547                    let dir_src_rel = relative_path(&base, &normalize_lexical(&path))
548                        .to_string_lossy()
549                        .to_string();
550                    if allow_could_match_under(&allow_prefixes, &dir_src_rel) {
551                        stack.push(path);
552                    }
553                }
554            } else if file_type.is_file() {
555                let normalized = normalize_lexical(&path);
556                let rel = relative_path(workspace_root, &normalized)
557                    .to_string_lossy()
558                    .to_string();
559                let src_rel = relative_path(&base, &normalized)
560                    .to_string_lossy()
561                    .to_string();
562                let denied = scope_deny_set
563                    .as_ref()
564                    .is_some_and(|d| d.is_match(&src_rel))
565                    || ws_oracle.is_denied(&rel);
566                if allow_set.is_match(&src_rel) && !denied {
567                    out.push(rel);
568                }
569            }
570        }
571    }
572    out.sort();
573    out.dedup();
574    ScopeEnumeration {
575        files: out,
576        malformed,
577        legacy_dialect,
578    }
579}
580
581/// The longest run of leading path segments in a glob pattern that carry no
582/// glob metacharacter — the literal region a match must live under. `dev/**/
583/// *.md` → `["dev"]`; `VISION.md` → `["VISION.md"]`; `**/*.rs` → `[]`.
584fn glob_literal_prefix(pattern: &str) -> Vec<String> {
585    pattern
586        .split('/')
587        .take_while(|seg| {
588            !seg.chars()
589                .any(|c| matches!(c, '*' | '?' | '[' | ']' | '{' | '}'))
590        })
591        .map(str::to_string)
592        .collect()
593}
594
595/// Whether any allow pattern could match a file somewhere under the
596/// directory at `dir_rel` (source-relative). True when, for some pattern's
597/// literal prefix, the directory's segments and the prefix agree over their
598/// common length: the directory is either an ancestor of the literal region
599/// (the walk must pass through it) or inside the pattern's glob region. An
600/// empty prefix (pattern starts with a glob segment) matches every
601/// directory — no pruning for unanchored scopes.
602fn allow_could_match_under(prefixes: &[Vec<String>], dir_rel: &str) -> bool {
603    let dir_segs: Vec<&str> = dir_rel.split('/').filter(|s| !s.is_empty()).collect();
604    prefixes.iter().any(|prefix| {
605        let n = dir_segs.len().min(prefix.len());
606        dir_segs[..n]
607            .iter()
608            .zip(prefix[..n].iter())
609            .all(|(d, p)| *d == p.as_str())
610    })
611}
612
613/// What an enumeration of a source's scope selected, and what it could not
614/// speak for.
615///
616/// The campaign rule this serves: a surface does not report a figure over
617/// state it did not examine. A coverage percentage computed over a denominator
618/// that silently lost patterns is exactly that figure, so the partiality
619/// travels with the set rather than being discarded at the seam.
620#[derive(Debug, Clone, Default, PartialEq, Eq)]
621pub struct ScopeEnumeration {
622    /// The workspace-relative artifact paths the scope selected.
623    pub files: Vec<String>,
624    /// Scope patterns that would not compile, by name. Their share of the
625    /// population is unknown, so any enumeration carrying one is partial.
626    pub malformed: Vec<String>,
627    /// Scope patterns still written in the retired workspace-relative
628    /// dialect — reported so an author is told before a pattern's meaning
629    /// changes, never after.
630    pub legacy_dialect: Vec<ScopePatternNote>,
631}
632
633impl ScopeEnumeration {
634    /// Whether this enumeration is known to be incomplete. A partial
635    /// enumeration must not be reduced to a percentage: the denominator is
636    /// not the population.
637    ///
638    /// BOTH causes count. A malformed pattern was skipped, so its share never
639    /// entered the walk; a pattern still in the retired workspace-relative
640    /// dialect selects nothing under the pointer join, so its share is
641    /// likewise absent. The second is the more dangerous of the two, because a
642    /// MIXED scope still enumerates and the surviving subset looks like a
643    /// population.
644    pub fn is_partial(&self) -> bool {
645        !self.malformed.is_empty() || !self.legacy_dialect.is_empty()
646    }
647
648    /// Why this enumeration is incomplete, naming the offending patterns, or
649    /// `None` when it is whole.
650    pub fn partiality_reason(&self) -> Option<String> {
651        let mut causes = Vec::new();
652        if !self.malformed.is_empty() {
653            causes.push(format!(
654                "scope pattern(s) that would not compile were skipped: {}",
655                self.malformed.join(", ")
656            ));
657        }
658        if !self.legacy_dialect.is_empty() {
659            causes.push(format!(
660                "scope pattern(s) are still written against the workspace root rather than the \
661                 source pointer, so they select nothing under the pointer join: {}",
662                self.legacy_dialect
663                    .iter()
664                    .map(|n| n.pattern.as_str())
665                    .collect::<Vec<_>>()
666                    .join(", ")
667            ));
668        }
669        (!causes.is_empty()).then(|| causes.join("; "))
670    }
671}
672
673/// One scope pattern still written in the retired workspace-relative dialect.
674///
675/// Emitted by [`scope_migration_notes`] so a binding author is told BEFORE a
676/// pattern's meaning changes, never after. The campaign rule this serves is
677/// the plain one: a surface does not quietly begin covering a different set.
678#[derive(Debug, Clone, PartialEq, Eq)]
679pub struct ScopePatternNote {
680    /// The pattern as written in the binding record.
681    pub pattern: String,
682    /// Whether it is an allow or a deny rule.
683    pub deny: bool,
684    /// The pattern rewritten into the source-relative dialect, when the
685    /// rewrite is mechanical (the pattern begins with the source's pointer).
686    /// `None` when it is not — the author has to decide what they meant.
687    pub suggested: Option<String>,
688}
689
690/// Name every scope pattern of `source` that reads as the retired
691/// workspace-relative dialect: one that begins with the source's own pointer,
692/// so it selected artifacts before the 2026-08-27 join and selects nothing
693/// after it.
694///
695/// A pointer-less source has no dialect to migrate (the two readings coincide
696/// — which is exactly why the defect stayed invisible on the scaffolded path),
697/// so it returns empty. A `**`-prefixed pattern is prefix-free and matched
698/// under both readings, so it is not reported.
699pub fn scope_migration_notes(source: &Source) -> Vec<ScopePatternNote> {
700    let pointer = source.pointer.trim_end_matches('/');
701    if pointer.is_empty() {
702        return Vec::new();
703    }
704    let prefix = format!("{pointer}/");
705    source
706        .scope
707        .iter()
708        .filter(|r| !r.path.starts_with("**"))
709        .filter_map(|rule| {
710            let stripped = rule.path.strip_prefix(&prefix)?;
711            Some(ScopePatternNote {
712                pattern: rule.path.clone(),
713                deny: rule.mode == PatternMode::Deny,
714                suggested: (!stripped.is_empty()).then(|| stripped.to_string()),
715            })
716        })
717        .collect()
718}
719
720/// Compute the git changed slice for one primary source between its stored
721/// baseline commit and the tree's current `HEAD`. Mirrors `computeGitSlice`.
722fn compute_git_slice(
723    source: &Source,
724    deny_paths: &[String],
725    workspace_root: &Path,
726    baseline: Option<&str>,
727) -> SliceOutcome {
728    let base = medium_base(&source.pointer, workspace_root);
729    let Some(git_root) = find_git_root(&base) else {
730        return SliceOutcome::NoSignal {
731            reason: NoSignalReason::GitUnavailable,
732        };
733    };
734    let Some(head) = git_head(&git_root) else {
735        return SliceOutcome::NoSignal {
736            reason: NoSignalReason::GitUnavailable,
737        };
738    };
739
740    let baseline = match baseline {
741        Some(b) if is_git_token(b) => b,
742        // No usable commit baseline — seed at HEAD, present no slice.
743        _ => return SliceOutcome::Reseed { token: head },
744    };
745    if baseline == head {
746        return SliceOutcome::Unchanged { token: head };
747    }
748    // A git-shaped baseline that THIS repo does not contain is not a usable
749    // baseline either — it is foreign (seeded when the pointer resolved to a
750    // different repo, e.g. before a source tree moved into a submodule) or
751    // gone (gc'd / rewritten away). Diffing against it fatals, which used to
752    // degrade every pass into `GitUnavailable` — a baseline that never seats
753    // and a binding that never backs off. Reseed at HEAD instead: one honest
754    // full re-roam, then normal change detection. `GitUnavailable` below is
755    // reserved for transient git failures on a baseline that does exist.
756    if !commit_exists(&git_root, baseline) {
757        return SliceOutcome::Reseed { token: head };
758    }
759
760    // Pathspecs from the facet scope + the ingest's deny_paths.
761    let mut allows: Vec<&str> = Vec::new();
762    let mut scope_denies: Vec<&str> = Vec::new();
763    for rule in &source.scope {
764        match rule.mode {
765            PatternMode::Allow => allows.push(&rule.path),
766            PatternMode::Deny => scope_denies.push(&rule.path),
767        }
768    }
769    if allows.is_empty() {
770        // Unscoped facet — the uniform typed refusal (never diff the whole
771        // repo); renders in the brief rather than degrading silently.
772        return SliceOutcome::NoSignal {
773            reason: NoSignalReason::Unscoped,
774        };
775    }
776    let mut ws_denies: Vec<&str> = deny_paths.iter().map(String::as_str).collect();
777    // Engine self-exclusion — same forced set the mtime strategy's
778    // enumeration applies, pushed down as exclude pathspecs so the
779    // slice never names engine state either.
780    let forced = engine_state_denies(workspace_root);
781    for f in &forced {
782        ws_denies.push(f);
783    }
784    let mut specs: Vec<String> =
785        Vec::with_capacity(allows.len() + scope_denies.len() + ws_denies.len());
786    // Scope patterns are source-relative, so they re-anchor against the medium
787    // base — the same join the mtime enumeration performs. Ingest denies below
788    // stay workspace-rooted; the two namespaces are per-source and per-binding
789    // respectively, not two dialects for one thing.
790    for a in &allows {
791        specs.push(to_git_pathspec(a, &git_root, &base, false));
792    }
793    // A deny may target a path OUTSIDE this medium's git repo — a
794    // cross-medium workspace-relative glob such as `../dev/**`, whose tree
795    // lives in a sibling repo. Git *fatals* on an out-of-tree pathspec
796    // (`'../dev/**' is outside repository`), which would sink the entire
797    // diff into a no-signal degrade. Such a deny can exclude nothing here
798    // anyway (the files simply aren't in this repo), so drop it: the plugin
799    // hook still enforces it agent-side (workspace-relative, cross-repo),
800    // and a genuinely-dead entry is still surfaced by the brief warning.
801    for d in &scope_denies {
802        if let Some(spec) = in_repo_pathspec(d, &git_root, &base, true) {
803            specs.push(spec);
804        }
805    }
806    for d in &ws_denies {
807        if let Some(spec) = in_repo_pathspec(d, &git_root, workspace_root, true) {
808            specs.push(spec);
809        }
810    }
811
812    let mut cmd = Command::new("git");
813    cmd.args([
814        "diff",
815        "--no-renames",
816        "--name-status",
817        baseline,
818        &head,
819        "--",
820    ]);
821    cmd.args(&specs);
822    cmd.current_dir(&git_root);
823    let out = match cmd.output() {
824        Ok(o) if o.status.success() => o,
825        // Unknown baseline (gc'd / rewritten), an out-of-repo pathspec, or a
826        // git failure — degrade to a whole re-roam (the plugin does the same).
827        _ => {
828            return SliceOutcome::NoSignal {
829                reason: NoSignalReason::GitUnavailable,
830            };
831        }
832    };
833    let text = String::from_utf8_lossy(&out.stdout);
834
835    // The exclude pathspecs above are git's own glob dialect and cannot
836    // express the two rules that extend the engine's deny resolution: the
837    // literal-base directory-prefix block, and the fallback that keeps a
838    // MALFORMED entry blocking by its literal prefix rather than dropping it.
839    // Pushing what git understands and then filtering the result through the
840    // same `DenyOracle` the mtime enumeration and the path-check command use
841    // is what makes deny enforcement genuinely strategy-invariant — before
842    // this, a `broken[` entry excluded a file from `S(D)` and left it in the
843    // git slice.
844    let ws_oracle = super::check_path::DenyOracle::new(
845        &ws_denies
846            .iter()
847            .map(|d| (*d).to_string())
848            .collect::<Vec<_>>(),
849    );
850
851    let mut slice = Slice::default();
852    for line in text.lines() {
853        if line.trim().is_empty() {
854            continue;
855        }
856        let Some(tab) = line.find('\t') else { continue };
857        let status = line[..tab].trim();
858        let git_path = line[tab + 1..].trim();
859        let ws_path = relative_path(workspace_root, &normalize_lexical(&git_root.join(git_path)))
860            .to_string_lossy()
861            .to_string();
862        if ws_oracle.is_denied(&ws_path) {
863            continue;
864        }
865        match status.chars().next() {
866            Some('A') => slice.added.push(ws_path),
867            Some('D') => slice.deleted.push(ws_path),
868            // M, T (type change), C, and the rest.
869            _ => slice.modified.push(ws_path),
870        }
871    }
872    slice.added.sort();
873    slice.modified.sort();
874    slice.deleted.sort();
875    SliceOutcome::Changed {
876        token: head,
877        slice,
878        degraded: false,
879    }
880}
881
882/// One parsed entry of a **graph** facet's scope — the entity-namespace
883/// counterpart of a path glob. A graph source selects entities, and an entity
884/// is not a path: matching id-shaped globs against `mem--slug` invites the
885/// "looks scoped, selects nothing" failure the dead-deny lint exists to catch
886/// on paths, so the vocabulary is explicit about which axis it selects on.
887///
888/// Grammar (the whole of it):
889///
890/// - `*` — every entity in the source mem
891/// - `type:<entity_type>` — entities of exactly that type
892/// - `id:<glob>` — entities whose full `mem--slug` id matches the glob
893///
894/// Anything else is refused at binding validation
895/// ([`crate::binding::validate_binding`]) rather than silently selecting
896/// nothing: a scope nothing interprets is the defect, not a permissible form.
897#[derive(Debug, Clone, PartialEq, Eq)]
898pub enum EntitySelector {
899    /// `*` — every entity in the mem.
900    All,
901    /// `type:<entity_type>` — exact type match.
902    Type(String),
903    /// `id:<glob>` — glob over the full entity id.
904    Id(String),
905}
906
907/// Parse one graph scope pattern. `None` for an unrecognised form — the
908/// caller decides whether that is a validation refusal (declaration time) or
909/// a skipped rule (run time, already refused at declaration).
910pub fn parse_entity_selector(pattern: &str) -> Option<EntitySelector> {
911    let pattern = pattern.trim();
912    if pattern == "*" {
913        return Some(EntitySelector::All);
914    }
915    if let Some(rest) = pattern.strip_prefix("type:") {
916        let rest = rest.trim();
917        if rest.is_empty() {
918            return None;
919        }
920        return Some(EntitySelector::Type(rest.to_string()));
921    }
922    if let Some(rest) = pattern.strip_prefix("id:") {
923        let rest = rest.trim();
924        if rest.is_empty() {
925            return None;
926        }
927        // A malformed glob is a refusal, not a rule that matches nothing.
928        Glob::new(rest).ok()?;
929        return Some(EntitySelector::Id(rest.to_string()));
930    }
931    None
932}
933
934/// Does `selector` select this entity?
935fn selector_matches(selector: &EntitySelector, id: &str, entity_type: &str) -> bool {
936    match selector {
937        EntitySelector::All => true,
938        EntitySelector::Type(t) => entity_type == t,
939        EntitySelector::Id(g) => Glob::new(g)
940            .ok()
941            .map(|glob| glob.compile_matcher().is_match(id))
942            .unwrap_or(false),
943    }
944}
945
946/// Enumerate the entity ids a **graph** source's facet scope selects — the
947/// graph medium's `S(D)`, the exact counterpart of [`enumerate_facet_files`]
948/// for a path medium. The source's `pointer` names the source mem; the store
949/// already holds every mounted mem's entities, so this is a filter over
950/// memory rather than any kind of walk.
951///
952/// Stubs are excluded: a stub is a placeholder the engine created for an
953/// unresolved reference, not an authored source artifact. Counting them would
954/// inflate the denominator with entities the source never wrote, making
955/// coverage look worse than it is for a reason no author can act on.
956///
957/// An unscoped facet (no allow rules) yields an empty list here — callers must
958/// not read that as "nothing in scope"; the strategy layer refuses an unscoped
959/// facet before reaching this, exactly as it does for the path mediums.
960pub fn enumerate_graph_entities(engine: &Engine, source: &Source) -> Vec<String> {
961    if source.medium_type != MediumType::Graph {
962        return Vec::new();
963    }
964    let mut allows: Vec<EntitySelector> = Vec::new();
965    let mut denies: Vec<EntitySelector> = Vec::new();
966    for rule in &source.scope {
967        // An unparseable rule is already a validation refusal; at run time it
968        // selects nothing rather than everything — a scope the engine cannot
969        // read must never widen reach.
970        let Some(sel) = parse_entity_selector(&rule.path) else {
971            continue;
972        };
973        match rule.mode {
974            PatternMode::Allow => allows.push(sel),
975            PatternMode::Deny => denies.push(sel),
976        }
977    }
978    if allows.is_empty() {
979        return Vec::new();
980    }
981    let mem = source.pointer.as_str();
982    let mut out: Vec<String> = Vec::new();
983    for entity in engine.store().all_entities() {
984        if entity.mem != mem || entity.stub {
985            continue;
986        }
987        let id = entity.id.0.as_str();
988        let ty = entity.entity_type.as_str();
989        if !allows.iter().any(|s| selector_matches(s, id, ty)) {
990            continue;
991        }
992        if denies.iter().any(|s| selector_matches(s, id, ty)) {
993            continue;
994        }
995        out.push(id.to_string());
996    }
997    out.sort();
998    out.dedup();
999    out
1000}
1001
1002/// Enumerate one primary source's in-scope artifacts, whatever its medium —
1003/// the single entry point every `S(D)` consumer uses. Path-shaped mediums
1004/// (codebase / filesystem / git) walk the file tree; a graph source filters
1005/// the source mem's entities. A medium the matrix marks non-enumerable
1006/// yields nothing, and its callers render the non-enumerable basis rather
1007/// than a denominator.
1008///
1009/// This exists because the enumeration bail was never in one place: five call
1010/// sites each repeated the same loop over `enumerate_facet_files`, so teaching
1011/// only the report about a new medium left the findings store, the refinement
1012/// rotation, and the exclude membership gate empty-handed.
1013pub fn enumerate_source_artifacts(
1014    engine: &Engine,
1015    source: &Source,
1016    deny_paths: &[String],
1017    workspace_root: &Path,
1018) -> Vec<String> {
1019    enumerate_source_artifacts_reported(engine, source, deny_paths, workspace_root).files
1020}
1021
1022/// [`enumerate_source_artifacts`], carrying what the enumeration could not
1023/// speak for. Any surface reducing `S(D)` to a figure consults
1024/// [`ScopeEnumeration::is_partial`] first.
1025pub fn enumerate_source_artifacts_reported(
1026    engine: &Engine,
1027    source: &Source,
1028    deny_paths: &[String],
1029    workspace_root: &Path,
1030) -> ScopeEnumeration {
1031    match source.medium_type {
1032        MediumType::Codebase | MediumType::Filesystem | MediumType::Git => {
1033            enumerate_facet_files_reported(source, deny_paths, workspace_root)
1034        }
1035        // Entity selectors are their own grammar with their own parser — a
1036        // graph scope has no glob to malform and no pointer dialect to migrate.
1037        MediumType::Graph => ScopeEnumeration {
1038            files: enumerate_graph_entities(engine, source),
1039            ..Default::default()
1040        },
1041        MediumType::Web => ScopeEnumeration::default(),
1042    }
1043}
1044
1045/// Compute the graph changed slice for a source mem between its stored
1046/// baseline snapshot token and the mem's current head. Mirrors
1047/// `computeGraphSlice`, using the engine's own change history.
1048/// Restrict a graph changed slice to the facet's scope. Without this the
1049/// selector was honoured by enumeration and ignored by change detection, so a
1050/// brief could print `Entities: type:concept` and then hand the agent a
1051/// changed `memo` two sections below — an artifact its own coverage model
1052/// says is out of scope, which `advance` would then accept because its gate
1053/// is the presented slice.
1054///
1055/// Added and modified entities are classified from the live store. **Deleted
1056/// entities are kept unconditionally**: the entity is gone, so its type can no
1057/// longer be read, and a deletion that cannot be classified must be reported
1058/// rather than dropped — a missed deletion is the highest-signal drift there
1059/// is. An `id:` selector still applies to deletions, because an id is all a
1060/// deletion leaves behind.
1061fn filter_graph_slice_to_scope(engine: &Engine, source: &Source, slice: &mut Slice) {
1062    let mut allows: Vec<EntitySelector> = Vec::new();
1063    let mut denies: Vec<EntitySelector> = Vec::new();
1064    for rule in &source.scope {
1065        let Some(sel) = parse_entity_selector(&rule.path) else {
1066            continue;
1067        };
1068        match rule.mode {
1069            PatternMode::Allow => allows.push(sel),
1070            PatternMode::Deny => denies.push(sel),
1071        }
1072    }
1073    if allows.is_empty() {
1074        return;
1075    }
1076    let in_scope = |id: &str, known_type: Option<&str>| {
1077        // A `type:` selector cannot judge an entity whose type is unreadable
1078        // (a deletion). Treat it as matching so the artifact survives to be
1079        // reported, rather than silently vanishing from the slice.
1080        let matches = |s: &EntitySelector| match (s, known_type) {
1081            (EntitySelector::Type(_), None) => true,
1082            _ => selector_matches(s, id, known_type.unwrap_or_default()),
1083        };
1084        allows.iter().any(&matches) && !denies.iter().any(&matches)
1085    };
1086    let type_of = |id: &str| {
1087        engine
1088            .store()
1089            .get(&crate::entity::EntityId::canonical(id))
1090            .map(|e| e.entity_type.clone())
1091    };
1092    slice
1093        .added
1094        .retain(|id| in_scope(id, type_of(id).as_deref()));
1095    slice
1096        .modified
1097        .retain(|id| in_scope(id, type_of(id).as_deref()));
1098    slice.deleted.retain(|id| in_scope(id, None));
1099}
1100
1101fn compute_graph_slice(
1102    engine: &Engine,
1103    source: Option<&Source>,
1104    source_mem: &str,
1105    baseline: Option<&str>,
1106) -> SliceOutcome {
1107    let current = match engine.mem_head_sha(source_mem) {
1108        Ok(Some(sha)) => sha,
1109        // Source has no snapshot signal, or is unknown — degrade.
1110        _ => {
1111            return SliceOutcome::NoSignal {
1112                reason: NoSignalReason::GraphSnapshotMissing,
1113            };
1114        }
1115    };
1116    // Fetch the entity delta only when the source actually moved.
1117    let changed = matches!(baseline, Some(b) if is_git_token(b) && b != current);
1118    let mut outcome = if changed {
1119        let baseline = baseline.expect("changed implies a baseline");
1120        match engine.changes_since(source_mem, baseline, None) {
1121            Ok(report) => graph_slice_outcome(Some(baseline), &current, &report.changes),
1122            // Unknown baseline / engine error — degrade.
1123            Err(_) => SliceOutcome::NoSignal {
1124                reason: NoSignalReason::GraphSnapshotMissing,
1125            },
1126        }
1127    } else {
1128        graph_slice_outcome(baseline, &current, &[])
1129    };
1130    // The scope narrows the slice exactly as it narrows S(D). Applied after
1131    // the diff rather than pushed into it, mirroring how the path strategies
1132    // apply deny pathspecs — one place decides what "in scope" means.
1133    // A reference mem carries no facet scope — it is read whole by design,
1134    // so there is nothing to narrow by and `None` is the honest input.
1135    if let (Some(source), SliceOutcome::Changed { slice, .. }) = (source, &mut outcome) {
1136        filter_graph_slice_to_scope(engine, source, slice);
1137    }
1138    outcome
1139}
1140
1141// ── mtime source-cursor memo ────────────────────────────────────────────────
1142//
1143// The `mtime` strategy's durable baseline is a small digest token (in the
1144// destination mem's `sync_state`), which cannot by itself say *which* files
1145// changed. The engine keeps a rebuildable memo — the full stat map keyed by
1146// its digest aggregate — so a run whose baseline matches a memoised aggregate
1147// diffs precisely (incl. deletions) instead of degrading to a full scan.
1148//
1149// The memo lives engine-side under `<workspace>/.memstead.cache/ingest/` in
1150// the plugin's format (`{aggregate: {relpath: {mtime, size}}}`), so the engine
1151// and the transition-era skill share it. It is pure engine-internal cache —
1152// not mem-repo, not the graph — so writing it during brief rendering is not a
1153// tracked mutation. A write failure only costs the next run's precision.
1154
1155/// The `<cache_root>/source-cursor/<ingest>/<facet>.json` memo path.
1156fn cursor_memo_path(cache_root: &Path, ingest_name: &str, facet_ref: &str) -> PathBuf {
1157    let safe: String = facet_ref
1158        .chars()
1159        .map(|c| {
1160            if c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-') {
1161                c
1162            } else {
1163                '_'
1164            }
1165        })
1166        .collect();
1167    cache_root
1168        .join("source-cursor")
1169        .join(ingest_name)
1170        .join(format!("{safe}.json"))
1171}
1172
1173/// Read the stat map memoised under `aggregate` for a facet, or `None` on miss.
1174fn read_cursor_memo(
1175    cache_root: &Path,
1176    ingest: &str,
1177    facet: &str,
1178    aggregate: &str,
1179) -> Option<StatMap> {
1180    let bytes = std::fs::read(cursor_memo_path(cache_root, ingest, facet)).ok()?;
1181    let memo: BTreeMap<String, StatMap> = serde_json::from_slice(&bytes).ok()?;
1182    memo.get(aggregate).cloned()
1183}
1184
1185/// Memoise the current stat map under its aggregate, bounding the file to the
1186/// 3 most-recent aggregates. Best-effort.
1187fn write_cursor_memo(cache_root: &Path, ingest: &str, facet: &str, aggregate: &str, map: &StatMap) {
1188    let path = cursor_memo_path(cache_root, ingest, facet);
1189    let mut memo: BTreeMap<String, StatMap> = std::fs::read(&path)
1190        .ok()
1191        .and_then(|b| serde_json::from_slice(&b).ok())
1192        .unwrap_or_default();
1193    memo.insert(aggregate.to_string(), map.clone());
1194    if memo.len() > 3 {
1195        // Keep the just-written aggregate plus up to two others.
1196        let drop: Vec<String> = memo
1197            .keys()
1198            .filter(|k| k.as_str() != aggregate)
1199            .skip(2)
1200            .cloned()
1201            .collect();
1202        for key in drop {
1203            memo.remove(&key);
1204        }
1205    }
1206    if let Some(parent) = path.parent() {
1207        let _ = std::fs::create_dir_all(parent);
1208    }
1209    if let Ok(bytes) = serde_json::to_vec(&memo) {
1210        let _ = std::fs::write(&path, bytes);
1211    }
1212}
1213
1214/// VCS metadata directories — never source artifacts. Pruned from source
1215/// enumeration (`S(D)`, mtime slices, advance) and from the dead-deny scan.
1216const VCS_INTERNAL_DIRS: &[&str] = &[".git", ".svn", ".hg"];
1217
1218/// Directory names never worth walking for the dead-deny scan — build output,
1219/// VCS metadata ([`VCS_INTERNAL_DIRS`]), dependency caches, and the engine's
1220/// own cache.
1221const DEAD_DENY_SKIP_DIRS: &[&str] = &[
1222    ".git",
1223    "node_modules",
1224    "target",
1225    "dist",
1226    ".memstead.cache",
1227    ".sqlx",
1228    ".svn",
1229    ".hg",
1230];
1231
1232/// Bounded, pruned walk of `base` collecting every file's **workspace-relative**
1233/// path (the same string space the deny globs match). Skips heavy directories
1234/// ([`DEAD_DENY_SKIP_DIRS`]) and gives up (returns `None`) past `cap` files, so
1235/// the dead-deny scan degrades to "can't tell" rather than warning falsely or
1236/// walking an unbounded tree. Best-effort: unreadable directories are skipped.
1237fn walk_tree_bounded(base: &Path, workspace_root: &Path, cap: usize) -> Option<Vec<String>> {
1238    let mut out: Vec<String> = Vec::new();
1239    let mut stack = vec![base.to_path_buf()];
1240    while let Some(dir) = stack.pop() {
1241        let Ok(entries) = std::fs::read_dir(&dir) else {
1242            continue;
1243        };
1244        for entry in entries.flatten() {
1245            let Ok(file_type) = entry.file_type() else {
1246                continue;
1247            };
1248            let path = entry.path();
1249            if file_type.is_dir() {
1250                let skip = path
1251                    .file_name()
1252                    .and_then(|n| n.to_str())
1253                    .is_some_and(|n| DEAD_DENY_SKIP_DIRS.contains(&n));
1254                if !skip {
1255                    stack.push(path);
1256                }
1257            } else if file_type.is_file() {
1258                if out.len() >= cap {
1259                    return None;
1260                }
1261                out.push(
1262                    relative_path(workspace_root, &normalize_lexical(&path))
1263                        .to_string_lossy()
1264                        .to_string(),
1265                );
1266            }
1267        }
1268    }
1269    Some(out)
1270}
1271
1272/// The ingest `deny_paths` entries that select **no file** in the project tree
1273/// — surfaced as a rendered brief warning (AC 6 refusal leg) so a zero-matching
1274/// deny is never a silent no-op. Resolution base is the medium's git project
1275/// root (so a cross-medium workspace-relative deny like `../dev/**`, whose
1276/// target lives outside a sub-medium, still resolves against real files),
1277/// falling back to the workspace root. Answers through the *same*
1278/// [`super::check_path::DenyOracle`] the strategies and the path-check command
1279/// use, so "does this deny select anything" is decided by the resolution that
1280/// actually enforces it — including the literal-base directory-prefix rule and
1281/// the malformed-entry fallback. Answering with the raw glob alone told an
1282/// author that a working bare-name entry (`dev`) matched nothing and invited
1283/// them to delete it, which would have raised the denominator.
1284/// Best-effort: if the tree can't be enumerated
1285/// (walk cap hit, no readable base) nothing is reported — a warning is only
1286/// ever raised on a confirmed zero-match.
1287///
1288/// The scaffold's own default hygiene entries
1289/// ([`crate::binding::DEFAULT_SCAFFOLD_DENY_PATHS`]) are exempt: `projection
1290/// init` writes them into every codebase/filesystem binding, and most trees
1291/// carry none of the debris they name. The exemption is a membership check
1292/// against that constant, not a prediction about what the enumerator walks —
1293/// enumeration is a filesystem walk even on a git-signalled source, so these
1294/// entries CAN match (deleting `**/node_modules/**` from a scaffolded record
1295/// over a repo that gitignores `node_modules/` raises the denominator). The
1296/// engine never calls its own output a typo; a user-authored entry that
1297/// matches nothing keeps the loud warning.
1298fn dead_deny_entries(resolved: &ResolvedIngest, workspace_root: &Path) -> Vec<String> {
1299    if resolved.deny_paths.is_empty() {
1300        return Vec::new();
1301    }
1302    let base = find_git_root(workspace_root).unwrap_or_else(|| workspace_root.to_path_buf());
1303    let Some(files) = walk_tree_bounded(&base, workspace_root, 100_000) else {
1304        return Vec::new();
1305    };
1306    let mut dead: Vec<String> = Vec::new();
1307    for entry in &resolved.deny_paths {
1308        if crate::binding::DEFAULT_SCAFFOLD_DENY_PATHS.contains(&entry.as_str()) {
1309            continue;
1310        }
1311        let oracle = super::check_path::DenyOracle::new(std::slice::from_ref(entry));
1312        if oracle.is_empty() {
1313            // An empty entry resolves to nothing either way — not a confirmed
1314            // zero-match, so it is not reported here.
1315            continue;
1316        }
1317        if !files.iter().any(|f| oracle.is_denied(f)) {
1318            dead.push(entry.clone());
1319        }
1320    }
1321    dead
1322}
1323
1324/// Compute the `mtime` changed slice for one primary source: enumerate the
1325/// facet files, stat them, memoise the current map, and diff against the
1326/// baseline digest's memoised map (precise) or degrade to a full scan on memo
1327/// miss. Mirrors the mtime branch of the plugin's `computeSourceCursor`.
1328fn compute_mtime_slice(
1329    source: &Source,
1330    ingest_name: &str,
1331    deny_paths: &[String],
1332    workspace_root: &Path,
1333    cache_root: &Path,
1334    baseline: Option<&str>,
1335) -> SliceOutcome {
1336    if facet_unscoped(source) {
1337        // Unscoped facet — the same typed refusal git raises, so the mtime
1338        // strategy never enumerates the whole medium nor emits an empty slice.
1339        return SliceOutcome::NoSignal {
1340            reason: NoSignalReason::Unscoped,
1341        };
1342    }
1343    let files = enumerate_facet_files(source, deny_paths, workspace_root);
1344    let now_map = compute_stat_map(&files, workspace_root);
1345    let now_digest = digest_stat_map(&now_map);
1346    write_cursor_memo(
1347        cache_root,
1348        ingest_name,
1349        &source.name,
1350        &now_digest.aggregate,
1351        &now_map,
1352    );
1353    let prev_map = baseline
1354        .and_then(parse_digest_token)
1355        .and_then(|base| read_cursor_memo(cache_root, ingest_name, &source.name, &base.aggregate));
1356    mtime_slice_outcome(baseline, prev_map.as_ref(), &now_map)
1357}
1358
1359/// The current change-detection token for a primary source, per its resolved
1360/// strategy: git `HEAD`, the graph mem's snapshot, or the freshly-computed
1361/// mtime digest. `None` when there is no signal.
1362fn current_primary_token(
1363    engine: &Engine,
1364    source: &Source,
1365    deny_paths: &[String],
1366    workspace_root: &Path,
1367) -> Option<String> {
1368    match resolve_change_strategy(source, workspace_root) {
1369        ChangeStrategy::Git => git_head(&find_git_root(&medium_base(
1370            &source.pointer,
1371            workspace_root,
1372        ))?),
1373        ChangeStrategy::Graph => {
1374            if facet_unscoped(source) {
1375                // Symmetric with the mtime arm: no signal at all, rather than
1376                // a whole-mem token posing as a scoped one.
1377                None
1378            } else {
1379                engine.mem_head_sha(&source.pointer).ok().flatten()
1380            }
1381        }
1382        ChangeStrategy::Mtime => {
1383            if facet_unscoped(source) {
1384                // Unscoped facet has no signal — not an empty-set digest posing
1385                // as one, so the source can never register as "moved".
1386                None
1387            } else {
1388                let files = enumerate_facet_files(source, deny_paths, workspace_root);
1389                Some(serialize_digest_token(&digest_stat_map(&compute_stat_map(
1390                    &files,
1391                    workspace_root,
1392                ))))
1393            }
1394        }
1395        ChangeStrategy::None => None,
1396    }
1397}
1398
1399/// Whether any of an ingest's sources moved since its last synced pass — the
1400/// cheap, slice-free predicate the backoff uses as its additive second
1401/// trigger. Compares each source's current token to the baseline stored in the
1402/// destination mem's `sync_state`; a source with no baseline is not "moved"
1403/// (a first sync does not by itself defeat backoff). Mirrors the plugin's
1404/// `sourceChangedSince`.
1405pub fn source_moved(engine: &Engine, resolved: &ResolvedIngest, workspace_root: &Path) -> bool {
1406    source_moved_since(engine, resolved, workspace_root, "synced", false)
1407}
1408
1409/// The generalized form of [`source_moved`]: compare each source's current
1410/// change-detection token against the baseline stored under
1411/// `"<binding>/<facet>#<state>"` in the destination mem's `sync_state`. The
1412/// `state` suffix selects the baseline family — `"synced"` (the build/sync
1413/// baseline [`source_moved`] reads) or `"verified"` (the verify baseline).
1414///
1415/// `missing_baseline_is_moved` decides the never-recorded case: `false`
1416/// preserves [`source_moved`]'s posture (no baseline ⇒ not "moved" — a first
1417/// sync does not by itself defeat backoff); `true` treats a source with a live
1418/// current token but no recorded baseline as moved — the verify due-check's
1419/// posture, where "never verified" means the first verify is due.
1420pub fn source_moved_since(
1421    engine: &Engine,
1422    resolved: &ResolvedIngest,
1423    workspace_root: &Path,
1424    state: &str,
1425    missing_baseline_is_moved: bool,
1426) -> bool {
1427    let dest = &resolved.destination_mem;
1428    let baseline_map = engine
1429        .mem_config_for(dest)
1430        .map(|c| c.sync_state.clone())
1431        .unwrap_or_default();
1432
1433    for source in &resolved.sources {
1434        let (facet_ref, current) = match source {
1435            ResolvedSource::Primary(p) => (
1436                p.name.clone(),
1437                current_primary_token(engine, p, &resolved.deny_paths, workspace_root),
1438            ),
1439            ResolvedSource::Reference { mem } => {
1440                (mem.clone(), engine.mem_head_sha(mem).ok().flatten())
1441            }
1442        };
1443        let key = format!("{}/{}#{state}", resolved.name, facet_ref);
1444        let Some(baseline) = baseline_map.get(&key) else {
1445            // No baseline recorded for this state family.
1446            if missing_baseline_is_moved && current.as_deref().is_some_and(|c| !c.is_empty()) {
1447                return true;
1448            }
1449            continue;
1450        };
1451        if let Some(current) = current
1452            && !current.is_empty()
1453            && current != *baseline
1454        {
1455            return true;
1456        }
1457    }
1458    false
1459}
1460
1461/// Assemble the combined [`SourceCursor`] for an ingest from live state: the
1462/// destination mem's `sync_state` baselines and each source's current state.
1463pub fn compute_source_cursor(
1464    engine: &Engine,
1465    resolved: &ResolvedIngest,
1466    workspace_root: &Path,
1467) -> SourceCursor {
1468    let dest = &resolved.destination_mem;
1469    let baseline_map = engine
1470        .mem_config_for(dest)
1471        .map(|c| c.sync_state.clone())
1472        .unwrap_or_default();
1473
1474    let cache_root = workspace_root.join(".memstead.cache").join("ingest");
1475    let mut union = Slice::default();
1476    let mut write_commands: Vec<SyncCommand> = Vec::new();
1477    let mut reseed: Vec<SyncCommand> = Vec::new();
1478    let mut no_signal: Vec<NoSignalNote> = Vec::new();
1479    let mut delivery: Vec<DeliverySequence> = Vec::new();
1480    let mut degraded = false;
1481    // Units already disposed in an in-progress pass (touchpoint B): the
1482    // sequence counts them and presents the next ones in order. Read once,
1483    // lazily — a binding without a delivery source never touches the store.
1484    let disposed_units: std::cell::OnceCell<BTreeSet<String>> = std::cell::OnceCell::new();
1485    let disposed_units = || {
1486        disposed_units.get_or_init(|| {
1487            resolved
1488                .name
1489                .split_once('/')
1490                .and_then(|(mem, name)| {
1491                    super::advance::read_advance_store(workspace_root, mem, name)
1492                        .ok()
1493                        .flatten()
1494                })
1495                .map(|state| state.dispositions.keys().cloned().collect())
1496                .unwrap_or_default()
1497        })
1498    };
1499
1500    for source in &resolved.sources {
1501        // Key: "<ingest>/<facet_ref>" for primaries, "<ingest>/<mem>" for
1502        // reference sources — matching the plugin's sync_state keying.
1503        // The note's remedy is medium-shaped, so the medium travels with it.
1504        let primary_medium = match source {
1505            ResolvedSource::Primary(p) => Some(p.medium_type),
1506            ResolvedSource::Reference { .. } => None,
1507        };
1508        let (facet_ref, outcome) = match source {
1509            ResolvedSource::Primary(p) => {
1510                let key = format!("{}/{}#synced", resolved.name, p.name);
1511                let baseline = baseline_map.get(&key).map(String::as_str);
1512                let outcome = match resolve_change_strategy(p, workspace_root) {
1513                    ChangeStrategy::Git => {
1514                        compute_git_slice(p, &resolved.deny_paths, workspace_root, baseline)
1515                    }
1516                    // A graph-typed primary's medium pointer is the source mem id.
1517                    // An unscoped graph facet refuses exactly as the git and
1518                    // mtime arms do: the graph slice alone used to proceed on
1519                    // an empty scope, which is how a facet could carry scope
1520                    // nothing interpreted and still look like it was working.
1521                    ChangeStrategy::Graph if facet_unscoped(p) => SliceOutcome::NoSignal {
1522                        reason: NoSignalReason::Unscoped,
1523                    },
1524                    ChangeStrategy::Graph => {
1525                        compute_graph_slice(engine, Some(p), &p.pointer, baseline)
1526                    }
1527                    ChangeStrategy::Mtime => compute_mtime_slice(
1528                        p,
1529                        &resolved.name,
1530                        &resolved.deny_paths,
1531                        workspace_root,
1532                        &cache_root,
1533                        baseline,
1534                    ),
1535                    // `none` is inert — a rendered `signal:none` state, no slice.
1536                    ChangeStrategy::None => SliceOutcome::NoSignal {
1537                        reason: NoSignalReason::DetectionNone,
1538                    },
1539                };
1540                // Touchpoint B: a source declaring a delivery preparation
1541                // delivers units in its own total order instead of files. A
1542                // source declaring none keeps the file-granularity outcome
1543                // computed above, byte-for-byte.
1544                let outcome =
1545                    match crate::preparation::delivery_preparation(p.preparation.as_deref()) {
1546                        Some(prep)
1547                            if matches!(
1548                                p.medium_type,
1549                                MediumType::Codebase | MediumType::Filesystem | MediumType::Git
1550                            ) =>
1551                        {
1552                            let (outcome, sequence) = deliver_units(
1553                                p,
1554                                prep.id,
1555                                &resolved.deny_paths,
1556                                workspace_root,
1557                                baseline,
1558                                resolved.batch_size as usize,
1559                                disposed_units(),
1560                                outcome,
1561                            );
1562                            delivery.extend(sequence);
1563                            outcome
1564                        }
1565                        _ => outcome,
1566                    };
1567                (p.name.clone(), outcome)
1568            }
1569            ResolvedSource::Reference { mem } => {
1570                let key = format!("{}/{}#synced", resolved.name, mem);
1571                let baseline = baseline_map.get(&key).map(String::as_str);
1572                (
1573                    mem.clone(),
1574                    compute_graph_slice(engine, None, mem, baseline),
1575                )
1576            }
1577        };
1578
1579        let key = format!("{}/{}#synced", resolved.name, facet_ref);
1580        match outcome {
1581            // Genuinely unchanged (baseline present, nothing moved) is the only
1582            // documented silence — it renders nothing, keeping an all-unchanged
1583            // brief byte-identical to a plain roam.
1584            SliceOutcome::Unchanged { .. } => {}
1585            // Every no-signal reason is a visible per-source note.
1586            SliceOutcome::NoSignal { reason } => no_signal.push(NoSignalNote {
1587                source: facet_ref.clone(),
1588                reason,
1589                medium_type: primary_medium,
1590            }),
1591            SliceOutcome::Reseed { token } => reseed.push(SyncCommand { key, token }),
1592            SliceOutcome::Changed {
1593                token,
1594                slice,
1595                degraded: d,
1596            } => {
1597                union.added.extend(slice.added);
1598                union.modified.extend(slice.modified);
1599                union.deleted.extend(slice.deleted);
1600                degraded |= d;
1601                write_commands.push(SyncCommand { key, token });
1602            }
1603        }
1604    }
1605
1606    dedupe_sort(&mut union.added);
1607    dedupe_sort(&mut union.modified);
1608    dedupe_sort(&mut union.deleted);
1609    let any_changes =
1610        !union.added.is_empty() || !union.modified.is_empty() || !union.deleted.is_empty();
1611
1612    SourceCursor {
1613        union,
1614        write_commands,
1615        reseed,
1616        no_signal,
1617        any_changes,
1618        degraded,
1619        dead_denies: dead_deny_entries(resolved, workspace_root),
1620        delivery,
1621        dest_mem: dest.clone(),
1622        // The resolved ingest's `name` is the canonical binding id `<mem>/<stem>`
1623        // (via `resolve_binding_run`) — the id the `projection advance` line the
1624        // brief renders (D4/D7) is keyed on.
1625        binding_id: resolved.name.clone(),
1626    }
1627}
1628
1629fn dedupe_sort(v: &mut Vec<String>) {
1630    v.sort();
1631    v.dedup();
1632}
1633
1634/// A workspace-relative source file's text (lossy for non-UTF-8 bytes).
1635fn read_workspace_file(workspace_root: &Path, ws_rel: &str) -> Option<String> {
1636    std::fs::read(workspace_root.join(ws_rel))
1637        .ok()
1638        .map(|bytes| String::from_utf8_lossy(&bytes).into_owned())
1639}
1640
1641/// The text of a source file as it stood at the git baseline commit —
1642/// `git show <baseline>:<repo-relative path>` — when the source resolves to
1643/// the git strategy and the baseline is a commit of its repo. `None`
1644/// otherwise: the caller then has no old state to diff units against and
1645/// degrades to whole-file units, saying so.
1646fn git_baseline_content(
1647    source: &Source,
1648    workspace_root: &Path,
1649    baseline: Option<&str>,
1650    ws_rel: &str,
1651) -> Option<String> {
1652    let baseline = baseline.filter(|b| is_git_token(b))?;
1653    if !matches!(
1654        resolve_change_strategy(source, workspace_root),
1655        ChangeStrategy::Git
1656    ) {
1657        return None;
1658    }
1659    let git_root = find_git_root(&medium_base(&source.pointer, workspace_root))?;
1660    let abs = normalize_lexical(&workspace_root.join(ws_rel));
1661    let rel = relative_path(&git_root, &abs);
1662    let spec = format!("{baseline}:{}", rel.to_string_lossy().replace('\\', "/"));
1663    let out = Command::new("git")
1664        .args(["show", &spec])
1665        .current_dir(&git_root)
1666        .output()
1667        .ok()?;
1668    out.status
1669        .success()
1670        .then(|| String::from_utf8_lossy(&out.stdout).into_owned())
1671}
1672
1673/// The total order of a delivery sequence: the units' own order keys first,
1674/// then the path, then the same-stamp ordinal NUMERICALLY (`.2` before
1675/// `.10`: an unpadded ordinal compared as text would deliver the tenth entry
1676/// of a day before the second), then the key as text — never the order the
1677/// files were discovered in. The same set of units sorts identically however
1678/// it was collected.
1679pub(crate) fn sequence_units(units: &mut Vec<DeliveredUnit>) {
1680    fn rank(id: &str) -> (&str, u64, &str) {
1681        let (path, key) = crate::preparation::split_unit_id(id);
1682        let key = key.unwrap_or("");
1683        let ordinal = key
1684            .rsplit_once('.')
1685            .and_then(|(_, n)| n.parse::<u64>().ok())
1686            .unwrap_or(1);
1687        (path, ordinal, key)
1688    }
1689    units.sort_by(|a, b| (&a.order_key, rank(&a.id)).cmp(&(&b.order_key, rank(&b.id))));
1690    units.dedup_by(|a, b| a.id == b.id);
1691}
1692
1693/// Touchpoint B: turn a delivery-prepared source's file-level outcome into
1694/// its unit sequence. A first run (`Reseed`) delivers every unit of every
1695/// in-scope file; a change run (`Changed`) delivers the units of added files,
1696/// the units that differ in modified files (diffed against the git baseline
1697/// content; without one, every unit of the file, flagged degraded), and the
1698/// baseline's units of deleted files (a deleted file with no retrievable
1699/// baseline stays a file-level deletion). The units sort into the total
1700/// order `(order key, id)`, the same on every pass; units already disposed in
1701/// the in-progress advance store are marked so the brief presents the next
1702/// ones. The unit ids replace the file ids in the outcome's slice, so the
1703/// advance gate accepts every unit of the sequence (the brief lists the next
1704/// batch of them). Every other outcome passes through untouched.
1705#[allow(clippy::too_many_arguments)]
1706fn deliver_units(
1707    source: &Source,
1708    preparation: &str,
1709    deny_paths: &[String],
1710    workspace_root: &Path,
1711    baseline: Option<&str>,
1712    batch: usize,
1713    disposed: &BTreeSet<String>,
1714    outcome: SliceOutcome,
1715) -> (SliceOutcome, Option<DeliverySequence>) {
1716    use crate::preparation::{DeliveryUnit, UnitChange, diff_units, unit_id, unitize};
1717
1718    let units_of =
1719        |text: &str| -> Vec<DeliveryUnit> { unitize(preparation, text).unwrap_or_default() };
1720    let delivered = |path: &str, u: &DeliveryUnit, change: UnitChange| DeliveredUnit {
1721        id: unit_id(path, &u.key),
1722        order_key: u.order_key.clone(),
1723        change,
1724        disposed: false,
1725    };
1726
1727    let mut units: Vec<DeliveredUnit> = Vec::new();
1728    let mut file_level_deleted: Vec<String> = Vec::new();
1729    let mut degraded_units = false;
1730    let (token, first_run, degraded) = match outcome {
1731        SliceOutcome::Reseed { token } => {
1732            for f in enumerate_facet_files(source, deny_paths, workspace_root) {
1733                if let Some(text) = read_workspace_file(workspace_root, &f) {
1734                    for u in units_of(&text) {
1735                        units.push(delivered(&f, &u, UnitChange::Added));
1736                    }
1737                }
1738            }
1739            if units.is_empty() {
1740                // Nothing in scope: the plain reseed, exactly as before.
1741                return (SliceOutcome::Reseed { token }, None);
1742            }
1743            (token, true, false)
1744        }
1745        SliceOutcome::Changed {
1746            token,
1747            slice,
1748            degraded,
1749        } => {
1750            for f in &slice.added {
1751                if let Some(text) = read_workspace_file(workspace_root, f) {
1752                    for u in units_of(&text) {
1753                        units.push(delivered(f, &u, UnitChange::Added));
1754                    }
1755                }
1756            }
1757            for f in &slice.modified {
1758                let Some(now) = read_workspace_file(workspace_root, f) else {
1759                    continue;
1760                };
1761                let new_units = units_of(&now);
1762                match git_baseline_content(source, workspace_root, baseline, f) {
1763                    Some(old) => {
1764                        for (u, change) in diff_units(&units_of(&old), &new_units) {
1765                            units.push(delivered(f, &u, change));
1766                        }
1767                    }
1768                    None => {
1769                        degraded_units = true;
1770                        for u in new_units {
1771                            units.push(delivered(f, &u, UnitChange::Modified));
1772                        }
1773                    }
1774                }
1775            }
1776            for f in &slice.deleted {
1777                match git_baseline_content(source, workspace_root, baseline, f) {
1778                    Some(old) => {
1779                        for u in units_of(&old) {
1780                            units.push(delivered(f, &u, UnitChange::Deleted));
1781                        }
1782                    }
1783                    None => file_level_deleted.push(f.clone()),
1784                }
1785            }
1786            (token, false, degraded)
1787        }
1788        other => return (other, None),
1789    };
1790
1791    sequence_units(&mut units);
1792    for u in &mut units {
1793        u.disposed = disposed.contains(&u.id);
1794    }
1795
1796    let mut slice = Slice::default();
1797    for u in &units {
1798        match u.change {
1799            UnitChange::Added => slice.added.push(u.id.clone()),
1800            UnitChange::Modified => slice.modified.push(u.id.clone()),
1801            UnitChange::Deleted => slice.deleted.push(u.id.clone()),
1802        }
1803    }
1804    slice.deleted.extend(file_level_deleted);
1805    dedupe_sort(&mut slice.added);
1806    dedupe_sort(&mut slice.modified);
1807    dedupe_sort(&mut slice.deleted);
1808
1809    let sequence = DeliverySequence {
1810        source: source.name.clone(),
1811        preparation: preparation.to_string(),
1812        first_run,
1813        degraded: degraded_units,
1814        batch,
1815        units,
1816    };
1817    (
1818        SliceOutcome::Changed {
1819            token,
1820            slice,
1821            degraded,
1822        },
1823        Some(sequence),
1824    )
1825}
1826
1827#[cfg(test)]
1828mod tests {
1829    use super::*;
1830
1831    #[test]
1832    fn normalize_resolves_dot_and_dotdot() {
1833        assert_eq!(
1834            normalize_lexical(Path::new("/a/b/../c/./d")),
1835            PathBuf::from("/a/c/d")
1836        );
1837        assert_eq!(
1838            normalize_lexical(Path::new("/a/../../b")),
1839            PathBuf::from("/b"),
1840            "dotdot past root is clamped"
1841        );
1842    }
1843
1844    #[test]
1845    fn relative_computes_updowns() {
1846        assert_eq!(
1847            relative_path(Path::new("/a/b"), Path::new("/a/b/c/d")),
1848            PathBuf::from("c/d")
1849        );
1850        assert_eq!(
1851            relative_path(Path::new("/a/b/c"), Path::new("/a/x")),
1852            PathBuf::from("../../x")
1853        );
1854        // A workspace whose medium is a sibling repository.
1855        assert_eq!(
1856            relative_path(Path::new("/m/public"), Path::new("/m/public/crates/x.rs")),
1857            PathBuf::from("crates/x.rs")
1858        );
1859        assert_eq!(
1860            relative_path(Path::new("/m/graph"), Path::new("/m/public/crates/x.rs")),
1861            PathBuf::from("../public/crates/x.rs")
1862        );
1863    }
1864
1865    #[test]
1866    fn pathspec_builds_glob_magic_relative_to_git_root() {
1867        let ws = Path::new("/m/graph");
1868        let git_root = Path::new("/m/public");
1869        assert_eq!(
1870            to_git_pathspec("../public/**/*.rs", git_root, ws, false),
1871            ":(glob)**/*.rs"
1872        );
1873        assert_eq!(
1874            to_git_pathspec("../public/target/**", git_root, ws, true),
1875            ":(glob,exclude)target/**"
1876        );
1877    }
1878
1879    /// A `**`-prefixed pattern (the scaffolded facet default `**/*`) is
1880    /// prefix-free and re-anchors verbatim onto the git root. Lexical
1881    /// re-rooting would yield `:(glob)../**/*` for any sub-medium — an
1882    /// out-of-tree pathspec git fatals on, degrading every diff to
1883    /// no-signal.
1884    #[test]
1885    fn wildcard_prefixed_pathspec_reanchors_verbatim() {
1886        let ws = Path::new("/m/ws");
1887        let git_root = Path::new("/m/ws/src");
1888        assert_eq!(to_git_pathspec("**/*", git_root, ws, false), ":(glob)**/*");
1889        assert_eq!(
1890            in_repo_pathspec("**/__pycache__/**", git_root, ws, true).as_deref(),
1891            Some(":(glob,exclude)**/__pycache__/**")
1892        );
1893    }
1894
1895    use crate::ingest::resolve::Source;
1896    use crate::pipeline::{MediumType, PatternEntry};
1897
1898    fn git(repo: &Path, args: &[&str]) {
1899        let status = std::process::Command::new("git")
1900            .args(args)
1901            .current_dir(repo)
1902            .env("GIT_AUTHOR_NAME", "t")
1903            .env("GIT_AUTHOR_EMAIL", "t@t")
1904            .env("GIT_COMMITTER_NAME", "t")
1905            .env("GIT_COMMITTER_EMAIL", "t@t")
1906            .output()
1907            .unwrap();
1908        assert!(
1909            status.status.success(),
1910            "git {args:?}: {}",
1911            String::from_utf8_lossy(&status.stderr)
1912        );
1913    }
1914
1915    fn primary(scope: Vec<PatternEntry>) -> Source {
1916        Source {
1917            name: "src".to_string(),
1918            medium_type: MediumType::Codebase,
1919            pointer: String::new(),
1920            change_detection: Some("git".to_string()),
1921            scope,
1922            engagement: None,
1923            preparation: None,
1924        }
1925    }
1926
1927    /// One dialect, one implementation: the SAME entry list must exclude the
1928    /// SAME files from an engine slice as [`super::check_path::check_deny_paths`]
1929    /// denies. Successor to the retired cross-boundary fixture test that
1930    /// pinned the engine against the plugin's JS dialect clone — both callers
1931    /// now run engine code, and this test keeps the two engine consumers
1932    /// (enumeration, path check) agreeing on shared data. Proven by
1933    /// materialising every path into a temp workspace, scoping a facet to
1934    /// `**` (everything), applying the entries as the ingest `deny_paths`,
1935    /// and asserting `enumerate_facet_files` yields exactly `allowed`.
1936    #[test]
1937    fn deny_dialect_agrees_between_slice_and_check() {
1938        let strs =
1939            |items: &[&str]| -> Vec<String> { items.iter().map(|s| s.to_string()).collect() };
1940        let entries = strs(&["dev/**", "**/VISION.md", "docs/meta/CLAUDE.md"]);
1941        let blocked = strs(&[
1942            "dev/notes/a.md",
1943            "dev/x.rs",
1944            "dev/deep/nested/y.txt",
1945            "VISION.md",
1946            "crates/foo/VISION.md",
1947            "docs/meta/CLAUDE.md",
1948        ]);
1949        let allowed = strs(&[
1950            "src/lib.rs",
1951            "dev-tools/x.rs",
1952            "VISION-draft.md",
1953            "docs/meta/README.md",
1954            "other/CLAUDE.md",
1955            "crates/foo/mod.rs",
1956        ]);
1957
1958        let ws = tempfile::tempdir().unwrap();
1959        for rel in blocked.iter().chain(allowed.iter()) {
1960            let path = ws.path().join(rel);
1961            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1962            std::fs::write(&path, "x").unwrap();
1963        }
1964
1965        // Scope = everything; the ONLY exclusions are the ingest deny_paths.
1966        let source = primary(vec![PatternEntry {
1967            path: "**".to_string(),
1968            mode: PatternMode::Allow,
1969        }]);
1970        let mut got = enumerate_facet_files(&source, &entries, ws.path());
1971        got.sort();
1972        let mut want = allowed.clone();
1973        want.sort();
1974        assert_eq!(
1975            got, want,
1976            "engine slice must equal the fixture `allowed` set"
1977        );
1978
1979        for b in &blocked {
1980            assert!(
1981                !got.contains(b),
1982                "denied `{b}` leaked into the engine slice"
1983            );
1984        }
1985        // The path check agrees on every case — the two engine consumers of
1986        // the dialect can never drift apart silently.
1987        let all: Vec<String> = blocked.iter().chain(allowed.iter()).cloned().collect();
1988        let checks =
1989            super::super::check_path::check_deny_paths(&entries, &all, ws.path(), ws.path());
1990        for c in &checks {
1991            let expect = blocked.contains(&c.path);
1992            assert_eq!(
1993                c.denied, expect,
1994                "check_deny_paths disagrees with the slice on `{}`",
1995                c.path
1996            );
1997        }
1998    }
1999
2000    /// A cross-repo deny (its target sibling to the medium's git repo)
2001    /// resolves outside `git_root` and is dropped from the pathspecs — pushing
2002    /// it would make git fatal on the whole diff. An in-repo deny is kept.
2003    #[test]
2004    fn out_of_repo_deny_pathspec_is_dropped() {
2005        let ws = Path::new("/m/graph");
2006        let git_root = Path::new("/m/public");
2007        // `../dev/**` (workspace-relative) → /m/dev/** — outside /m/public.
2008        assert_eq!(in_repo_pathspec("../dev/**", git_root, ws, true), None);
2009        assert_eq!(in_repo_pathspec("../CLAUDE.md", git_root, ws, true), None);
2010        // An in-repo deny is preserved as a normal exclude pathspec.
2011        assert_eq!(
2012            in_repo_pathspec("../public/target/**", git_root, ws, true),
2013            Some(":(glob,exclude)target/**".to_string())
2014        );
2015    }
2016
2017    /// A git-shaped baseline the repo does NOT contain reseeds at HEAD
2018    /// instead of degrading to `GitUnavailable` forever. Regression for the
2019    /// dogfood plugin/graph binding, whose stored baseline was a commit of a
2020    /// *different* repo (seeded before the source moved into the submodule):
2021    /// every pass diffed against a foreign sha, fataled, and the baseline
2022    /// never seated.
2023    #[test]
2024    fn foreign_baseline_reseeds_instead_of_degrading() {
2025        let repo = tempfile::tempdir().unwrap();
2026        let root = repo.path();
2027        std::fs::write(root.join("keep.rs"), "one").unwrap();
2028        git(root, &["init", "-q"]);
2029        git(root, &["add", "-A"]);
2030        git(root, &["commit", "-qm", "seed"]);
2031
2032        let source = primary(vec![PatternEntry {
2033            path: "**/*.rs".to_string(),
2034            mode: PatternMode::Allow,
2035        }]);
2036        // Git-token-shaped, but no such commit exists in this repo.
2037        let foreign = "46ce8add0fe87250527b6fa21fcfdc2d943d51f0";
2038        match compute_git_slice(&source, &[], root, Some(foreign)) {
2039            SliceOutcome::Reseed { token } => {
2040                // Reseeds at the repo's actual HEAD — the baseline seats.
2041                let head = String::from_utf8(
2042                    std::process::Command::new("git")
2043                        .args(["rev-parse", "HEAD"])
2044                        .current_dir(root)
2045                        .output()
2046                        .unwrap()
2047                        .stdout,
2048                )
2049                .unwrap()
2050                .trim()
2051                .to_string();
2052                assert_eq!(token, head);
2053            }
2054            other => panic!("foreign baseline must reseed, got {other:?}"),
2055        }
2056    }
2057
2058    /// A real git diff with a cross-repo deny present must still succeed (the
2059    /// out-of-repo pathspec is dropped, not fataled), and the in-repo scope is
2060    /// honoured. Regression for the dogfood dialect (`../dev/**` under a
2061    /// sub-medium): git must not degrade the whole slice.
2062    #[test]
2063    fn git_slice_survives_cross_repo_deny() {
2064        let repo = tempfile::tempdir().unwrap();
2065        let root = repo.path();
2066        std::fs::write(root.join("keep.rs"), "one").unwrap();
2067        git(root, &["init", "-q"]);
2068        git(root, &["add", "-A"]);
2069        git(root, &["commit", "-qm", "seed"]);
2070        let baseline = String::from_utf8(
2071            std::process::Command::new("git")
2072                .args(["rev-parse", "HEAD"])
2073                .current_dir(root)
2074                .output()
2075                .unwrap()
2076                .stdout,
2077        )
2078        .unwrap()
2079        .trim()
2080        .to_string();
2081        std::fs::write(root.join("keep.rs"), "two").unwrap();
2082        git(root, &["add", "-A"]);
2083        git(root, &["commit", "-qm", "move"]);
2084
2085        let source = primary(vec![PatternEntry {
2086            path: "**/*.rs".to_string(),
2087            mode: PatternMode::Allow,
2088        }]);
2089        // `../dev/**` resolves outside this repo — must be dropped, not fatal.
2090        let outcome = compute_git_slice(&source, &["../dev/**".to_string()], root, Some(&baseline));
2091        match outcome {
2092            SliceOutcome::Changed { slice, .. } => {
2093                assert_eq!(slice.modified, vec!["keep.rs"]);
2094            }
2095            other => panic!("expected Changed (deny dropped), got {other:?}"),
2096        }
2097    }
2098
2099    /// A sub-tree-pointed source's changed slice honours the pointer join the
2100    /// enumeration honours: a `**`-prefixed scope glob anchors at the medium
2101    /// base, so a change OUTSIDE the pointed subtree never enters the slice.
2102    /// Regression for drift-benchmark runs 03/06 (`plugin/graph`): the slice
2103    /// presented repo-wide artifacts that `S(D)` correctly excluded and the
2104    /// exclude gate refused, steering sync at artifacts it had no mandate over.
2105    #[test]
2106    fn git_slice_confines_to_the_medium_subtree() {
2107        let repo = tempfile::tempdir().unwrap();
2108        let root = repo.path();
2109        std::fs::create_dir_all(root.join("sub")).unwrap();
2110        std::fs::write(root.join("sub/in.md"), "one").unwrap();
2111        std::fs::write(root.join("out.md"), "one").unwrap();
2112        git(root, &["init", "-q"]);
2113        git(root, &["add", "-A"]);
2114        git(root, &["commit", "-qm", "seed"]);
2115        let baseline = String::from_utf8(
2116            std::process::Command::new("git")
2117                .args(["rev-parse", "HEAD"])
2118                .current_dir(root)
2119                .output()
2120                .unwrap()
2121                .stdout,
2122        )
2123        .unwrap()
2124        .trim()
2125        .to_string();
2126        std::fs::write(root.join("sub/in.md"), "two").unwrap();
2127        std::fs::write(root.join("out.md"), "two").unwrap();
2128        git(root, &["add", "-A"]);
2129        git(root, &["commit", "-qm", "move both"]);
2130
2131        let source = Source {
2132            pointer: "sub".to_string(),
2133            ..primary(vec![PatternEntry {
2134                path: "**/*.md".to_string(),
2135                mode: PatternMode::Allow,
2136            }])
2137        };
2138        let outcome = compute_git_slice(&source, &[], root, Some(&baseline));
2139        match outcome {
2140            SliceOutcome::Changed { slice, .. } => {
2141                assert_eq!(slice.modified, vec!["sub/in.md"]);
2142                assert!(slice.added.is_empty());
2143                assert!(slice.deleted.is_empty());
2144            }
2145            other => panic!("expected Changed confined to the subtree, got {other:?}"),
2146        }
2147    }
2148
2149    /// A real git diff: baseline commit → HEAD produces the changed slice,
2150    /// classifying added / modified / deleted and honouring the scope.
2151    #[test]
2152    fn git_slice_diffs_baseline_to_head() {
2153        let repo = tempfile::tempdir().unwrap();
2154        let root = repo.path();
2155        git(root, &["init", "-q"]);
2156        std::fs::write(root.join("keep.rs"), "one").unwrap();
2157        std::fs::write(root.join("gone.rs"), "bye").unwrap();
2158        std::fs::write(root.join("note.md"), "ignored-by-scope").unwrap();
2159        git(root, &["add", "-A"]);
2160        git(root, &["commit", "-qm", "base"]);
2161        let baseline = String::from_utf8(
2162            std::process::Command::new("git")
2163                .args(["rev-parse", "HEAD"])
2164                .current_dir(root)
2165                .output()
2166                .unwrap()
2167                .stdout,
2168        )
2169        .unwrap()
2170        .trim()
2171        .to_string();
2172
2173        // Move: modify keep.rs, delete gone.rs, add new.rs, touch note.md.
2174        std::fs::write(root.join("keep.rs"), "two").unwrap();
2175        std::fs::remove_file(root.join("gone.rs")).unwrap();
2176        std::fs::write(root.join("new.rs"), "hi").unwrap();
2177        std::fs::write(root.join("note.md"), "still ignored").unwrap();
2178        git(root, &["add", "-A"]);
2179        git(root, &["commit", "-qm", "move"]);
2180
2181        // Scope to *.rs only — note.md must not appear.
2182        let source = primary(vec![PatternEntry {
2183            path: "**/*.rs".to_string(),
2184            mode: PatternMode::Allow,
2185        }]);
2186        let outcome = compute_git_slice(&source, &[], root, Some(&baseline));
2187        match outcome {
2188            SliceOutcome::Changed {
2189                slice, degraded, ..
2190            } => {
2191                assert!(!degraded);
2192                assert_eq!(slice.added, vec!["new.rs"]);
2193                assert_eq!(slice.modified, vec!["keep.rs"]);
2194                assert_eq!(slice.deleted, vec!["gone.rs"]);
2195            }
2196            other => panic!("expected Changed, got {other:?}"),
2197        }
2198
2199        // Same baseline == HEAD → Unchanged.
2200        let head = String::from_utf8(
2201            std::process::Command::new("git")
2202                .args(["rev-parse", "HEAD"])
2203                .current_dir(root)
2204                .output()
2205                .unwrap()
2206                .stdout,
2207        )
2208        .unwrap()
2209        .trim()
2210        .to_string();
2211        assert!(matches!(
2212            compute_git_slice(&source, &[], root, Some(&head)),
2213            SliceOutcome::Unchanged { .. }
2214        ));
2215
2216        // A non-commit baseline → Reseed at HEAD.
2217        assert!(matches!(
2218            compute_git_slice(&source, &[], root, None),
2219            SliceOutcome::Reseed { .. }
2220        ));
2221    }
2222
2223    /// Facet-file enumeration honours allow globs, deny globs, and the
2224    /// codebase/filesystem medium-type gate.
2225    /// The defect this join closes: a source with a non-empty pointer whose
2226    /// scope is written the way the brief presents it (paths beneath the
2227    /// pointer) selects those artifacts. Under the retired workspace-relative
2228    /// reading the prefix-anchored pattern matched nothing and the walk
2229    /// returned an empty set with no signal that anything had been dropped.
2230    #[test]
2231    fn scope_patterns_resolve_against_the_source_pointer() {
2232        let tmp = tempfile::tempdir().unwrap();
2233        let root = tmp.path();
2234        std::fs::create_dir_all(root.join("src/lib/deep")).unwrap();
2235        std::fs::write(root.join("src/lib/a.rs"), "").unwrap();
2236        std::fs::write(root.join("src/lib/deep/b.rs"), "").unwrap();
2237        std::fs::write(root.join("src/lib/skip.md"), "").unwrap();
2238        std::fs::write(root.join("outside.rs"), "").unwrap();
2239
2240        let mut source = primary(vec![
2241            PatternEntry {
2242                path: "**/*.rs".to_string(),
2243                mode: PatternMode::Allow,
2244            },
2245            PatternEntry {
2246                path: "deep/**".to_string(),
2247                mode: PatternMode::Deny,
2248            },
2249        ]);
2250        source.pointer = "src/lib".to_string();
2251        let got = enumerate_facet_files(&source, &[], root);
2252        assert_eq!(
2253            got,
2254            vec!["src/lib/a.rs".to_string()],
2255            "the deny is source-relative too, and nothing outside the pointer enters"
2256        );
2257    }
2258
2259    /// A bare literal beneath the pointer is the shape that could never even
2260    /// appear as uncovered under the old reading: it matched nothing, so it
2261    /// was absent from the denominator rather than present-and-uncovered.
2262    #[test]
2263    fn a_bare_literal_under_the_pointer_selects_its_file() {
2264        let tmp = tempfile::tempdir().unwrap();
2265        let root = tmp.path();
2266        std::fs::create_dir_all(root.join("pkg")).unwrap();
2267        std::fs::write(root.join("pkg/Cargo.toml"), "").unwrap();
2268        std::fs::write(root.join("pkg/other.toml"), "").unwrap();
2269
2270        let mut source = primary(vec![PatternEntry {
2271            path: "Cargo.toml".to_string(),
2272            mode: PatternMode::Allow,
2273        }]);
2274        source.pointer = "pkg".to_string();
2275        assert_eq!(
2276            enumerate_facet_files(&source, &[], root),
2277            vec!["pkg/Cargo.toml".to_string()]
2278        );
2279    }
2280
2281    /// An ingest-level deny stays workspace-relative — it spans every source
2282    /// in the binding, so it has no pointer to be relative to. The two
2283    /// namespaces coexist on one candidate without either leaking.
2284    #[test]
2285    fn ingest_denies_stay_workspace_relative() {
2286        let tmp = tempfile::tempdir().unwrap();
2287        let root = tmp.path();
2288        std::fs::create_dir_all(root.join("src/secret")).unwrap();
2289        std::fs::write(root.join("src/keep.rs"), "").unwrap();
2290        std::fs::write(root.join("src/secret/hide.rs"), "").unwrap();
2291
2292        let mut source = primary(vec![PatternEntry {
2293            path: "**/*.rs".to_string(),
2294            mode: PatternMode::Allow,
2295        }]);
2296        source.pointer = "src".to_string();
2297        let got = enumerate_facet_files(&source, &["src/secret/**".to_string()], root);
2298        assert_eq!(got, vec!["src/keep.rs".to_string()]);
2299    }
2300
2301    /// An empty pointer is the shape the scaffolder writes and the only one
2302    /// where the two readings coincide — it must be untouched by the join.
2303    #[test]
2304    fn an_empty_pointer_is_unaffected_by_the_join() {
2305        let tmp = tempfile::tempdir().unwrap();
2306        let root = tmp.path();
2307        std::fs::create_dir_all(root.join("docs")).unwrap();
2308        std::fs::write(root.join("docs/a.md"), "").unwrap();
2309        std::fs::write(root.join("docs/b.txt"), "").unwrap();
2310
2311        let source = primary(vec![PatternEntry {
2312            path: "docs/**/*.md".to_string(),
2313            mode: PatternMode::Allow,
2314        }]);
2315        assert_eq!(
2316            enumerate_facet_files(&source, &[], root),
2317            vec!["docs/a.md".to_string()]
2318        );
2319    }
2320
2321    /// The enumerator and the path-check oracle agree on the rules that
2322    /// EXTEND the glob, not merely on the glob library: a bare-name deny
2323    /// (which degrades to a directory-prefix block) and a malformed deny
2324    /// (whose literal base still blocks) must exclude from `S(D)` exactly
2325    /// what they deny at the hook. Before the shared resolver these two
2326    /// shapes were denied at the hook and still counted in the denominator.
2327    #[test]
2328    fn the_enumerator_applies_the_oracle_extra_rules_too() {
2329        let tmp = tempfile::tempdir().unwrap();
2330        let root = tmp.path();
2331        for dir in ["bare", "broken"] {
2332            std::fs::create_dir_all(root.join(dir)).unwrap();
2333            std::fs::write(root.join(dir).join("f.rs"), "").unwrap();
2334        }
2335        std::fs::write(root.join("keep.rs"), "").unwrap();
2336
2337        // `bare` is a legacy bare name (no metacharacter at all); `broken[`
2338        // will not compile, so only its literal base can block.
2339        let denies = vec!["bare".to_string(), "broken[".to_string()];
2340        let source = primary(vec![PatternEntry {
2341            path: "**/*.rs".to_string(),
2342            mode: PatternMode::Allow,
2343        }]);
2344        let enumerated = enumerate_facet_files(&source, &denies, root);
2345        assert_eq!(
2346            enumerated,
2347            vec!["keep.rs".to_string()],
2348            "both extra rules must exclude from S(D)"
2349        );
2350
2351        // The same entries, answered by the hook's own surface.
2352        let verdicts = super::super::check_path::check_deny_paths(
2353            &denies,
2354            &[
2355                "bare/f.rs".to_string(),
2356                "broken/f.rs".to_string(),
2357                "keep.rs".to_string(),
2358            ],
2359            root,
2360            root,
2361        );
2362        let denied: Vec<bool> = verdicts.iter().map(|v| v.denied).collect();
2363        assert_eq!(
2364            denied,
2365            vec![true, true, false],
2366            "the hook and the enumerator must agree path for path"
2367        );
2368    }
2369
2370    /// The dead-deny lint must answer with the resolution that ENFORCES a
2371    /// deny, not with the raw glob. A bare-name entry (`dev`) blocks through
2372    /// the literal-base directory-prefix rule, so it is live — reporting it as
2373    /// matching nothing told the author to delete an entry whose removal would
2374    /// have raised the denominator, while the same binding's slice and the
2375    /// path-check command both proved it working.
2376    #[test]
2377    fn the_dead_deny_lint_agrees_with_the_resolution_that_enforces() {
2378        use crate::binding::BuildMode;
2379        use crate::pipeline::IngestTrigger;
2380        let tmp = tempfile::tempdir().unwrap();
2381        let root = tmp.path();
2382        std::fs::create_dir_all(root.join("bare")).unwrap();
2383        std::fs::write(root.join("bare/f.md"), "").unwrap();
2384        std::fs::write(root.join("keep.md"), "").unwrap();
2385
2386        let live = super::super::check_path::DenyOracle::new(&["bare".to_string()]);
2387        assert!(
2388            live.is_denied("bare/f.md"),
2389            "the enforcing resolution blocks a bare name by its literal base"
2390        );
2391
2392        let resolved = ResolvedIngest {
2393            name: "ing".to_string(),
2394            mode: BuildMode::Discovery,
2395            trigger: IngestTrigger::Loop,
2396            batch_size: 50,
2397            deny_paths: vec!["bare".to_string(), "nothing-here/**".to_string()],
2398            projection_ref: "m/p".to_string(),
2399            projection_mem: "m".to_string(),
2400            projection_name: "p".to_string(),
2401            intent: None,
2402            sources: vec![ResolvedSource::Primary(primary(vec![PatternEntry {
2403                path: "**/*.md".to_string(),
2404                mode: PatternMode::Allow,
2405            }]))],
2406            destination_mem: "m".to_string(),
2407            rules: None,
2408            post_actions: None,
2409        };
2410        let dead = dead_deny_entries(&resolved, root);
2411        assert_eq!(
2412            dead,
2413            vec!["nothing-here/**".to_string()],
2414            "only the genuinely dead entry is reported; got {dead:?}"
2415        );
2416    }
2417
2418    /// Partiality has two causes, not one. A malformed pattern is the obvious
2419    /// one; a pattern still in the retired workspace-relative dialect is the
2420    /// dangerous one, because a MIXED scope still enumerates and the surviving
2421    /// subset looks like a population. Both must make `is_partial` true, or a
2422    /// coverage percentage gets computed over a set that is provably short.
2423    #[test]
2424    fn a_legacy_dialect_pattern_makes_the_enumeration_partial() {
2425        let tmp = tempfile::tempdir().unwrap();
2426        let root = tmp.path();
2427        std::fs::create_dir_all(root.join("src")).unwrap();
2428        std::fs::write(root.join("src/a.rs"), "").unwrap();
2429        std::fs::write(root.join("src/b.md"), "").unwrap();
2430
2431        let mut source = primary(vec![
2432            PatternEntry {
2433                path: "**/*.rs".to_string(),
2434                mode: PatternMode::Allow,
2435            },
2436            // Written against the workspace root, not the pointer: it selects
2437            // nothing now, and its share of the population is missing.
2438            PatternEntry {
2439                path: "src/b.md".to_string(),
2440                mode: PatternMode::Allow,
2441            },
2442        ]);
2443        source.pointer = "src".to_string();
2444
2445        let got = enumerate_facet_files_reported(&source, &[], root);
2446        assert_eq!(got.files, vec!["src/a.rs".to_string()]);
2447        assert!(got.malformed.is_empty(), "nothing here fails to compile");
2448        assert!(
2449            got.is_partial(),
2450            "a legacy-dialect pattern truncates the set just as a malformed one does"
2451        );
2452        let why = got.partiality_reason().expect("a reason is stated");
2453        assert!(
2454            why.contains("src/b.md") && why.contains("pointer"),
2455            "the reason must name the pattern and the cause: {why}"
2456        );
2457    }
2458
2459    /// Criterion 7's complement, allow half: a malformed allow no longer takes
2460    /// the whole enumeration with it. The valid patterns still select, and the
2461    /// bad one comes back by name so the caller can state the partiality.
2462    #[test]
2463    fn a_malformed_allow_does_not_empty_the_enumeration() {
2464        let tmp = tempfile::tempdir().unwrap();
2465        let root = tmp.path();
2466        std::fs::write(root.join("a.rs"), "").unwrap();
2467
2468        let source = primary(vec![
2469            PatternEntry {
2470                path: "**/*.rs".to_string(),
2471                mode: PatternMode::Allow,
2472            },
2473            PatternEntry {
2474                path: "[unclosed".to_string(),
2475                mode: PatternMode::Allow,
2476            },
2477        ]);
2478        let got = enumerate_facet_files_reported(&source, &[], root);
2479        assert_eq!(got.files, vec!["a.rs".to_string()]);
2480        assert_eq!(got.malformed, vec!["[unclosed".to_string()]);
2481        assert!(got.is_partial(), "a skipped pattern makes the set partial");
2482    }
2483
2484    /// Criterion 7's complement, deny half: a malformed deny no longer
2485    /// disables the other denies and inflates the denominator.
2486    #[test]
2487    fn a_malformed_deny_does_not_disable_the_other_denies() {
2488        let tmp = tempfile::tempdir().unwrap();
2489        let root = tmp.path();
2490        std::fs::create_dir_all(root.join("skip")).unwrap();
2491        std::fs::write(root.join("keep.rs"), "").unwrap();
2492        std::fs::write(root.join("skip/gone.rs"), "").unwrap();
2493
2494        let source = primary(vec![
2495            PatternEntry {
2496                path: "**/*.rs".to_string(),
2497                mode: PatternMode::Allow,
2498            },
2499            PatternEntry {
2500                path: "skip/**".to_string(),
2501                mode: PatternMode::Deny,
2502            },
2503            PatternEntry {
2504                path: "[unclosed".to_string(),
2505                mode: PatternMode::Deny,
2506            },
2507        ]);
2508        let got = enumerate_facet_files_reported(&source, &[], root);
2509        assert_eq!(
2510            got.files,
2511            vec!["keep.rs".to_string()],
2512            "the good deny still applies"
2513        );
2514        assert_eq!(got.malformed, vec!["[unclosed".to_string()]);
2515    }
2516
2517    /// A binding carrying a malformed scope pattern is refused at validation,
2518    /// naming it — so the enumerator's tolerance above only ever carries
2519    /// records written before this gate existed.
2520    #[test]
2521    fn validation_refuses_a_malformed_scope_pattern_by_name() {
2522        use crate::binding::CapabilityError;
2523        let mut binding = crate::binding::scaffold_binding(crate::binding::ScaffoldParams {
2524            destination_mem: "home",
2525            source_name: "src",
2526            medium_type: MediumType::Codebase,
2527            pointer: "src",
2528            intent: None,
2529            additional_deny_paths: Vec::new(),
2530        })
2531        .binding;
2532        binding.sources[0].scope.push(PatternEntry {
2533            path: "[unclosed".to_string(),
2534            mode: PatternMode::Allow,
2535        });
2536        let errs = crate::binding::validate_binding(&binding).unwrap_err();
2537        assert!(
2538            errs.iter().any(|e| matches!(
2539                e,
2540                CapabilityError::MalformedScopePattern { pattern, .. } if pattern == "[unclosed"
2541            )),
2542            "expected MalformedScopePattern naming the pattern, got {errs:?}"
2543        );
2544    }
2545
2546    #[test]
2547    fn migration_notes_name_old_dialect_patterns_and_suggest_the_rewrite() {
2548        let mut source = primary(vec![
2549            PatternEntry {
2550                path: "../public/plugins/claude-code/**/*.md".to_string(),
2551                mode: PatternMode::Allow,
2552            },
2553            PatternEntry {
2554                path: "../public/plugins/claude-code/dist/**".to_string(),
2555                mode: PatternMode::Deny,
2556            },
2557            PatternEntry {
2558                path: "**/*.mjs".to_string(),
2559                mode: PatternMode::Allow,
2560            },
2561        ]);
2562        source.pointer = "../public/plugins/claude-code".to_string();
2563        let notes = scope_migration_notes(&source);
2564        assert_eq!(notes.len(), 2, "the prefix-free pattern is not reported");
2565        assert_eq!(notes[0].suggested.as_deref(), Some("**/*.md"));
2566        assert!(!notes[0].deny);
2567        assert_eq!(notes[1].suggested.as_deref(), Some("dist/**"));
2568        assert!(notes[1].deny);
2569    }
2570
2571    /// A pointer-less source has no dialect to migrate.
2572    #[test]
2573    fn migration_notes_are_empty_without_a_pointer() {
2574        let source = primary(vec![PatternEntry {
2575            path: "notes/**/*.md".to_string(),
2576            mode: PatternMode::Allow,
2577        }]);
2578        assert!(scope_migration_notes(&source).is_empty());
2579    }
2580
2581    #[test]
2582    fn enumerate_honours_allow_and_deny() {
2583        let ws = tempfile::tempdir().unwrap();
2584        let root = ws.path();
2585        std::fs::create_dir_all(root.join("sub")).unwrap();
2586        std::fs::write(root.join("a.rs"), "").unwrap();
2587        std::fs::write(root.join("sub/b.rs"), "").unwrap();
2588        std::fs::write(root.join("c.md"), "").unwrap();
2589
2590        // medium_pointer "" → base is the workspace root; allow **/*.rs,
2591        // deny sub/** (so sub/b.rs is excluded, c.md never matched).
2592        let source = primary(vec![
2593            PatternEntry {
2594                path: "**/*.rs".to_string(),
2595                mode: PatternMode::Allow,
2596            },
2597            PatternEntry {
2598                path: "sub/**".to_string(),
2599                mode: PatternMode::Deny,
2600            },
2601        ]);
2602        assert_eq!(enumerate_facet_files(&source, &[], root), vec!["a.rs"]);
2603
2604        // A graph medium is not a file tree, so the FILE walk yields nothing
2605        // for it — but that is a statement about this function, not about
2606        // graph enumerability. `enumerate_graph_entities` is the graph arm,
2607        // and `enumerate_source_artifacts` is what every S(D) consumer calls.
2608        let mut graph_source = source.clone();
2609        graph_source.medium_type = MediumType::Graph;
2610        assert!(enumerate_facet_files(&graph_source, &[], root).is_empty());
2611    }
2612
2613    /// The allow-prefix pruning never changes WHAT an enumeration selects —
2614    /// only which directories the walk bothers to enter. Anchored patterns
2615    /// (`notes/**/*.md`) still find their files, root-level literals
2616    /// (`VISION.md`) still match, and a subtree no pattern can reach
2617    /// contributes nothing whether walked or pruned.
2618    #[test]
2619    fn enumerate_prunes_directories_outside_every_allow_prefix() {
2620        let ws = tempfile::tempdir().unwrap();
2621        let root = ws.path();
2622        std::fs::create_dir_all(root.join("notes/drafts")).unwrap();
2623        std::fs::create_dir_all(root.join("target/debug/build")).unwrap();
2624        std::fs::write(root.join("notes/drafts/a.md"), "").unwrap();
2625        std::fs::write(root.join("notes/top.md"), "").unwrap();
2626        std::fs::write(root.join("VISION.md"), "").unwrap();
2627        std::fs::write(root.join("target/debug/build/junk.md"), "").unwrap();
2628
2629        let source = primary(vec![
2630            PatternEntry {
2631                path: "notes/**/*.md".to_string(),
2632                mode: PatternMode::Allow,
2633            },
2634            PatternEntry {
2635                path: "VISION.md".to_string(),
2636                mode: PatternMode::Allow,
2637            },
2638        ]);
2639        assert_eq!(
2640            enumerate_facet_files(&source, &[], root),
2641            vec!["VISION.md", "notes/drafts/a.md", "notes/top.md"],
2642        );
2643    }
2644
2645    /// The prefix/could-match helpers, on their own terms: segment-exact
2646    /// comparison (no byte-prefix confusion between `no` and `notes`), the
2647    /// empty prefix of an unanchored pattern admitting everything, and both
2648    /// directions of the common-length rule (ancestor of the literal region,
2649    /// inside the glob region).
2650    #[test]
2651    fn allow_prefix_pruning_helpers() {
2652        assert_eq!(glob_literal_prefix("notes/**/*.md"), vec!["notes"]);
2653        assert_eq!(glob_literal_prefix("VISION.md"), vec!["VISION.md"]);
2654        assert!(glob_literal_prefix("**/*.rs").is_empty());
2655        assert_eq!(glob_literal_prefix("a/b/{c,d}/e"), vec!["a", "b"]);
2656
2657        let prefixes = vec![vec!["notes".to_string()], vec!["VISION.md".to_string()]];
2658        assert!(allow_could_match_under(&prefixes, "notes"));
2659        assert!(allow_could_match_under(&prefixes, "notes/drafts"));
2660        assert!(!allow_could_match_under(&prefixes, "no"));
2661        assert!(!allow_could_match_under(&prefixes, "public"));
2662        assert!(!allow_could_match_under(&prefixes, "target/debug"));
2663
2664        let unanchored = vec![Vec::<String>::new()];
2665        assert!(allow_could_match_under(&unanchored, "anything/at/all"));
2666    }
2667
2668    /// Graph enumeration is real: a graph source's `S(D)` is the source mem's
2669    /// in-scope entity set, selected by the entity vocabulary. This is the
2670    /// bail the S1b pilot hit — enumeration returned empty for every graph
2671    /// source, so coverage was vacuously 0/0 and `--full` passed over a
2672    /// measurement that never happened.
2673    #[test]
2674    fn graph_enumeration_selects_the_source_mems_entities() {
2675        use crate::workspace::{
2676            Mount, MountCapability, MountLifecycle, MountStorage, Workspace, WorkspaceSettings,
2677        };
2678        use crate::workspace_store::WorkspaceStoreAdapter;
2679
2680        let tmp = tempfile::tempdir().unwrap();
2681        let root = tmp.path();
2682        let mem_dir = root.join("srcmem");
2683        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
2684        std::fs::write(
2685            mem_dir.join(".memstead").join("config.json"),
2686            r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
2687        )
2688        .unwrap();
2689
2690        let entity = |slug: &str, ty: &str, title: &str| {
2691            std::fs::write(
2692                mem_dir.join(format!("{slug}.md")),
2693                format!("---\ntype: {ty}\n---\n\n# {title}\n\n## Decision\n\nBody.\n"),
2694            )
2695            .unwrap();
2696        };
2697        entity("alpha-choice", "decision", "Alpha choice");
2698        entity("beta-choice", "decision", "Beta choice");
2699        entity("gamma-note", "memo", "Gamma note");
2700
2701        std::fs::create_dir_all(root.join(".memstead")).unwrap();
2702        std::fs::write(
2703            root.join(".memstead").join("workspace.toml"),
2704            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2705        )
2706        .unwrap();
2707        crate::FileWorkspaceStore::new()
2708            .save_state(
2709                root,
2710                &Workspace {
2711                    mounts: vec![Mount {
2712                        mem: "srcmem".to_string(),
2713                        schema: Some("default@1.0.0".parse().unwrap()),
2714                        storage: MountStorage::Folder {
2715                            path: mem_dir.clone(),
2716                        },
2717                        capability: MountCapability::Write,
2718                        lifecycle: MountLifecycle::Eager,
2719                        cross_linkable: false,
2720                        migration_target: None,
2721                    }],
2722                    settings: WorkspaceSettings::default(),
2723                },
2724            )
2725            .unwrap();
2726
2727        let engine = crate::Engine::from_workspace_root(root).unwrap();
2728
2729        let graph_source = |patterns: Vec<(&str, PatternMode)>| Source {
2730            name: "g".to_string(),
2731            medium_type: MediumType::Graph,
2732            pointer: "srcmem".to_string(),
2733            change_detection: None,
2734            scope: patterns
2735                .into_iter()
2736                .map(|(p, mode)| crate::pipeline::PatternEntry {
2737                    path: p.to_string(),
2738                    mode,
2739                })
2740                .collect(),
2741            engagement: None,
2742            preparation: None,
2743        };
2744
2745        // `*` — the whole mem. A real denominator, not an empty walk.
2746        let all = enumerate_graph_entities(&engine, &graph_source(vec![("*", PatternMode::Allow)]));
2747        assert_eq!(
2748            all,
2749            vec![
2750                "srcmem--alpha-choice".to_string(),
2751                "srcmem--beta-choice".to_string(),
2752                "srcmem--gamma-note".to_string(),
2753            ],
2754            "the whole-mem selector enumerates every real entity"
2755        );
2756
2757        // `type:` selects on the type axis.
2758        let decisions = enumerate_graph_entities(
2759            &engine,
2760            &graph_source(vec![("type:decision", PatternMode::Allow)]),
2761        );
2762        assert_eq!(
2763            decisions,
2764            vec![
2765                "srcmem--alpha-choice".to_string(),
2766                "srcmem--beta-choice".to_string()
2767            ],
2768            "type selector excludes the memo"
2769        );
2770
2771        // `id:` globs the id, and a deny subtracts from an allow.
2772        let globbed = enumerate_graph_entities(
2773            &engine,
2774            &graph_source(vec![
2775                ("id:srcmem--*-choice", PatternMode::Allow),
2776                ("id:srcmem--beta-*", PatternMode::Deny),
2777            ]),
2778        );
2779        assert_eq!(
2780            globbed,
2781            vec!["srcmem--alpha-choice".to_string()],
2782            "deny subtracts from allow in the entity namespace too"
2783        );
2784
2785        // An unscoped graph facet enumerates nothing — the same posture the
2786        // path mediums have always had, and the reason the strategy layer
2787        // refuses it before ever reaching here.
2788        assert!(
2789            enumerate_graph_entities(&engine, &graph_source(vec![])).is_empty(),
2790            "an unscoped graph facet is never silently 'everything'"
2791        );
2792
2793        // The dispatching entry point every S(D) consumer calls agrees.
2794        assert_eq!(
2795            enumerate_source_artifacts(
2796                &engine,
2797                &graph_source(vec![("*", PatternMode::Allow)]),
2798                &[],
2799                root
2800            ),
2801            all,
2802            "enumerate_source_artifacts routes a graph source to the graph arm"
2803        );
2804    }
2805
2806    /// The S1b pilot's headline failure, encoded as a permanent regression
2807    /// test: a stale-pinned entity anchor over a source entity that changed
2808    /// since it was pinned must be flagged `drifted`. It used to go unflagged
2809    /// — anchor resolution was 0/0 for every graph source, so drift was
2810    /// structurally undetectable while the matrix claimed full parity.
2811    #[test]
2812    fn a_stale_entity_anchor_over_a_changed_entity_is_drifted() {
2813        use crate::anchor::{
2814            Anchor, AnchorGrain, AnchorProvenanceClass, AnchorSidecar, AnchorState,
2815        };
2816        use crate::entity::EntityId;
2817        use crate::workspace::{
2818            Mount, MountCapability, MountLifecycle, MountStorage, Workspace, WorkspaceSettings,
2819        };
2820        use crate::workspace_store::WorkspaceStoreAdapter;
2821
2822        let tmp = tempfile::tempdir().unwrap();
2823        let root = tmp.path();
2824        let mem_dir = root.join("mem");
2825        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
2826        std::fs::write(
2827            mem_dir.join(".memstead").join("config.json"),
2828            r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
2829        )
2830        .unwrap();
2831        std::fs::write(
2832            mem_dir.join("pinned.md"),
2833            "---\ntype: decision\n---\n\n# Pinned\n\n## Decision\n\nOriginal body.\n",
2834        )
2835        .unwrap();
2836        std::fs::write(
2837            mem_dir.join("steady.md"),
2838            "---\ntype: decision\n---\n\n# Steady\n\n## Decision\n\nUnchanged body.\n",
2839        )
2840        .unwrap();
2841
2842        std::fs::create_dir_all(root.join(".memstead")).unwrap();
2843        std::fs::write(
2844            root.join(".memstead").join("workspace.toml"),
2845            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2846        )
2847        .unwrap();
2848        crate::FileWorkspaceStore::new()
2849            .save_state(
2850                root,
2851                &Workspace {
2852                    mounts: vec![Mount {
2853                        mem: "mem".to_string(),
2854                        schema: Some("default@1.0.0".parse().unwrap()),
2855                        storage: MountStorage::Folder {
2856                            path: mem_dir.clone(),
2857                        },
2858                        capability: MountCapability::Write,
2859                        lifecycle: MountLifecycle::Eager,
2860                        cross_linkable: false,
2861                        migration_target: None,
2862                    }],
2863                    settings: WorkspaceSettings::default(),
2864                },
2865            )
2866            .unwrap();
2867
2868        // Hash the entities as they stand, so the anchors start out honest.
2869        let engine = crate::Engine::from_workspace_root(root).unwrap();
2870        let hash_of = |engine: &crate::Engine, id: &str| {
2871            let e = engine.store().get(&EntityId::canonical(id)).unwrap();
2872            crate::anchor::prepared_content_hash(
2873                crate::render::render_entity_markdown(e, None).as_bytes(),
2874            )
2875        };
2876        let pinned_hash = hash_of(&engine, "mem--pinned");
2877        let steady_hash = hash_of(&engine, "mem--steady");
2878
2879        let entity_anchor = |artifact: &str, hash: &str| Anchor {
2880            artifact: artifact.to_string(),
2881            grain: AnchorGrain::Entity,
2882            class: AnchorProvenanceClass::Anchored,
2883            hash: Some(hash.to_string()),
2884            source: None,
2885            binding: None,
2886            at_version: None,
2887            derived_from: Vec::new(),
2888            hash_stability: crate::anchor::AnchorHashStability::Stable,
2889            span_unvalidated: false,
2890            hash_source: None,
2891            last_observed: None,
2892        };
2893
2894        let mut sidecar = AnchorSidecar::default();
2895        sidecar.set(
2896            "mem--holder",
2897            vec![
2898                entity_anchor("mem--pinned", &pinned_hash),
2899                entity_anchor("mem--steady", &steady_hash),
2900                // An anchor over an entity that does not exist at all.
2901                entity_anchor("mem--vanished", "deadbeefdeadbeef"),
2902            ],
2903        );
2904        std::fs::write(
2905            mem_dir.join(".memstead").join("anchors.json"),
2906            sidecar.to_bytes(),
2907        )
2908        .unwrap();
2909        std::fs::write(
2910            mem_dir.join("holder.md"),
2911            "---\ntype: decision\n---\n\n# Holder\n\n## Decision\n\nHolds anchors.\n",
2912        )
2913        .unwrap();
2914
2915        // Now change ONE source entity — the pilot's move.
2916        std::fs::write(
2917            mem_dir.join("pinned.md"),
2918            "---\ntype: decision\n---\n\n# Pinned\n\n## Decision\n\nBody rewritten.\n",
2919        )
2920        .unwrap();
2921
2922        let engine = crate::Engine::from_workspace_root(root).unwrap();
2923        let resolved = engine.entity_anchors_resolved(&EntityId::canonical("mem--holder"));
2924        let state_of = |artifact: &str| {
2925            resolved
2926                .iter()
2927                .find(|r| r.anchor.artifact == artifact)
2928                .unwrap_or_else(|| panic!("no resolved anchor for {artifact}"))
2929                .state
2930        };
2931
2932        assert_eq!(
2933            state_of("mem--pinned"),
2934            Some(AnchorState::Drifted),
2935            "a stale-pinned anchor over a CHANGED entity must be drifted — \
2936             this is the pilot failure that went unflagged"
2937        );
2938        assert_eq!(
2939            state_of("mem--steady"),
2940            Some(AnchorState::Resolves),
2941            "an anchor over an unchanged entity still resolves"
2942        );
2943        assert_eq!(
2944            state_of("mem--vanished"),
2945            Some(AnchorState::Orphaned),
2946            "an anchor over an entity that is not there is orphaned, not unobserved"
2947        );
2948
2949        // The complement: a `url` grain genuinely cannot be observed, and must
2950        // stay unobserved rather than being swept up by the widened arm.
2951        let mut sc2 = AnchorSidecar::default();
2952        sc2.set(
2953            "mem--holder",
2954            vec![Anchor {
2955                artifact: "https://example.invalid/doc".to_string(),
2956                grain: AnchorGrain::Url,
2957                class: AnchorProvenanceClass::InformedBy,
2958                hash: None,
2959                source: None,
2960                binding: None,
2961                at_version: None,
2962                derived_from: Vec::new(),
2963                hash_stability: crate::anchor::AnchorHashStability::Stable,
2964                span_unvalidated: false,
2965                hash_source: None,
2966                last_observed: None,
2967            }],
2968        );
2969        std::fs::write(
2970            mem_dir.join(".memstead").join("anchors.json"),
2971            sc2.to_bytes(),
2972        )
2973        .unwrap();
2974        let engine = crate::Engine::from_workspace_root(root).unwrap();
2975        let url_state =
2976            engine.entity_anchors_resolved(&EntityId::canonical("mem--holder"))[0].state;
2977        assert_eq!(
2978            url_state, None,
2979            "url anchors stay unobserved — the fix widens observation, never the \
2980             scoring of non-observation"
2981        );
2982    }
2983
2984    /// Touchpoint A of the preparation registry, end to end: a graph source
2985    /// declaring `entity-load-bearing` makes its entity anchors hash the
2986    /// type's load-bearing sections. A notes-only edit (an optional section)
2987    /// keeps the prepared anchor resolving while an anchor over the default
2988    /// form (no source, hence no preparation) drifts — today's behaviour,
2989    /// untouched for it; a load-bearing edit drifts both. The standalone
2990    /// `verify_mem_anchors` walks the same observation and inherits the
2991    /// preparation unchanged. An unregistered identifier reaching a record
2992    /// by hand computes no form: its anchors stay unobserved, never scored.
2993    #[test]
2994    fn entity_load_bearing_preparation_ignores_notes_edits_and_catches_claim_edits() {
2995        use crate::anchor::{
2996            Anchor, AnchorGrain, AnchorProvenanceClass, AnchorSidecar, AnchorState,
2997        };
2998        use crate::binding::{
2999            BINDING_VERSION, Binding, BuildMode, BuildOperation, Operations, VerifyOperation,
3000        };
3001        use crate::entity::EntityId;
3002        use crate::pipeline::{IngestTrigger, MediumType, PatternEntry, PatternMode, Source};
3003        use crate::workspace::{
3004            Mount, MountCapability, MountLifecycle, MountStorage, Workspace, WorkspaceSettings,
3005        };
3006        use crate::workspace_store::WorkspaceStoreAdapter;
3007
3008        let tmp = tempfile::tempdir().unwrap();
3009        let root = tmp.path();
3010        let mem_dir = root.join("home");
3011        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
3012        std::fs::write(
3013            mem_dir.join(".memstead").join("config.json"),
3014            r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
3015        )
3016        .unwrap();
3017        // `assertion` in default@1.0.0: `claim` and `evidence` are required
3018        // (the load-bearing set), `conditions` is optional (notes-class).
3019        let write_pinned = |claim: &str, conditions: &str| {
3020            std::fs::write(
3021                mem_dir.join("pinned.md"),
3022                format!(
3023                    "---\ntype: assertion\n---\n\n# Pinned\n\n## Claim\n\n{claim}\n\n\
3024                     ## Evidence\n\nMeasured.\n\n## Conditions\n\n{conditions}\n"
3025                ),
3026            )
3027            .unwrap();
3028        };
3029        write_pinned("The sky is blue.", "daylight");
3030        std::fs::write(
3031            mem_dir.join("holder.md"),
3032            "---\ntype: assertion\n---\n\n# Holder\n\n## Claim\n\nDepends on pinned.\n\n\
3033             ## Evidence\n\nSee pinned.\n",
3034        )
3035        .unwrap();
3036
3037        std::fs::create_dir_all(root.join(".memstead")).unwrap();
3038        std::fs::write(
3039            root.join(".memstead").join("workspace.toml"),
3040            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
3041        )
3042        .unwrap();
3043        crate::FileWorkspaceStore::new()
3044            .save_state(
3045                root,
3046                &Workspace {
3047                    mounts: vec![Mount {
3048                        mem: "home".to_string(),
3049                        schema: Some("default@1.0.0".parse().unwrap()),
3050                        storage: MountStorage::Folder {
3051                            path: mem_dir.clone(),
3052                        },
3053                        capability: MountCapability::Write,
3054                        lifecycle: MountLifecycle::Eager,
3055                        cross_linkable: false,
3056                        migration_target: None,
3057                    }],
3058                    settings: WorkspaceSettings::default(),
3059                },
3060            )
3061            .unwrap();
3062
3063        // The binding: one graph source named `claims`, declaring the
3064        // registered preparation. Written straight to the store — the shape
3065        // every edit path validates (`validate_binding`) accepts it.
3066        let binding_with = |preparation: Option<&str>| Binding {
3067            version: BINDING_VERSION,
3068            intent: None,
3069            sources: vec![Source {
3070                name: "claims".to_string(),
3071                medium_type: MediumType::Graph,
3072                pointer: "home".to_string(),
3073                change_detection: None,
3074                scope: vec![PatternEntry {
3075                    path: "*".to_string(),
3076                    mode: PatternMode::Allow,
3077                }],
3078                engagement: None,
3079                preparation: preparation.map(str::to_string),
3080            }],
3081            reference_mems: vec![],
3082            destination_mem: "home".to_string(),
3083            deny_paths: vec![],
3084            coverage_semantics: None,
3085            rules: None,
3086            prune: None,
3087            operations: Operations {
3088                build: Some(BuildOperation {
3089                    mode: BuildMode::Discovery,
3090                    trigger: IngestTrigger::Manual,
3091                    batch_size: 5,
3092                    post_actions: None,
3093                }),
3094                sync: None,
3095                verify: Some(VerifyOperation {
3096                    trigger: IngestTrigger::Manual,
3097                    batch_size: 5,
3098                    adjudication_cap: 0,
3099                    full_resync_every: 0,
3100                }),
3101            },
3102        };
3103        let prepared_binding = binding_with(Some(crate::preparation::ENTITY_LOAD_BEARING));
3104        assert!(crate::binding::validate_binding(&prepared_binding).is_ok());
3105        crate::pipeline_store::write_binding(root, "home", "claims", &prepared_binding).unwrap();
3106
3107        // Record both anchors honestly against the entity as it stands: one
3108        // produced by the `claims` source (prepared form), one hand-authored
3109        // (no source: the default form, the canonical rendered markdown).
3110        let engine = crate::Engine::from_workspace_root(root).unwrap();
3111        let pinned = engine
3112            .store()
3113            .get(&EntityId::canonical("home--pinned"))
3114            .unwrap();
3115        let type_def = engine
3116            .schema_for("home")
3117            .and_then(|s| s.get_type("assertion"))
3118            .expect("default@1.0.0 declares assertion");
3119        assert!(
3120            crate::preparation::load_bearing_sections(&type_def)
3121                .iter()
3122                .map(|s| s.key.as_str())
3123                .eq(["claim", "evidence"]),
3124            "the required sections are the load-bearing set"
3125        );
3126        let prepared_hash = crate::preparation::entity_prepared_hash(
3127            pinned,
3128            Some(&type_def),
3129            Some(crate::preparation::ENTITY_LOAD_BEARING),
3130        )
3131        .unwrap();
3132        let default_hash =
3133            crate::preparation::entity_prepared_hash(pinned, Some(&type_def), None).unwrap();
3134        assert_ne!(prepared_hash, default_hash);
3135
3136        let anchor = |source: Option<&str>, hash: &str| Anchor {
3137            artifact: "home--pinned".to_string(),
3138            grain: AnchorGrain::Entity,
3139            class: AnchorProvenanceClass::Anchored,
3140            hash: Some(hash.to_string()),
3141            source: source.map(str::to_string),
3142            binding: None,
3143            at_version: None,
3144            derived_from: Vec::new(),
3145            hash_stability: crate::anchor::AnchorHashStability::Stable,
3146            span_unvalidated: false,
3147            hash_source: None,
3148            last_observed: None,
3149        };
3150        // Two holders so the two anchors over one artifact stay distinct rows.
3151        std::fs::write(
3152            mem_dir.join("holder2.md"),
3153            "---\ntype: assertion\n---\n\n# Holder2\n\n## Claim\n\nAlso depends.\n\n\
3154             ## Evidence\n\nSee pinned.\n",
3155        )
3156        .unwrap();
3157        let mut sidecar = AnchorSidecar::default();
3158        sidecar.set("home--holder", vec![anchor(Some("claims"), &prepared_hash)]);
3159        sidecar.set("home--holder2", vec![anchor(None, &default_hash)]);
3160        std::fs::write(
3161            mem_dir.join(".memstead").join("anchors.json"),
3162            sidecar.to_bytes(),
3163        )
3164        .unwrap();
3165
3166        let states = |root: &std::path::Path| {
3167            let engine = crate::Engine::from_workspace_root(root).unwrap();
3168            let state_of = |holder: &str| {
3169                engine.entity_anchors_resolved(&EntityId::canonical(holder))[0].state
3170            };
3171            let standalone = engine.verify_mem_anchors("home").unwrap();
3172            (
3173                state_of("home--holder"),
3174                state_of("home--holder2"),
3175                standalone,
3176            )
3177        };
3178
3179        // Unchanged: both resolve.
3180        let (prepared, plain, report) = states(root);
3181        assert_eq!(prepared, Some(AnchorState::Resolves));
3182        assert_eq!(plain, Some(AnchorState::Resolves));
3183        assert_eq!((report.resolved, report.drifted), (2, 0));
3184
3185        // A notes-only edit (`conditions` is not load-bearing): the prepared
3186        // anchor holds, the default-form anchor drifts — today's behaviour,
3187        // byte-for-byte, for a source that declares nothing.
3188        write_pinned("The sky is blue.", "daylight, clear weather");
3189        let (prepared, plain, report) = states(root);
3190        assert_eq!(
3191            prepared,
3192            Some(AnchorState::Resolves),
3193            "a comma in the notes must not break a load-bearing anchor"
3194        );
3195        assert_eq!(plain, Some(AnchorState::Drifted));
3196        assert_eq!((report.resolved, report.drifted), (1, 1));
3197
3198        // A load-bearing edit: both drift.
3199        write_pinned("The sky is green.", "daylight, clear weather");
3200        let (prepared, plain, report) = states(root);
3201        assert_eq!(prepared, Some(AnchorState::Drifted));
3202        assert_eq!(plain, Some(AnchorState::Drifted));
3203        assert_eq!((report.resolved, report.drifted), (0, 2));
3204
3205        // Complement: a hand-edited record naming an identifier the registry
3206        // does not know computes no form — its anchors are unobserved, never
3207        // scored as drift or resolution; the source-less anchor is unaffected.
3208        crate::pipeline_store::write_binding(
3209            root,
3210            "home",
3211            "claims",
3212            &binding_with(Some("pdf-to-markdown")),
3213        )
3214        .unwrap();
3215        let (prepared, plain, report) = states(root);
3216        assert_eq!(
3217            prepared, None,
3218            "an unknown preparation yields no observation"
3219        );
3220        assert_eq!(plain, Some(AnchorState::Drifted));
3221        // The unknown-preparation anchor yields NO observation, so it is
3222        // `unobserved`, not `unresolvable` (consistency-sweep 03/05,
3223        // criterion 2). Before the split this assertion read `(1, 1)` on
3224        // `unresolvable`, which is the collapse itself: the pass not reaching
3225        // an artifact was reported as the artifact being gone.
3226        assert_eq!(
3227            (report.unresolvable, report.unobserved, report.drifted),
3228            (0, 1, 1)
3229        );
3230    }
3231
3232    /// Touchpoint B's order is a property of the units, not of discovery: a
3233    /// shuffled collection sorts into the identical sequence.
3234    #[test]
3235    fn shuffled_discovery_sequences_identically() {
3236        use crate::preparation::UnitChange;
3237        let unit = |id: &str, order: &str| DeliveredUnit {
3238            id: id.to_string(),
3239            order_key: order.to_string(),
3240            change: UnitChange::Added,
3241            disposed: false,
3242        };
3243        let ordered = vec![
3244            unit("corpus/notes.md#whole", ""),
3245            unit("corpus/b.md#2026-08-20T00:00:00", "2026-08-20T00:00:00"),
3246            unit("corpus/a.md#2026-08-21T00:00:00", "2026-08-21T00:00:00"),
3247            unit("corpus/a.md#2026-08-21T00:00:00.2", "2026-08-21T00:00:00"),
3248            unit("corpus/c.md#2026-08-21T00:00:00", "2026-08-21T00:00:00"),
3249            unit("corpus/b.md#2026-08-22T00:00:00", "2026-08-22T00:00:00"),
3250        ];
3251        for shuffle in [
3252            vec![5, 3, 0, 4, 1, 2],
3253            vec![2, 1, 0, 5, 4, 3],
3254            vec![4, 0, 5, 2, 3, 1],
3255        ] {
3256            let mut units: Vec<DeliveredUnit> =
3257                shuffle.iter().map(|i| ordered[*i].clone()).collect();
3258            sequence_units(&mut units);
3259            assert_eq!(units, ordered, "discovery order {shuffle:?} must not leak");
3260        }
3261
3262        // A date-only day with twelve entries: the same-stamp ordinal orders
3263        // numerically, never as text (`.10` after `.9`, not before `.2`).
3264        let day = "2026-08-24T00:00:00";
3265        let expected: Vec<String> = (1..=12)
3266            .map(|n| {
3267                if n == 1 {
3268                    format!("journal.md#{day}")
3269                } else {
3270                    format!("journal.md#{day}.{n}")
3271                }
3272            })
3273            .collect();
3274        let mut units: Vec<DeliveredUnit> = expected.iter().rev().map(|id| unit(id, day)).collect();
3275        sequence_units(&mut units);
3276        assert_eq!(
3277            units.iter().map(|u| u.id.as_str()).collect::<Vec<_>>(),
3278            expected.iter().map(String::as_str).collect::<Vec<_>>()
3279        );
3280    }
3281
3282    /// Touchpoint B end to end over a git corpus whose path order is not its
3283    /// chronological order: the first run delivers every unit in stamp order
3284    /// interleaved across files; the sibling source without a preparation
3285    /// keeps file-granularity delivery; disposing advances through the
3286    /// sequence, an anchor over exactly a unit auto-disposes it while a
3287    /// file-level anchor does not; the change run delivers only the new,
3288    /// changed and removed units at their ordered positions (keys stable
3289    /// under growth); and a span anchor over a unit observes the unit, not
3290    /// the file.
3291    #[test]
3292    fn dated_entries_deliver_in_a_total_order_across_first_and_change_runs() {
3293        use crate::anchor::{
3294            Anchor, AnchorGrain, AnchorProvenanceClass, AnchorSidecar, AnchorState,
3295        };
3296        use crate::binding::{
3297            BINDING_VERSION, Binding, BuildMode, BuildOperation, Operations, VerifyOperation,
3298        };
3299        use crate::entity::EntityId;
3300        use crate::ingest::advance::{DispositionInput, advance_baseline};
3301        use crate::ingest::brief::render_changed_slice;
3302        use crate::ingest::resolve::resolve_binding_run;
3303        use crate::pipeline::{IngestTrigger, PatternMode};
3304        use crate::preparation::{DATED_ENTRIES, UnitChange, unitize};
3305        use crate::workspace::{
3306            Mount, MountCapability, MountLifecycle, MountStorage, Workspace, WorkspaceSettings,
3307        };
3308        use crate::workspace_store::WorkspaceStoreAdapter;
3309
3310        let tmp = tempfile::tempdir().unwrap();
3311        let root = tmp.path();
3312
3313        // Destination: a folder mem `home` with one holder entity for anchors.
3314        let mem_dir = root.join("home");
3315        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
3316        std::fs::write(
3317            mem_dir.join(".memstead").join("config.json"),
3318            r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
3319        )
3320        .unwrap();
3321        std::fs::write(
3322            mem_dir.join("holder.md"),
3323            "---\ntype: assertion\n---\n\n# Holder\n\n## Claim\n\nHolds anchors.\n\n\
3324             ## Evidence\n\nSee corpus.\n",
3325        )
3326        .unwrap();
3327        std::fs::create_dir_all(root.join(".memstead")).unwrap();
3328        std::fs::write(
3329            root.join(".memstead").join("workspace.toml"),
3330            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
3331        )
3332        .unwrap();
3333        crate::FileWorkspaceStore::new()
3334            .save_state(
3335                root,
3336                &Workspace {
3337                    mounts: vec![Mount {
3338                        mem: "home".to_string(),
3339                        schema: Some("default@1.0.0".parse().unwrap()),
3340                        storage: MountStorage::Folder {
3341                            path: mem_dir.clone(),
3342                        },
3343                        capability: MountCapability::Write,
3344                        lifecycle: MountLifecycle::Eager,
3345                        cross_linkable: false,
3346                        migration_target: None,
3347                    }],
3348                    settings: WorkspaceSettings::default(),
3349                },
3350            )
3351            .unwrap();
3352
3353        // Source: a git corpus whose lexical path order (a, b, notes) is not
3354        // its chronological order.
3355        let corpus = root.join("corpus");
3356        std::fs::create_dir_all(corpus.join("plain")).unwrap();
3357        git(&corpus, &["init", "-q"]);
3358        let write = |name: &str, text: &str| std::fs::write(corpus.join(name), text).unwrap();
3359        write(
3360            "a.md",
3361            "2026-08-21 alpha one\nbody a1\n2026-08-23 alpha two\nbody a2\n",
3362        );
3363        write(
3364            "b.md",
3365            "2026-08-20 beta one\nbody b1\n2026-08-22 beta two\nbody b2\n",
3366        );
3367        write("notes.md", "undated notes\n");
3368        write("plain/readme.txt", "plain source, file granularity\n");
3369        git(&corpus, &["add", "."]);
3370        git(&corpus, &["commit", "-q", "-m", "corpus"]);
3371
3372        let source = |name: &str, scope: &str, preparation: Option<&str>| Source {
3373            name: name.to_string(),
3374            medium_type: MediumType::Filesystem,
3375            pointer: "corpus".to_string(),
3376            change_detection: Some("git".to_string()),
3377            scope: vec![PatternEntry {
3378                path: scope.to_string(),
3379                mode: PatternMode::Allow,
3380            }],
3381            engagement: None,
3382            preparation: preparation.map(str::to_string),
3383        };
3384        let binding = Binding {
3385            version: BINDING_VERSION,
3386            intent: None,
3387            sources: vec![
3388                // Source-relative against the `corpus` pointer.
3389                source("logs", "*.md", Some(DATED_ENTRIES)),
3390                source("plain", "plain/**", None),
3391            ],
3392            reference_mems: vec![],
3393            destination_mem: "home".to_string(),
3394            deny_paths: vec![],
3395            coverage_semantics: None,
3396            rules: None,
3397            prune: None,
3398            operations: Operations {
3399                build: Some(BuildOperation {
3400                    mode: BuildMode::Discovery,
3401                    trigger: IngestTrigger::Manual,
3402                    batch_size: 3,
3403                    post_actions: None,
3404                }),
3405                sync: None,
3406                verify: Some(VerifyOperation {
3407                    trigger: IngestTrigger::Manual,
3408                    batch_size: 5,
3409                    adjudication_cap: 0,
3410                    full_resync_every: 0,
3411                }),
3412            },
3413        };
3414        assert!(
3415            crate::binding::validate_binding(&binding).is_ok(),
3416            "{:?}",
3417            crate::binding::validate_binding(&binding)
3418        );
3419        crate::pipeline_store::write_binding(root, "home", "corpus", &binding).unwrap();
3420        let resolved = resolve_binding_run("home/corpus", &binding).unwrap();
3421
3422        // ---- First run: every unit, in stamp order, interleaved across files.
3423        let mut engine = crate::Engine::from_workspace_root(root).unwrap();
3424        let cursor = compute_source_cursor(&engine, &resolved, root);
3425        let expected: Vec<&str> = vec![
3426            "corpus/notes.md#whole",
3427            "corpus/b.md#2026-08-20T00:00:00",
3428            "corpus/a.md#2026-08-21T00:00:00",
3429            "corpus/b.md#2026-08-22T00:00:00",
3430            "corpus/a.md#2026-08-23T00:00:00",
3431        ];
3432        assert_eq!(
3433            cursor.delivery.len(),
3434            1,
3435            "one sequence, for the prepared source only"
3436        );
3437        let seq = &cursor.delivery[0];
3438        assert_eq!(
3439            (seq.source.as_str(), seq.preparation.as_str()),
3440            ("logs", DATED_ENTRIES)
3441        );
3442        assert!(seq.first_run && !seq.degraded && seq.batch == 3);
3443        assert_eq!(
3444            seq.units.iter().map(|u| u.id.as_str()).collect::<Vec<_>>(),
3445            expected
3446        );
3447        assert!(
3448            seq.units
3449                .iter()
3450                .all(|u| u.change == UnitChange::Added && !u.disposed)
3451        );
3452        let mut expected_sorted: Vec<String> = expected.iter().map(|s| s.to_string()).collect();
3453        expected_sorted.sort();
3454        assert_eq!(
3455            cursor.union.added, expected_sorted,
3456            "the advance gate accepts the unit ids"
3457        );
3458        // The sibling source without a preparation keeps file granularity:
3459        // a plain first-run reseed, no units, no sequence.
3460        assert!(
3461            cursor
3462                .reseed
3463                .iter()
3464                .any(|c| c.key == "home/corpus/plain#synced")
3465        );
3466        assert!(
3467            cursor
3468                .write_commands
3469                .iter()
3470                .any(|c| c.key == "home/corpus/logs#synced")
3471        );
3472        assert!(
3473            !cursor
3474                .union
3475                .added
3476                .iter()
3477                .any(|a| a.starts_with("corpus/plain"))
3478        );
3479        // Recomputing yields the identical sequence.
3480        assert_eq!(compute_source_cursor(&engine, &resolved, root), cursor);
3481
3482        let brief = render_changed_slice(&cursor);
3483        assert!(
3484            brief.contains("### Delivery sequence: `logs` (`dated-entries`)"),
3485            "{brief}"
3486        );
3487        assert!(brief.contains("First delivery of this source"));
3488        let listed: Vec<&str> = brief
3489            .lines()
3490            .filter(|l| l.starts_with(|c: char| c.is_ascii_digit()) && l.contains("`corpus/"))
3491            .collect();
3492        assert_eq!(
3493            listed,
3494            vec![
3495                "1. `corpus/notes.md#whole` (new)",
3496                "2. `corpus/b.md#2026-08-20T00:00:00` (new)",
3497                "3. `corpus/a.md#2026-08-21T00:00:00` (new)",
3498            ],
3499            "the batch presents the first three in order"
3500        );
3501        assert!(brief.contains("…and 2 more, presented in order once these are disposed"));
3502        assert!(
3503            !brief.contains("**Added:**"),
3504            "unit ids never repeat in a class list: {brief}"
3505        );
3506
3507        // ---- Advance through the sequence.
3508        let dispositions: BTreeMap<String, DispositionInput> =
3509            [(expected[0], "skipped"), (expected[1], "worked")]
3510                .into_iter()
3511                .map(|(a, d)| (a.to_string(), DispositionInput::Verdict(d.to_string())))
3512                .collect();
3513        let outcome = advance_baseline(&mut engine, root, &resolved, &dispositions).unwrap();
3514        assert_eq!((outcome.pending, outcome.completed), (3, false));
3515        let cursor = compute_source_cursor(&engine, &resolved, root);
3516        assert!(cursor.delivery[0].units[0].disposed && cursor.delivery[0].units[1].disposed);
3517        let brief = render_changed_slice(&cursor);
3518        assert!(
3519            brief.contains("3. `corpus/a.md#2026-08-21T00:00:00` (new)"),
3520            "{brief}"
3521        );
3522        assert!(
3523            !brief.contains("1. `corpus/notes.md#whole`"),
3524            "disposed units are not re-presented"
3525        );
3526        assert!(brief.contains("2 units of this sequence already disposed"));
3527
3528        // An anchor over exactly a unit disposes it; a file-level anchor over
3529        // `b.md` disposes none of b's units.
3530        let a21_text = std::fs::read_to_string(corpus.join("a.md")).unwrap();
3531        let a21_unit = unitize(DATED_ENTRIES, &a21_text)
3532            .unwrap()
3533            .into_iter()
3534            .find(|u| u.key == "2026-08-21T00:00:00")
3535            .unwrap();
3536        let anchor = |artifact: &str, grain: AnchorGrain, hash: &str| Anchor {
3537            artifact: artifact.to_string(),
3538            grain,
3539            class: AnchorProvenanceClass::Anchored,
3540            hash: Some(hash.to_string()),
3541            source: Some("logs".to_string()),
3542            binding: None,
3543            at_version: None,
3544            derived_from: Vec::new(),
3545            hash_stability: crate::anchor::AnchorHashStability::Stable,
3546            span_unvalidated: false,
3547            hash_source: None,
3548            last_observed: None,
3549        };
3550        let b_file_hash =
3551            crate::anchor::prepared_content_hash(&std::fs::read(corpus.join("b.md")).unwrap());
3552        let mut sidecar = AnchorSidecar::default();
3553        sidecar.set(
3554            "home--holder",
3555            vec![
3556                anchor(expected[2], AnchorGrain::Span, &a21_unit.hash),
3557                anchor("corpus/b.md", AnchorGrain::File, &b_file_hash),
3558            ],
3559        );
3560        std::fs::write(
3561            mem_dir.join(".memstead").join("anchors.json"),
3562            sidecar.to_bytes(),
3563        )
3564        .unwrap();
3565        let mut engine = crate::Engine::from_workspace_root(root).unwrap();
3566        let outcome = advance_baseline(&mut engine, root, &resolved, &BTreeMap::new()).unwrap();
3567        assert_eq!(
3568            outcome.pending, 2,
3569            "the unit anchor auto-disposed its unit, the file anchor nothing"
3570        );
3571        assert!(outcome.remainder.added.contains(&expected[3].to_string()));
3572        assert!(outcome.remainder.added.contains(&expected[4].to_string()));
3573        let rest: BTreeMap<String, DispositionInput> = [expected[3], expected[4]]
3574            .into_iter()
3575            .map(|a| {
3576                (
3577                    a.to_string(),
3578                    DispositionInput::Verdict("worked".to_string()),
3579                )
3580            })
3581            .collect();
3582        let outcome = advance_baseline(&mut engine, root, &resolved, &rest).unwrap();
3583        assert!(outcome.completed, "{outcome:?}");
3584        assert!(
3585            outcome
3586                .tokens_written
3587                .contains(&"home/corpus/logs#synced".to_string())
3588        );
3589
3590        // ---- Change run: one earlier entry appended to a.md, b's second
3591        // entry edited, b's first entry removed. Only those three units are
3592        // delivered, at their ordered positions; every other key survives.
3593        write(
3594            "a.md",
3595            "2026-08-21 alpha one\nbody a1\n2026-08-23 alpha two\nbody a2\n2026-08-19 alpha zero\nbody a0\n",
3596        );
3597        write("b.md", "2026-08-22 beta two\nbody b2, revised\n");
3598        git(&corpus, &["add", "."]);
3599        git(&corpus, &["commit", "-q", "-m", "grow, edit, remove"]);
3600        let engine = crate::Engine::from_workspace_root(root).unwrap();
3601        let cursor = compute_source_cursor(&engine, &resolved, root);
3602        let seq = &cursor.delivery[0];
3603        assert!(!seq.first_run && !seq.degraded);
3604        assert_eq!(
3605            seq.units
3606                .iter()
3607                .map(|u| (u.id.as_str(), u.change))
3608                .collect::<Vec<_>>(),
3609            vec![
3610                ("corpus/a.md#2026-08-19T00:00:00", UnitChange::Added),
3611                ("corpus/b.md#2026-08-20T00:00:00", UnitChange::Deleted),
3612                ("corpus/b.md#2026-08-22T00:00:00", UnitChange::Modified),
3613            ]
3614        );
3615        let brief = render_changed_slice(&cursor);
3616        assert!(brief.contains("The units that changed since the last pass"));
3617        assert!(
3618            brief.contains("1. `corpus/a.md#2026-08-19T00:00:00` (new)"),
3619            "{brief}"
3620        );
3621        assert!(brief.contains("3. `corpus/b.md#2026-08-22T00:00:00` (changed)"));
3622
3623        // ---- Touchpoint A over units: the span anchor on a.md's unchanged
3624        // unit still resolves although its file changed; an anchor on the
3625        // removed unit orphans; one on the edited unit drifts.
3626        let b22_old_text = "2026-08-22 beta two\nbody b2\n";
3627        let b22_old = unitize(DATED_ENTRIES, b22_old_text).unwrap()[0]
3628            .hash
3629            .clone();
3630        let mut sidecar = AnchorSidecar::default();
3631        sidecar.set(
3632            "home--holder",
3633            vec![
3634                anchor(expected[2], AnchorGrain::Span, &a21_unit.hash),
3635                anchor(expected[1], AnchorGrain::Span, "deadbeefdeadbeef"),
3636                anchor(expected[3], AnchorGrain::Span, &b22_old),
3637            ],
3638        );
3639        std::fs::write(
3640            mem_dir.join(".memstead").join("anchors.json"),
3641            sidecar.to_bytes(),
3642        )
3643        .unwrap();
3644        let engine = crate::Engine::from_workspace_root(root).unwrap();
3645        let resolved_anchors = engine.entity_anchors_resolved(&EntityId::canonical("home--holder"));
3646        let state_of = |artifact: &str| {
3647            resolved_anchors
3648                .iter()
3649                .find(|r| r.anchor.artifact == artifact)
3650                .unwrap()
3651                .state
3652        };
3653        assert_eq!(
3654            state_of(expected[2]),
3655            Some(AnchorState::Resolves),
3656            "unit unchanged, file changed"
3657        );
3658        assert_eq!(
3659            state_of(expected[1]),
3660            Some(AnchorState::Orphaned),
3661            "unit removed"
3662        );
3663        assert_eq!(
3664            state_of(expected[3]),
3665            Some(AnchorState::Drifted),
3666            "unit edited"
3667        );
3668    }
3669
3670    /// Touchpoint A's code-map flavour end to end: anchors on a code-map
3671    /// source hash interface digests, so a comment, formatting or body edit
3672    /// leaves file, span and tree anchors resolving while a signature edit
3673    /// drifts them; a file joining the tree drifts the tree anchor alone;
3674    /// the sibling source without a preparation keeps whole-file hashing
3675    /// and its tree anchor hashes the plain per-file digest (so it resolves
3676    /// and drifts deterministically like everything else — the once-stated
3677    /// unhashed remainder is closed); and a write-time `content` on a
3678    /// code-map source records the digest hash, never the raw one.
3679    #[test]
3680    fn code_map_anchors_drift_on_interface_changes_only() {
3681        use crate::anchor::{
3682            Anchor, AnchorGrain, AnchorInput, AnchorProvenanceClass, AnchorSidecar, AnchorState,
3683        };
3684        use crate::binding::{
3685            BINDING_VERSION, Binding, BuildMode, BuildOperation, Operations, VerifyOperation,
3686        };
3687        use crate::entity::EntityId;
3688        use crate::ingest::resolve::Source;
3689        use crate::pipeline::{IngestTrigger, PatternMode};
3690        use crate::preparation::{CODE_MAP, code_map_digest, code_map_tree_digest};
3691        use crate::workspace::{
3692            Mount, MountCapability, MountLifecycle, MountStorage, Workspace, WorkspaceSettings,
3693        };
3694        use crate::workspace_store::WorkspaceStoreAdapter;
3695
3696        let tmp = tempfile::tempdir().unwrap();
3697        let root = tmp.path();
3698        let mem_dir = root.join("home");
3699        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
3700        std::fs::write(
3701            mem_dir.join(".memstead").join("config.json"),
3702            r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
3703        )
3704        .unwrap();
3705        std::fs::write(
3706            mem_dir.join("holder.md"),
3707            "---\ntype: assertion\n---\n\n# Holder\n\n## Claim\n\nHolds anchors.\n\n\
3708             ## Evidence\n\nSee code.\n",
3709        )
3710        .unwrap();
3711        std::fs::create_dir_all(root.join(".memstead")).unwrap();
3712        std::fs::write(
3713            root.join(".memstead").join("workspace.toml"),
3714            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
3715        )
3716        .unwrap();
3717        crate::FileWorkspaceStore::new()
3718            .save_state(
3719                root,
3720                &Workspace {
3721                    mounts: vec![Mount {
3722                        mem: "home".to_string(),
3723                        schema: Some("default@1.0.0".parse().unwrap()),
3724                        storage: MountStorage::Folder {
3725                            path: mem_dir.clone(),
3726                        },
3727                        capability: MountCapability::Write,
3728                        lifecycle: MountLifecycle::Eager,
3729                        cross_linkable: false,
3730                        migration_target: None,
3731                    }],
3732                    settings: WorkspaceSettings::default(),
3733                },
3734            )
3735            .unwrap();
3736
3737        // The corpus: a source tree under code-map, a sibling tree without.
3738        let corpus = root.join("corpus");
3739        std::fs::create_dir_all(corpus.join("src")).unwrap();
3740        std::fs::create_dir_all(corpus.join("plain")).unwrap();
3741        const A: &str = "// auth\nimport axios from 'axios'\n\nexport async function login(user, password) {\n  // call\n  return axios.post('/login', { user, password })\n}\n";
3742        const B: &str = "// the limit\nexport const LIMIT = 10\n";
3743        let write = |rel: &str, text: &str| std::fs::write(corpus.join(rel), text).unwrap();
3744        write("src/a.js", A);
3745        write("src/b.js", B);
3746        write("plain/notes.js", "export const N = 1\n");
3747
3748        let source = |name: &str, scope: &str, preparation: Option<&str>| Source {
3749            name: name.to_string(),
3750            medium_type: MediumType::Codebase,
3751            pointer: "corpus".to_string(),
3752            change_detection: Some("none".to_string()),
3753            scope: vec![PatternEntry {
3754                path: scope.to_string(),
3755                mode: PatternMode::Allow,
3756            }],
3757            engagement: None,
3758            preparation: preparation.map(str::to_string),
3759        };
3760        let binding = Binding {
3761            version: BINDING_VERSION,
3762            intent: None,
3763            sources: vec![
3764                // Scope patterns are SOURCE-relative: the pointer is
3765                // `corpus`, so these read as `corpus/src/**/*.js` and
3766                // `corpus/plain/**` on disk.
3767                source("code", "src/**/*.js", Some(CODE_MAP)),
3768                source("plain", "plain/**", None),
3769            ],
3770            reference_mems: vec![],
3771            destination_mem: "home".to_string(),
3772            deny_paths: vec![],
3773            coverage_semantics: None,
3774            rules: None,
3775            prune: None,
3776            operations: Operations {
3777                build: Some(BuildOperation {
3778                    mode: BuildMode::Discovery,
3779                    trigger: IngestTrigger::Manual,
3780                    batch_size: 5,
3781                    post_actions: None,
3782                }),
3783                sync: None,
3784                verify: Some(VerifyOperation {
3785                    trigger: IngestTrigger::Manual,
3786                    batch_size: 5,
3787                    adjudication_cap: 0,
3788                    full_resync_every: 0,
3789                }),
3790            },
3791        };
3792        assert!(crate::binding::validate_binding(&binding).is_ok());
3793        crate::pipeline_store::write_binding(root, "home", "code", &binding).unwrap();
3794
3795        let digest_hash = |rel: &str, text: &str| {
3796            crate::anchor::prepared_content_hash(code_map_digest(rel, text).as_bytes())
3797        };
3798        let tree_hash = |files: &[(&str, &str)]| {
3799            let owned: Vec<(String, String)> = files
3800                .iter()
3801                .map(|(p, t)| (p.to_string(), t.to_string()))
3802                .collect();
3803            crate::anchor::prepared_content_hash(code_map_tree_digest(&owned).as_bytes())
3804        };
3805        let anchor = |artifact: &str, grain: AnchorGrain, source: &str, hash: &str| Anchor {
3806            artifact: artifact.to_string(),
3807            grain,
3808            class: AnchorProvenanceClass::Anchored,
3809            hash: Some(hash.to_string()),
3810            source: Some(source.to_string()),
3811            binding: None,
3812            at_version: None,
3813            derived_from: Vec::new(),
3814            hash_stability: crate::anchor::AnchorHashStability::Stable,
3815            span_unvalidated: false,
3816            hash_source: None,
3817            last_observed: None,
3818        };
3819        let plain_raw = crate::anchor::prepared_content_hash(b"export const N = 1\n");
3820        let plain_tree = crate::anchor::prepared_content_hash(
3821            crate::preparation::plain_tree_digest(&[(
3822                "corpus/plain/notes.js".to_string(),
3823                b"export const N = 1\n".to_vec(),
3824            )])
3825            .as_bytes(),
3826        );
3827        let mut sidecar = AnchorSidecar::default();
3828        sidecar.set(
3829            "home--holder",
3830            vec![
3831                anchor(
3832                    "corpus/src/a.js",
3833                    AnchorGrain::File,
3834                    "code",
3835                    &digest_hash("corpus/src/a.js", A),
3836                ),
3837                anchor(
3838                    "corpus/src/a.js#L4-L7",
3839                    AnchorGrain::Span,
3840                    "code",
3841                    &digest_hash("corpus/src/a.js", A),
3842                ),
3843                anchor(
3844                    "corpus/src",
3845                    AnchorGrain::Tree,
3846                    "code",
3847                    &tree_hash(&[("corpus/src/a.js", A), ("corpus/src/b.js", B)]),
3848                ),
3849                anchor(
3850                    "corpus/plain/notes.js",
3851                    AnchorGrain::File,
3852                    "plain",
3853                    &plain_raw,
3854                ),
3855                anchor("corpus/plain", AnchorGrain::Tree, "plain", &plain_tree),
3856            ],
3857        );
3858        std::fs::write(
3859            mem_dir.join(".memstead").join("anchors.json"),
3860            sidecar.to_bytes(),
3861        )
3862        .unwrap();
3863
3864        let states = |root: &std::path::Path| {
3865            let engine = crate::Engine::from_workspace_root(root).unwrap();
3866            let resolved = engine.entity_anchors_resolved(&EntityId::canonical("home--holder"));
3867            let of = |artifact: &str| {
3868                let r = resolved
3869                    .iter()
3870                    .find(|r| r.anchor.artifact == artifact)
3871                    .unwrap_or_else(|| panic!("no anchor {artifact}"));
3872                (r.state, r.observed_hash.clone())
3873            };
3874            (
3875                of("corpus/src/a.js").0,
3876                of("corpus/src/a.js#L4-L7").0,
3877                of("corpus/src").0,
3878                of("corpus/plain/notes.js").0,
3879                of("corpus/plain"),
3880                engine.verify_mem_anchors("home").unwrap(),
3881            )
3882        };
3883
3884        // Unchanged: everything resolves — the plain tree included, since
3885        // its prepared form is the plain per-file digest of its scoped files
3886        // (the once-stated unhashed remainder is closed).
3887        let (file, span, tree, plain_file, plain_tree_state, report) = states(root);
3888        assert_eq!(
3889            (file, span, tree, plain_file),
3890            (
3891                Some(AnchorState::Resolves),
3892                Some(AnchorState::Resolves),
3893                Some(AnchorState::Resolves),
3894                Some(AnchorState::Resolves)
3895            )
3896        );
3897        assert_eq!(
3898            plain_tree_state,
3899            (Some(AnchorState::Resolves), Some(plain_tree.clone()))
3900        );
3901        assert_eq!((report.resolved, report.drifted, report.recheck), (5, 0, 0));
3902
3903        // Comment, formatting and body edits: invisible.
3904        write(
3905            "src/a.js",
3906            "// auth (rewritten comment)\nimport axios from 'axios'\n\nexport async function login(user, password) {\n    return await axios.post('/session',   { user, password })\n}\n",
3907        );
3908        let (file, span, tree, _, _, report) = states(root);
3909        assert_eq!(
3910            (file, span, tree),
3911            (
3912                Some(AnchorState::Resolves),
3913                Some(AnchorState::Resolves),
3914                Some(AnchorState::Resolves)
3915            ),
3916            "a body edit must not drift a code-map anchor"
3917        );
3918        assert_eq!(report.drifted, 0);
3919
3920        // A signature edit: file, span and tree drift.
3921        write(
3922            "src/a.js",
3923            "// auth\nimport axios from 'axios'\n\nexport async function login(user, password, remember) {\n  return axios.post('/login', { user, password, remember })\n}\n",
3924        );
3925        let (file, span, tree, plain_file, _, report) = states(root);
3926        assert_eq!(
3927            (file, span, tree),
3928            (
3929                Some(AnchorState::Drifted),
3930                Some(AnchorState::Drifted),
3931                Some(AnchorState::Drifted)
3932            )
3933        );
3934        assert_eq!(plain_file, Some(AnchorState::Resolves));
3935        assert_eq!(report.drifted, 3);
3936
3937        // Restore, then a new file joins the tree: the tree drifts alone.
3938        write("src/a.js", A);
3939        write("src/c.js", "export const C = 1\n");
3940        let (file, span, tree, _, _, _) = states(root);
3941        assert_eq!(
3942            (file, span),
3943            (Some(AnchorState::Resolves), Some(AnchorState::Resolves))
3944        );
3945        assert_eq!(
3946            tree,
3947            Some(AnchorState::Drifted),
3948            "a file joining the tree changes its code map"
3949        );
3950
3951        // The plain source is untouched by the code map: a body edit in its
3952        // file drifts the whole-file hash exactly as before — and now the
3953        // plain TREE anchor too, since any scoped-file byte change moves the
3954        // plain per-file digest.
3955        write("plain/notes.js", "export const N = 1 // note\n");
3956        let (_, _, _, plain_file, plain_tree_state, _) = states(root);
3957        assert_eq!(plain_file, Some(AnchorState::Drifted));
3958        assert_eq!(
3959            plain_tree_state.0,
3960            Some(AnchorState::Drifted),
3961            "a body edit in a scoped file drifts the plain tree anchor"
3962        );
3963
3964        // Write time: `content` on a code-map source records the digest hash.
3965        let engine = crate::Engine::from_workspace_root(root).unwrap();
3966        let input = AnchorInput {
3967            artifact: Some("corpus/src/b.js".to_string()),
3968            grain: Some("file".to_string()),
3969            class: Some("anchored".to_string()),
3970            source: Some("code".to_string()),
3971            content: Some(B.to_string()),
3972            ..Default::default()
3973        };
3974        let validated = engine.validate_anchor_inputs("home", &[input]).unwrap();
3975        assert_eq!(
3976            validated[0].hash.as_deref(),
3977            Some(digest_hash("corpus/src/b.js", B).as_str())
3978        );
3979        assert_ne!(
3980            validated[0].hash.as_deref(),
3981            Some(crate::anchor::prepared_content_hash(B.as_bytes()).as_str())
3982        );
3983        let plain_input = AnchorInput {
3984            artifact: Some("corpus/plain/notes.js".to_string()),
3985            grain: Some("file".to_string()),
3986            class: Some("anchored".to_string()),
3987            source: Some("plain".to_string()),
3988            content: Some("export const N = 1\n".to_string()),
3989            ..Default::default()
3990        };
3991        let validated = engine
3992            .validate_anchor_inputs("home", &[plain_input])
3993            .unwrap();
3994        assert_eq!(
3995            validated[0].hash.as_deref(),
3996            Some(plain_raw.as_str()),
3997            "no preparation: the raw canonicalization, as before"
3998        );
3999    }
4000
4001    /// Criterion 6's regression pin: the graph change-detection half is
4002    /// untouched except for the deliberate unscoped gate. A SCOPED graph
4003    /// facet still routes to the graph strategy and reports the same
4004    /// no-signal reason it always did when the source mem exposes no
4005    /// snapshot token (a folder mem tracks no head); an UNSCOPED one now
4006    /// refuses as `Unscoped`, exactly as the git and mtime arms have always
4007    /// done. Distinguishing the two is the whole point — before this, an
4008    /// unscoped graph facet silently proceeded.
4009    #[test]
4010    fn graph_scoping_changes_only_the_unscoped_arm() {
4011        use crate::binding::BuildMode;
4012
4013        let tmp = tempfile::tempdir().unwrap();
4014        let root = tmp.path();
4015        let mem_dir = root.join("srcmem");
4016        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
4017        std::fs::write(
4018            mem_dir.join(".memstead").join("config.json"),
4019            r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
4020        )
4021        .unwrap();
4022        std::fs::write(
4023            mem_dir.join("one.md"),
4024            "---\ntype: decision\n---\n\n# One\n\n## Decision\n\nBody.\n",
4025        )
4026        .unwrap();
4027        std::fs::create_dir_all(root.join(".memstead")).unwrap();
4028        std::fs::write(
4029            root.join(".memstead").join("workspace.toml"),
4030            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
4031        )
4032        .unwrap();
4033        {
4034            use crate::workspace::{
4035                Mount, MountCapability, MountLifecycle, MountStorage, Workspace, WorkspaceSettings,
4036            };
4037            use crate::workspace_store::WorkspaceStoreAdapter;
4038            crate::FileWorkspaceStore::new()
4039                .save_state(
4040                    root,
4041                    &Workspace {
4042                        mounts: vec![Mount {
4043                            mem: "srcmem".to_string(),
4044                            schema: Some("default@1.0.0".parse().unwrap()),
4045                            storage: MountStorage::Folder {
4046                                path: mem_dir.clone(),
4047                            },
4048                            capability: MountCapability::Write,
4049                            lifecycle: MountLifecycle::Eager,
4050                            cross_linkable: false,
4051                            migration_target: None,
4052                        }],
4053                        settings: WorkspaceSettings::default(),
4054                    },
4055                )
4056                .unwrap();
4057        }
4058        let engine = crate::Engine::from_workspace_root(root).unwrap();
4059
4060        let resolved_with = |scope: Vec<crate::pipeline::PatternEntry>| ResolvedIngest {
4061            name: "srcmem/p".to_string(),
4062            mode: BuildMode::Discovery,
4063            trigger: crate::pipeline::IngestTrigger::Manual,
4064            batch_size: 20,
4065            deny_paths: Vec::new(),
4066            projection_ref: "srcmem/p".to_string(),
4067            projection_mem: "srcmem".to_string(),
4068            projection_name: "p".to_string(),
4069            intent: None,
4070            sources: vec![ResolvedSource::Primary(Source {
4071                name: "g".to_string(),
4072                medium_type: MediumType::Graph,
4073                pointer: "srcmem".to_string(),
4074                change_detection: None,
4075                scope,
4076                engagement: None,
4077                preparation: None,
4078            })],
4079            destination_mem: "srcmem".to_string(),
4080            rules: None,
4081            post_actions: None,
4082        };
4083
4084        let scoped = compute_source_cursor(
4085            &engine,
4086            &resolved_with(vec![crate::pipeline::PatternEntry {
4087                path: "*".to_string(),
4088                mode: PatternMode::Allow,
4089            }]),
4090            root,
4091        );
4092        let unscoped = compute_source_cursor(&engine, &resolved_with(Vec::new()), root);
4093
4094        let reason_of = |c: &SourceCursor| c.no_signal.first().map(|n| n.reason);
4095        assert_eq!(
4096            reason_of(&scoped),
4097            Some(NoSignalReason::GraphSnapshotMissing),
4098            "a scoped graph facet still routes to the graph strategy and reports \
4099             its own no-signal reason — the change-detection half is untouched"
4100        );
4101        assert_eq!(
4102            reason_of(&unscoped),
4103            Some(NoSignalReason::Unscoped),
4104            "an unscoped graph facet refuses like every other medium's, instead of \
4105             silently proceeding"
4106        );
4107    }
4108
4109    /// The git medium enumerates through the same path walk as codebase and
4110    /// filesystem — its artifacts are paths pinned at a commit, so the walk is
4111    /// identical and only the anchor namespace differs. It was excluded from
4112    /// that arm for no reason beyond the arm's shape, which made its
4113    /// `enumerable: true` row a claim nothing delivered. Pinned so a refactor
4114    /// cannot quietly drop it back out.
4115    #[test]
4116    fn git_medium_enumerates_through_the_path_walk() {
4117        let ws = tempfile::tempdir().unwrap();
4118        let root = ws.path();
4119        std::fs::write(root.join("a.rs"), "").unwrap();
4120        std::fs::write(root.join("b.rs"), "").unwrap();
4121
4122        let source = |medium: MediumType| Source {
4123            name: "s".to_string(),
4124            medium_type: medium,
4125            pointer: ".".to_string(),
4126            change_detection: None,
4127            scope: vec![crate::pipeline::PatternEntry {
4128                path: "**/*.rs".to_string(),
4129                mode: PatternMode::Allow,
4130            }],
4131            engagement: None,
4132            preparation: None,
4133        };
4134
4135        let want = vec!["a.rs".to_string(), "b.rs".to_string()];
4136        for medium in [
4137            MediumType::Codebase,
4138            MediumType::Filesystem,
4139            MediumType::Git,
4140        ] {
4141            assert_eq!(
4142                enumerate_facet_files(&source(medium), &[], root),
4143                want,
4144                "{medium:?} walks the file tree — every medium the matrix marks \
4145                 enumerable with a path namespace must actually enumerate"
4146            );
4147            assert!(
4148                crate::binding::medium_capabilities(medium).enumerable,
4149                "{medium:?} claims enumerability, and now delivers it"
4150            );
4151        }
4152    }
4153
4154    /// A narrowing selector must bound the CHANGED SLICE, not only `S(D)`.
4155    /// It used to bound only enumeration, so a brief could print
4156    /// `Entities: type:concept` and then present a changed `memo` two
4157    /// sections below — an artifact its own coverage model calls out of
4158    /// scope, which `advance` would accept because its gate is the presented
4159    /// slice. Scope interpreted in one place and decorative in the other is
4160    /// the defect this pins closed.
4161    #[test]
4162    fn a_narrowing_selector_bounds_the_changed_slice_too() {
4163        use crate::workspace::{
4164            Mount, MountCapability, MountLifecycle, MountStorage, Workspace, WorkspaceSettings,
4165        };
4166        use crate::workspace_store::WorkspaceStoreAdapter;
4167
4168        let tmp = tempfile::tempdir().unwrap();
4169        let root = tmp.path();
4170        let mem_dir = root.join("srcmem");
4171        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
4172        std::fs::write(
4173            mem_dir.join(".memstead").join("config.json"),
4174            r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
4175        )
4176        .unwrap();
4177        let write = |slug: &str, ty: &str| {
4178            std::fs::write(
4179                mem_dir.join(format!("{slug}.md")),
4180                format!("---\ntype: {ty}\n---\n\n# {slug}\n\n## Decision\n\nBody.\n"),
4181            )
4182            .unwrap();
4183        };
4184        write("kept", "decision");
4185        write("other", "memo");
4186        std::fs::create_dir_all(root.join(".memstead")).unwrap();
4187        std::fs::write(
4188            root.join(".memstead").join("workspace.toml"),
4189            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
4190        )
4191        .unwrap();
4192        crate::FileWorkspaceStore::new()
4193            .save_state(
4194                root,
4195                &Workspace {
4196                    mounts: vec![Mount {
4197                        mem: "srcmem".to_string(),
4198                        schema: Some("default@1.0.0".parse().unwrap()),
4199                        storage: MountStorage::Folder {
4200                            path: mem_dir.clone(),
4201                        },
4202                        capability: MountCapability::Write,
4203                        lifecycle: MountLifecycle::Eager,
4204                        cross_linkable: false,
4205                        migration_target: None,
4206                    }],
4207                    settings: WorkspaceSettings::default(),
4208                },
4209            )
4210            .unwrap();
4211        let engine = crate::Engine::from_workspace_root(root).unwrap();
4212
4213        let source = Source {
4214            name: "g".to_string(),
4215            medium_type: MediumType::Graph,
4216            pointer: "srcmem".to_string(),
4217            change_detection: None,
4218            scope: vec![crate::pipeline::PatternEntry {
4219                path: "type:decision".to_string(),
4220                mode: PatternMode::Allow,
4221            }],
4222            engagement: None,
4223            preparation: None,
4224        };
4225
4226        let mut slice = Slice {
4227            added: vec!["srcmem--other".to_string()],
4228            modified: vec!["srcmem--kept".to_string(), "srcmem--other".to_string()],
4229            deleted: vec!["srcmem--vanished".to_string()],
4230        };
4231        filter_graph_slice_to_scope(&engine, &source, &mut slice);
4232
4233        assert_eq!(
4234            slice.modified,
4235            vec!["srcmem--kept".to_string()],
4236            "the out-of-scope memo is dropped from the slice the brief presents"
4237        );
4238        assert!(
4239            slice.added.is_empty(),
4240            "an added out-of-scope entity is out of scope too"
4241        );
4242        assert_eq!(
4243            slice.deleted,
4244            vec!["srcmem--vanished".to_string()],
4245            "a DELETED entity is kept even though its type can no longer be \
4246             read — a deletion that cannot be classified must be reported, \
4247             never silently dropped"
4248        );
4249
4250        // The complement: the whole-mem selector narrows nothing.
4251        let mut wide = Slice {
4252            added: Vec::new(),
4253            modified: vec!["srcmem--kept".to_string(), "srcmem--other".to_string()],
4254            deleted: Vec::new(),
4255        };
4256        let mut all = source.clone();
4257        all.scope = vec![crate::pipeline::PatternEntry {
4258            path: "*".to_string(),
4259            mode: PatternMode::Allow,
4260        }];
4261        filter_graph_slice_to_scope(&engine, &all, &mut wide);
4262        assert_eq!(wide.modified.len(), 2, "`*` selects the whole mem");
4263    }
4264
4265    /// The entity-selector grammar is closed: three legal forms, everything
4266    /// else refused. A pattern that parses to `None` is a validation refusal
4267    /// at declaration — never a rule that silently selects nothing.
4268    #[test]
4269    fn entity_selector_grammar_is_closed() {
4270        use super::EntitySelector;
4271        assert_eq!(parse_entity_selector("*"), Some(EntitySelector::All));
4272        assert_eq!(
4273            parse_entity_selector("type:decision"),
4274            Some(EntitySelector::Type("decision".to_string()))
4275        );
4276        assert_eq!(
4277            parse_entity_selector("id:engine--*"),
4278            Some(EntitySelector::Id("engine--*".to_string()))
4279        );
4280        // The path glob `projection init` used to scaffold for graph sources:
4281        // it looks like scope and selects nothing. Refused, not accepted.
4282        assert_eq!(parse_entity_selector("**/*"), None);
4283        assert_eq!(parse_entity_selector("src/**"), None);
4284        assert_eq!(parse_entity_selector("type:"), None);
4285        assert_eq!(parse_entity_selector("id:"), None);
4286        assert_eq!(parse_entity_selector(""), None);
4287    }
4288
4289    /// The mtime driver reseeds on the first pass (writing the memo), then
4290    /// diffs precisely against the memoised map — including deletions.
4291    #[test]
4292    fn mtime_driver_reseeds_then_diffs_precisely() {
4293        let ws = tempfile::tempdir().unwrap();
4294        let root = ws.path();
4295        let cache = root.join(".memstead.cache").join("ingest");
4296        std::fs::write(root.join("a.rs"), "one").unwrap();
4297        std::fs::write(root.join("gone.rs"), "bye").unwrap();
4298        let source = primary(vec![PatternEntry {
4299            path: "**/*.rs".to_string(),
4300            mode: PatternMode::Allow,
4301        }]);
4302
4303        // First pass: no baseline → reseed at the current digest, memo written.
4304        let token = match compute_mtime_slice(&source, "ing", &[], root, &cache, None) {
4305            SliceOutcome::Reseed { token } => token,
4306            other => panic!("expected Reseed, got {other:?}"),
4307        };
4308
4309        // Move the source: modify a.rs (size change), delete gone.rs, add new.rs.
4310        std::fs::write(root.join("a.rs"), "one-longer").unwrap();
4311        std::fs::remove_file(root.join("gone.rs")).unwrap();
4312        std::fs::write(root.join("new.rs"), "x").unwrap();
4313
4314        // Second pass with the reseed token → precise diff from the memo.
4315        match compute_mtime_slice(&source, "ing", &[], root, &cache, Some(&token)) {
4316            SliceOutcome::Changed {
4317                slice, degraded, ..
4318            } => {
4319                assert!(
4320                    !degraded,
4321                    "memo present → precise, not a degraded full scan"
4322                );
4323                assert_eq!(slice.added, vec!["new.rs"]);
4324                assert_eq!(slice.modified, vec!["a.rs"]);
4325                assert_eq!(
4326                    slice.deleted,
4327                    vec!["gone.rs"],
4328                    "deletions come from the memo"
4329                );
4330            }
4331            other => panic!("expected Changed, got {other:?}"),
4332        }
4333
4334        // A run whose baseline aggregate is not memoised degrades to a full
4335        // scan (every current file as added, no deletions).
4336        let stale = super::super::change_detection::serialize_digest_token(
4337            &super::super::change_detection::digest_stat_map(&stat_map_for(&["absent.rs"])),
4338        );
4339        match compute_mtime_slice(&source, "ing", &[], root, &cache, Some(&stale)) {
4340            SliceOutcome::Changed { degraded, .. } => assert!(degraded, "memo miss → degraded"),
4341            other => panic!("expected degraded Changed, got {other:?}"),
4342        }
4343    }
4344
4345    fn head_sha(repo: &Path) -> String {
4346        String::from_utf8(
4347            std::process::Command::new("git")
4348                .args(["rev-parse", "HEAD"])
4349                .current_dir(repo)
4350                .output()
4351                .unwrap()
4352                .stdout,
4353        )
4354        .unwrap()
4355        .trim()
4356        .to_string()
4357    }
4358
4359    fn slice_contains(slice: &Slice, path: &str) -> bool {
4360        let p = path.to_string();
4361        slice.added.contains(&p) || slice.modified.contains(&p) || slice.deleted.contains(&p)
4362    }
4363
4364    /// The mtime `source_moved` / `current_primary_token` value: the digest
4365    /// token over the deny-filtered enumeration — exactly what the mtime branch
4366    /// of `current_primary_token` computes.
4367    fn mtime_token(source: &Source, deny: &[String], root: &Path) -> String {
4368        let files = enumerate_facet_files(source, deny, root);
4369        serialize_digest_token(&digest_stat_map(&compute_stat_map(&files, root)))
4370    }
4371
4372    /// AC1 (deny invariance): a file matching an ingest `deny_paths` entry
4373    /// appears in **no** changed slice (git, mtime), **no** refinement batch,
4374    /// and does **not** influence the mtime digest / `source_moved` token —
4375    /// exercising the *same* denied file across every strategy that reads a
4376    /// file tree.
4377    #[test]
4378    fn deny_paths_excluded_from_every_strategy_and_token() {
4379        use crate::binding::BuildMode;
4380        use crate::ingest::refinement::next_batch;
4381        use crate::pipeline::IngestTrigger;
4382
4383        let repo = tempfile::tempdir().unwrap();
4384        let root = repo.path();
4385        let cache = root.join(".memstead.cache").join("ingest");
4386
4387        // One tree that is both the git work tree and the mtime/refinement
4388        // workspace root (medium_pointer "" → base == root).
4389        git(root, &["init", "-q"]);
4390        std::fs::write(root.join("keep.rs"), "one").unwrap();
4391        std::fs::write(root.join("denied.rs"), "secret-one").unwrap();
4392        git(root, &["add", "-A"]);
4393        git(root, &["commit", "-qm", "base"]);
4394        let baseline = head_sha(root);
4395
4396        // Both files genuinely move — denied.rs must never surface anywhere.
4397        std::fs::write(root.join("keep.rs"), "two").unwrap();
4398        std::fs::write(root.join("denied.rs"), "secret-two").unwrap();
4399        git(root, &["add", "-A"]);
4400        git(root, &["commit", "-qm", "move"]);
4401
4402        // Scope allows every .rs; the ingest denies denied.rs by the same
4403        // workspace-relative glob grammar the git strategy uses.
4404        let source = primary(vec![PatternEntry {
4405            path: "**/*.rs".to_string(),
4406            mode: PatternMode::Allow,
4407        }]);
4408        let deny = vec!["denied.rs".to_string()];
4409
4410        // (1) git slice — with the deny, only keep.rs.
4411        match compute_git_slice(&source, &deny, root, Some(&baseline)) {
4412            SliceOutcome::Changed { slice, .. } => {
4413                assert_eq!(slice.modified, vec!["keep.rs"]);
4414                assert!(!slice_contains(&slice, "denied.rs"), "git deny leak");
4415            }
4416            other => panic!("git: expected Changed, got {other:?}"),
4417        }
4418        // Control: without the deny, denied.rs *is* a real change — proving the
4419        // deny (not the scope) is what excludes it above.
4420        match compute_git_slice(&source, &[], root, Some(&baseline)) {
4421            SliceOutcome::Changed { slice, .. } => {
4422                assert!(
4423                    slice_contains(&slice, "denied.rs"),
4424                    "un-denied, denied.rs is a genuine git change"
4425                );
4426            }
4427            other => panic!("git(no-deny): expected Changed, got {other:?}"),
4428        }
4429
4430        // (2) enumeration (mtime input set + refinement source set).
4431        assert_eq!(enumerate_facet_files(&source, &deny, root), vec!["keep.rs"]);
4432        assert!(
4433            enumerate_facet_files(&source, &[], root).contains(&"denied.rs".to_string()),
4434            "un-denied, denied.rs is enumerated"
4435        );
4436
4437        // (2b) mtime slice — reseed, then move both files; only keep.rs surfaces.
4438        let token = match compute_mtime_slice(&source, "ing", &deny, root, &cache, None) {
4439            SliceOutcome::Reseed { token } => token,
4440            other => panic!("mtime reseed expected, got {other:?}"),
4441        };
4442        std::fs::write(root.join("keep.rs"), "three-longer").unwrap();
4443        std::fs::write(root.join("denied.rs"), "secret-three-longer").unwrap();
4444        match compute_mtime_slice(&source, "ing", &deny, root, &cache, Some(&token)) {
4445            SliceOutcome::Changed { slice, .. } => {
4446                assert_eq!(slice.modified, vec!["keep.rs"]);
4447                assert!(!slice_contains(&slice, "denied.rs"), "mtime deny leak");
4448            }
4449            other => panic!("mtime: expected Changed, got {other:?}"),
4450        }
4451
4452        // (3) mtime digest / source_moved token — invariant to denied.rs, since
4453        // the token is the digest over the deny-filtered enumeration. Removing
4454        // denied.rs from disk leaves the token unchanged; a leak would show it
4455        // as a deletion and shift the digest.
4456        let token_present = mtime_token(&source, &deny, root);
4457        std::fs::remove_file(root.join("denied.rs")).unwrap();
4458        let token_absent = mtime_token(&source, &deny, root);
4459        assert_eq!(
4460            token_present, token_absent,
4461            "denied.rs must not influence the mtime digest / source_moved token"
4462        );
4463        std::fs::write(root.join("denied.rs"), "secret-restored").unwrap();
4464
4465        // (4) refinement batch — the denied file is never batched.
4466        let resolved = ResolvedIngest {
4467            name: "ing".to_string(),
4468            mode: BuildMode::Discovery,
4469            trigger: IngestTrigger::Loop,
4470            batch_size: 50,
4471            deny_paths: deny.clone(),
4472            projection_ref: "m/p".to_string(),
4473            projection_mem: "m".to_string(),
4474            projection_name: "p".to_string(),
4475            intent: None,
4476            sources: vec![ResolvedSource::Primary(source.clone())],
4477            destination_mem: "m".to_string(),
4478            rules: None,
4479            post_actions: None,
4480        };
4481        let engine = crate::Engine::from_mounts(Vec::new()).unwrap();
4482        let batch = next_batch(&engine, &resolved, root, &cache, 20).unwrap();
4483        assert!(
4484            batch.files.contains(&"keep.rs".to_string()),
4485            "keep.rs batched"
4486        );
4487        assert!(
4488            !batch.files.contains(&"denied.rs".to_string()),
4489            "denied.rs must never enter a refinement batch"
4490        );
4491    }
4492
4493    /// AC2 (one empty-scope semantic): an **unscoped** facet (no allow
4494    /// patterns) is the same typed refusal — `NoSignal { Unscoped }` — on git
4495    /// AND mtime, never a silent empty slice. AC2 complement: an empty
4496    /// `deny_paths` list does NOT trip that refusal — a *scoped* facet still
4497    /// classifies normally (empty scope and empty deny_paths are different
4498    /// fields with different semantics).
4499    #[test]
4500    fn unscoped_facet_refuses_uniformly_and_empty_deny_is_distinct() {
4501        let repo = tempfile::tempdir().unwrap();
4502        let root = repo.path();
4503        let cache = root.join(".memstead.cache").join("ingest");
4504        git(root, &["init", "-q"]);
4505        std::fs::write(root.join("a.rs"), "one").unwrap();
4506        git(root, &["add", "-A"]);
4507        git(root, &["commit", "-qm", "base"]);
4508        let baseline = head_sha(root);
4509        std::fs::write(root.join("a.rs"), "two").unwrap();
4510        git(root, &["add", "-A"]);
4511        git(root, &["commit", "-qm", "move"]);
4512
4513        // Unscoped: a deny pattern but no allow. `deny_paths` is empty here —
4514        // so the refusal comes from the empty *scope*, not from denies.
4515        let unscoped = primary(vec![PatternEntry {
4516            path: "target/**".to_string(),
4517            mode: PatternMode::Deny,
4518        }]);
4519        assert_eq!(
4520            compute_git_slice(&unscoped, &[], root, Some(&baseline)),
4521            SliceOutcome::NoSignal {
4522                reason: NoSignalReason::Unscoped
4523            },
4524            "git refuses an unscoped facet"
4525        );
4526        assert_eq!(
4527            compute_mtime_slice(&unscoped, "ing", &[], root, &cache, None),
4528            SliceOutcome::NoSignal {
4529                reason: NoSignalReason::Unscoped
4530            },
4531            "mtime refuses an unscoped facet identically"
4532        );
4533        // A fully empty scope is unscoped too.
4534        let empty_scope = primary(vec![]);
4535        assert_eq!(
4536            compute_git_slice(&empty_scope, &[], root, Some(&baseline)),
4537            SliceOutcome::NoSignal {
4538                reason: NoSignalReason::Unscoped
4539            }
4540        );
4541
4542        // Complement: a SCOPED facet with an empty `deny_paths` classifies
4543        // normally — empty deny_paths (no denies) must not trip the refusal.
4544        let scoped = primary(vec![PatternEntry {
4545            path: "**/*.rs".to_string(),
4546            mode: PatternMode::Allow,
4547        }]);
4548        assert!(
4549            matches!(
4550                compute_git_slice(&scoped, &[], root, Some(&baseline)),
4551                SliceOutcome::Changed { .. }
4552            ),
4553            "scoped facet + empty deny_paths → normal git slice, not a refusal"
4554        );
4555        assert!(
4556            matches!(
4557                compute_mtime_slice(&scoped, "ing", &[], root, &cache, None),
4558                SliceOutcome::Reseed { .. }
4559            ),
4560            "scoped facet + empty deny_paths → normal mtime reseed, not a refusal"
4561        );
4562    }
4563
4564    /// AC2 refinement leg: an ingest whose only source is unscoped emits no
4565    /// refinement batch — the refusal, not a silent empty batch.
4566    #[test]
4567    fn unscoped_facet_emits_no_refinement_batch() {
4568        use crate::binding::BuildMode;
4569        use crate::ingest::refinement::next_batch;
4570        use crate::pipeline::IngestTrigger;
4571
4572        let ws = tempfile::tempdir().unwrap();
4573        let root = ws.path();
4574        let cache = root.join(".memstead.cache").join("ingest");
4575        std::fs::write(root.join("a.rs"), "x").unwrap();
4576
4577        let resolved = ResolvedIngest {
4578            name: "ing".to_string(),
4579            mode: BuildMode::Discovery,
4580            trigger: IngestTrigger::Loop,
4581            batch_size: 50,
4582            deny_paths: vec![],
4583            projection_ref: "m/p".to_string(),
4584            projection_mem: "m".to_string(),
4585            projection_name: "p".to_string(),
4586            intent: None,
4587            // Only source: an unscoped facet (no allow patterns).
4588            sources: vec![ResolvedSource::Primary(primary(vec![]))],
4589            destination_mem: "m".to_string(),
4590            rules: None,
4591            post_actions: None,
4592        };
4593        assert!(
4594            next_batch(
4595                &crate::Engine::from_mounts(Vec::new()).unwrap(),
4596                &resolved,
4597                root,
4598                &cache,
4599                20
4600            )
4601            .is_none(),
4602            "an all-unscoped ingest emits no refinement batch"
4603        );
4604    }
4605
4606    /// AC3 (visible NoSignal) end-to-end through the cursor: a `signal:none`
4607    /// source and an unscoped source each contribute a distinct no-signal note;
4608    /// a first-seen (reseed) source does NOT — only no-signal reasons are
4609    /// noted. The rendered preface names `signal:none` explicitly and the
4610    /// unscoped reason distinctly.
4611    #[test]
4612    fn compute_source_cursor_notes_no_signal_reasons() {
4613        use crate::binding::BuildMode;
4614        use crate::pipeline::IngestTrigger;
4615
4616        let engine = crate::Engine::from_mounts(Vec::new()).unwrap();
4617        // No `.git` over the workspace → mtime strategy for `auto`/`mtime`.
4618        let ws = tempfile::tempdir().unwrap();
4619        let root = ws.path();
4620        std::fs::write(root.join("a.rs"), "x").unwrap();
4621
4622        let allow_rs = || {
4623            vec![PatternEntry {
4624                path: "**/*.rs".to_string(),
4625                mode: PatternMode::Allow,
4626            }]
4627        };
4628        let src = |facet: &str, declared: &str, scope: Vec<PatternEntry>| {
4629            ResolvedSource::Primary(Source {
4630                name: facet.to_string(),
4631                medium_type: MediumType::Filesystem,
4632                pointer: String::new(),
4633                change_detection: Some(declared.to_string()),
4634                scope,
4635                engagement: None,
4636                preparation: None,
4637            })
4638        };
4639
4640        let resolved = ResolvedIngest {
4641            name: "ing".to_string(),
4642            mode: BuildMode::Discovery,
4643            trigger: IngestTrigger::Loop,
4644            batch_size: 20,
4645            deny_paths: vec![],
4646            projection_ref: "m/p".to_string(),
4647            projection_mem: "m".to_string(),
4648            projection_name: "p".to_string(),
4649            intent: None,
4650            sources: vec![
4651                // signal:none → DetectionNone note (even though it is scoped).
4652                src("plan", "none", allow_rs()),
4653                // mtime + no allows → Unscoped note.
4654                src("blind", "mtime", vec![]),
4655                // mtime + allows, first-seen → Reseed, NOT a no-signal note.
4656                src("watched", "mtime", allow_rs()),
4657            ],
4658            destination_mem: "m".to_string(),
4659            rules: None,
4660            post_actions: None,
4661        };
4662
4663        let cursor = compute_source_cursor(&engine, &resolved, root);
4664        let reasons: BTreeMap<&str, NoSignalReason> = cursor
4665            .no_signal
4666            .iter()
4667            .map(|n| (n.source.as_str(), n.reason))
4668            .collect();
4669        assert_eq!(reasons.get("plan"), Some(&NoSignalReason::DetectionNone));
4670        assert_eq!(reasons.get("blind"), Some(&NoSignalReason::Unscoped));
4671        assert!(
4672            !reasons.contains_key("watched"),
4673            "a first-seen (reseed) source is not a no-signal note"
4674        );
4675        assert_eq!(cursor.no_signal.len(), 2);
4676        // The reseed source still produced a reseed command.
4677        assert!(cursor.reseed.iter().any(|c| c.key == "ing/watched#synced"));
4678
4679        // The rendered preface names signal:none and the unscoped reason.
4680        let out = crate::ingest::brief::render_changed_slice(&cursor);
4681        assert!(out.contains("- `plan`: `signal:none`"));
4682        assert!(out.contains("- `blind`: unscoped facet"));
4683    }
4684
4685    fn stat_map_for(paths: &[&str]) -> super::super::change_detection::StatMap {
4686        paths
4687            .iter()
4688            .map(|p| {
4689                (
4690                    (*p).to_string(),
4691                    super::super::change_detection::StatEntry { mtime: 1, size: 1 },
4692                )
4693            })
4694            .collect()
4695    }
4696
4697    /// Engine self-exclusion: `.memstead/**`, `.memstead.cache/**`, and
4698    /// every mount's resolved storage location (here a mem-repo at a
4699    /// NON-default directory name) are absent from the enumeration
4700    /// regardless of configuration — explicit allow globs covering them
4701    /// do not admit them.
4702    #[test]
4703    fn engine_state_never_enumerates_even_when_allowed() {
4704        let ws = tempfile::tempdir().unwrap();
4705        let root = ws.path();
4706        for rel in [
4707            ".memstead/state/findings/muehle/f.json",
4708            ".memstead/projections/muehle/f.json",
4709            ".memstead.cache/ingest/source-cursor/muehle/f/f.json",
4710            "custom-repo/README.md",
4711            "Allgemein/Protokoll.md",
4712            "Allgemein/Vertrag.md",
4713        ] {
4714            let path = root.join(rel);
4715            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
4716            std::fs::write(&path, "x").unwrap();
4717        }
4718        // Engine-managed workspace state resolving the mem-repo at
4719        // `custom-repo/` — the exclusion must key on this resolved
4720        // location, not on the literal default name `mem-repo/`.
4721        std::fs::write(
4722            root.join(".memstead/workspace.toml"),
4723            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
4724        )
4725        .unwrap();
4726        std::fs::write(
4727            root.join(".memstead/state/mounts.json"),
4728            serde_json::json!({
4729                "format": "memstead-mounts-3",
4730                "mounts": [{
4731                    "mem": "muehle",
4732                    "schema": "default@1.0.0",
4733                    "storage": {
4734                        "type": "git-branch",
4735                        "gitdir": "custom-repo/.git",
4736                        "branch": "refs/heads/muehle"
4737                    },
4738                    "capability": "write",
4739                    "lifecycle": "eager",
4740                    "cross_linkable": true
4741                }]
4742            })
4743            .to_string(),
4744        )
4745        .unwrap();
4746
4747        // Allow everything AND explicitly try to admit engine state.
4748        let source = primary(vec![
4749            PatternEntry {
4750                path: "**/*".to_string(),
4751                mode: PatternMode::Allow,
4752            },
4753            PatternEntry {
4754                path: ".memstead/**".to_string(),
4755                mode: PatternMode::Allow,
4756            },
4757            PatternEntry {
4758                path: "custom-repo/**".to_string(),
4759                mode: PatternMode::Allow,
4760            },
4761        ]);
4762        let got = enumerate_facet_files(&source, &[], root);
4763        assert_eq!(
4764            got,
4765            vec!["Allgemein/Protokoll.md", "Allgemein/Vertrag.md"],
4766            "only source artifacts may enter the denominator"
4767        );
4768    }
4769
4770    /// The git strategy pushes the same engine-state excludes as
4771    /// pathspecs: a diff touching `.memstead/**` and the resolved
4772    /// mem-repo path yields a slice naming neither — denominator and
4773    /// slice stay strategy-invariant.
4774    #[test]
4775    fn git_slice_excludes_engine_state() {
4776        let repo = tempfile::tempdir().unwrap();
4777        let root = repo.path();
4778        git(root, &["init", "-q"]);
4779        std::fs::write(
4780            root.join("workspace.rs"), // placeholder so base commit is non-empty
4781            "x",
4782        )
4783        .unwrap();
4784        std::fs::create_dir_all(root.join(".memstead/state")).unwrap();
4785        std::fs::write(
4786            root.join(".memstead/workspace.toml"),
4787            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
4788        )
4789        .unwrap();
4790        std::fs::write(
4791            root.join(".memstead/state/mounts.json"),
4792            serde_json::json!({
4793                "format": "memstead-mounts-3",
4794                "mounts": [{
4795                    "mem": "muehle",
4796                    "schema": "default@1.0.0",
4797                    "storage": {
4798                        "type": "git-branch",
4799                        "gitdir": "custom-repo/.git",
4800                        "branch": "refs/heads/muehle"
4801                    },
4802                    "capability": "write",
4803                    "lifecycle": "eager",
4804                    "cross_linkable": true
4805                }]
4806            })
4807            .to_string(),
4808        )
4809        .unwrap();
4810        git(root, &["add", "-A"]);
4811        git(root, &["commit", "-qm", "base"]);
4812        let baseline = String::from_utf8(
4813            std::process::Command::new("git")
4814                .args(["rev-parse", "HEAD"])
4815                .current_dir(root)
4816                .output()
4817                .unwrap()
4818                .stdout,
4819        )
4820        .unwrap()
4821        .trim()
4822        .to_string();
4823
4824        // Move: one real file, one engine-state file, one mem-repo file.
4825        std::fs::write(root.join("real.md"), "signal").unwrap();
4826        std::fs::write(root.join(".memstead/state/findings.json"), "self").unwrap();
4827        std::fs::create_dir_all(root.join("custom-repo")).unwrap();
4828        std::fs::write(root.join("custom-repo/README.md"), "repo").unwrap();
4829        git(root, &["add", "-A"]);
4830        git(root, &["commit", "-qm", "move"]);
4831
4832        let source = primary(vec![PatternEntry {
4833            path: "**/*".to_string(),
4834            mode: PatternMode::Allow,
4835        }]);
4836        match compute_git_slice(&source, &[], root, Some(&baseline)) {
4837            SliceOutcome::Changed { slice, .. } => {
4838                assert_eq!(
4839                    slice.added,
4840                    vec!["real.md"],
4841                    "engine state leaked: {slice:?}"
4842                );
4843                assert!(slice.modified.is_empty(), "{slice:?}");
4844            }
4845            other => panic!("expected Changed, got {other:?}"),
4846        }
4847    }
4848
4849    /// The dead-deny lint never flags the scaffold's own default hygiene
4850    /// entries (they can never match on a git-enumerated source — the
4851    /// engine must not call its own output a typo), while a user-authored
4852    /// entry that matches nothing keeps the loud warning and one that
4853    /// matches stays silent.
4854    #[test]
4855    fn dead_deny_lint_exempts_scaffold_defaults_but_not_user_typos() {
4856        use crate::binding::{BuildMode, DEFAULT_SCAFFOLD_DENY_PATHS};
4857        use crate::pipeline::IngestTrigger;
4858
4859        let repo = tempfile::tempdir().unwrap();
4860        let root = repo.path();
4861        git(root, &["init", "-q"]);
4862        std::fs::create_dir_all(root.join("src")).unwrap();
4863        std::fs::write(root.join("src/lib.rs"), "code").unwrap();
4864
4865        let mut deny_paths: Vec<String> = DEFAULT_SCAFFOLD_DENY_PATHS
4866            .iter()
4867            .map(|s| s.to_string())
4868            .collect();
4869        deny_paths.push("typo/**".to_string()); // user typo — matches nothing
4870        deny_paths.push("src/**".to_string()); // user entry that matches
4871
4872        let resolved = ResolvedIngest {
4873            name: "ing".to_string(),
4874            mode: BuildMode::Discovery,
4875            trigger: IngestTrigger::Loop,
4876            batch_size: 20,
4877            deny_paths,
4878            projection_ref: "m/p".to_string(),
4879            projection_mem: "m".to_string(),
4880            projection_name: "p".to_string(),
4881            intent: None,
4882            sources: vec![ResolvedSource::Primary(primary(vec![PatternEntry {
4883                path: "**/*.rs".to_string(),
4884                mode: PatternMode::Allow,
4885            }]))],
4886            destination_mem: "m".to_string(),
4887            rules: None,
4888            post_actions: None,
4889        };
4890
4891        let dead = dead_deny_entries(&resolved, root);
4892        assert_eq!(
4893            dead,
4894            vec!["typo/**".to_string()],
4895            "only the user typo is flagged — scaffold defaults and matching \
4896             entries stay silent"
4897        );
4898    }
4899}