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