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