Skip to main content

memstead_base/ingest/
advance.rs

1//! `projection advance` — the disposition-gated, resumable baseline advance
2//! (bundle plan `03-projection-promotion`, decision D7).
3//!
4//! An ingest/sync agent works the changed slice a brief presented, then records
5//! a **disposition** for every artifact it judged. `advance_baseline` is the
6//! engine primitive behind `memstead projection advance`: it freezes the
7//! presented slice, subtracts already-disposed artifacts on re-presentation,
8//! appends new-HEAD deltas when the source moves mid-pass, and — when the
9//! remainder empties — advances the destination mem's `#synced` baseline token
10//! through the existing [`Engine::set_mem_sync_state`] writer.
11//!
12//! ## Durability (why not `.memstead.cache/`)
13//!
14//! Dispositions are **not** disposable: losing them recreates the stall the
15//! redesign exists to kill. The frozen-slice snapshot + accumulated dispositions
16//! live under engine-owned **workspace state**,
17//! `.memstead/state/advance/<mem>/<name>.json` — a sibling of `state/mounts.json`
18//! and valid on both backends — read fresh from disk per call, so resumability
19//! is on-disk, not in-memory: a disposition recorded in one process is honored
20//! by the next.
21//!
22//! ## The gate (atomic, engine-printed ids only)
23//!
24//! The advance gate accepts **only** artifact ids the engine itself printed
25//! (the frozen slice, grown by any new-HEAD deltas). A disposition naming an id
26//! the engine never presented refuses the **whole call atomically** — validated
27//! before any disk write, so a refused call leaves the store byte-identical.
28//!
29//! ## Auto-`worked` from anchors (E3a — closes plan 03 D7's deferral)
30//!
31//! With anchors live, a mutation that carried `anchors[]` during a run records,
32//! in the destination mem's anchors sidecar, which source artifacts an entity
33//! now describes. [`advance_baseline`] reads that sidecar and marks any
34//! **frozen-slice** artifact referenced by such an anchor `worked`
35//! automatically, so the advance gate requires an explicit disposition only for
36//! the residue. Two invariants keep this honest:
37//!
38//! - the derivation reads **anchors, never a commit diff** — the inference-from-
39//!   diffs mechanism D7 rejected stays rejected; a write without `anchors[]`
40//!   marks nothing;
41//! - only the intersection with the frozen slice is ever marked — an anchored
42//!   write referencing an artifact outside the presented slice fabricates no
43//!   slice entry.
44//!
45//! AC9's non-stalling property still rests on the persisted dispositions + slice
46//! subtraction; auto-`worked` only removes the explicit-disposition burden for
47//! artifacts anchored during the same pass.
48
49use std::collections::{BTreeMap, BTreeSet};
50use std::path::{Path, PathBuf};
51
52use crate::Engine;
53use crate::workspace_store::{StoreError, WORKSPACE_STORE_DIR};
54
55use super::cursor::{
56    compute_source_cursor, enumerate_source_artifacts, enumerate_source_artifacts_reported,
57    medium_base, normalize_lexical, relative_path,
58};
59use super::resolve::{ResolvedIngest, ResolvedSource};
60use super::slice::Slice;
61
62/// The engine-owned state directory for advance stores, under the workspace
63/// store: `<root>/.memstead/state/advance/`.
64const STATE_DIR: &str = "state";
65/// See [`STATE_DIR`].
66const ADVANCE_DIR: &str = "advance";
67
68/// One binding's durable advance state (D7) — the frozen presented slice and
69/// the dispositions accumulated against it. Persisted at
70/// `.memstead/state/advance/<mem>/<name>.json`, read fresh per call.
71///
72/// The frozen slice is the **union** of every slice the engine has presented
73/// for this advance session (the initial freeze plus any new-HEAD deltas
74/// appended as the source moved). Its member ids are exactly the artifact ids
75/// the advance gate accepts.
76#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
77pub struct AdvanceState {
78    /// The canonical binding id `<mem>/<stem>` (D3) this state belongs to.
79    pub binding: String,
80    /// The frozen presented slice (union of freeze + appended new-HEAD deltas).
81    pub frozen_slice: Slice,
82    /// artifact id → agent-supplied disposition, accumulated across calls.
83    pub dispositions: BTreeMap<String, String>,
84    /// The **durable authored-exclusion ledger**: artifact id → the agent's
85    /// rationale for deliberately excluding it (mined, warrants no destination
86    /// entity). Unlike [`Self::dispositions`] and [`Self::frozen_slice`] — the
87    /// transient advance progress dropped on completion — this survives
88    /// completion so the fidelity report consults it under exhaustive coverage:
89    /// an excluded-on-purpose artifact stops re-surfacing as `uncovered` and
90    /// keeps its reasoning. Generic across every binding and medium.
91    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
92    pub exclusions: BTreeMap<String, String>,
93    /// artifact id → the source facet the exclusion was recorded under
94    /// (2026-09-02, basket line 3). An exclusion keys on the artifact and
95    /// its source, never on the binding hash: a re-declared source keeps
96    /// its exclusions across `projection edit`, a removed source drops
97    /// them ([`reconcile_exclusions`]). Entries recorded before the field
98    /// existed are attributed on the next reconcile.
99    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
100    pub exclusion_sources: BTreeMap<String, String>,
101    /// Exclusions the reconcile dropped because their source left the
102    /// declaration — kept so the next sync brief reports them with the
103    /// source named, then cleared once reported.
104    #[serde(default, skip_serializing_if = "Vec::is_empty")]
105    pub dropped_exclusions: Vec<DroppedExclusion>,
106}
107
108/// One authored exclusion dropped by [`reconcile_exclusions`]: the source it
109/// was recorded under is no longer declared on the binding.
110#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
111pub struct DroppedExclusion {
112    pub artifact: String,
113    /// The facet name the exclusion was recorded under; `unattributed` for
114    /// a pre-field entry no declared source enumerates.
115    pub source: String,
116    pub rationale: String,
117    pub dropped_at: String,
118}
119
120/// One authored exclusion in force, as the sync brief lists it.
121#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
122pub struct ActiveExclusion {
123    pub artifact: String,
124    pub source: String,
125    pub rationale: String,
126}
127
128/// The exclusion ledger after reconciliation against the binding as declared
129/// now: what is in force, and what was dropped (reported once).
130#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize)]
131pub struct ExclusionLedger {
132    pub active: Vec<ActiveExclusion>,
133    pub dropped: Vec<DroppedExclusion>,
134}
135
136/// Reconcile a binding's authored exclusions against its declared sources:
137/// an exclusion whose recorded source is still declared stays in force; one
138/// whose source left the declaration is dropped (moved to
139/// `dropped_exclusions`, reported by the next brief and cleared after); a
140/// pre-field entry with no recorded source is attributed to the declared
141/// source that enumerates it, or dropped as `unattributed` when none does.
142/// Nothing here keys on the binding hash, so `projection edit` of any other
143/// field leaves every exclusion untouched. Writes the store only when
144/// something changed; a binding with no store yields an empty ledger.
145pub fn reconcile_exclusions(
146    engine: &Engine,
147    workspace_root: &Path,
148    resolved: &ResolvedIngest,
149) -> Result<ExclusionLedger, StoreError> {
150    let Ok((mem, name)) = split_binding_id(&resolved.name) else {
151        return Ok(ExclusionLedger::default());
152    };
153    let Some(mut state) = read_advance_store(workspace_root, &mem, &name)? else {
154        return Ok(ExclusionLedger::default());
155    };
156    let declared: BTreeSet<String> = resolved
157        .sources
158        .iter()
159        .filter_map(|s| match s {
160            ResolvedSource::Primary(p) => Some(p.name.clone()),
161            ResolvedSource::Reference { .. } => None,
162        })
163        .collect();
164    let mut changed = false;
165    let mut membership: Option<BTreeMap<String, String>> = None;
166    let mut dropped_now: Vec<DroppedExclusion> = Vec::new();
167    let mut active: Vec<ActiveExclusion> = Vec::new();
168    for (artifact, rationale) in state.exclusions.clone() {
169        let recorded = state.exclusion_sources.get(&artifact).cloned();
170        let source = match recorded {
171            Some(s) if declared.contains(&s) => Some(s),
172            Some(s) => {
173                dropped_now.push(DroppedExclusion {
174                    artifact: artifact.clone(),
175                    source: s,
176                    rationale: rationale.clone(),
177                    dropped_at: crate::engine::mutation::iso_now(),
178                });
179                None
180            }
181            None => {
182                let facets = membership.get_or_insert_with(|| {
183                    let mut m = BTreeMap::new();
184                    for s in &resolved.sources {
185                        if let ResolvedSource::Primary(p) = s {
186                            for f in enumerate_source_artifacts(
187                                engine,
188                                p,
189                                &resolved.deny_paths,
190                                workspace_root,
191                            ) {
192                                m.entry(f).or_insert_with(|| p.name.clone());
193                            }
194                        }
195                    }
196                    m
197                });
198                match facets.get(&artifact).cloned() {
199                    Some(f) => {
200                        state.exclusion_sources.insert(artifact.clone(), f.clone());
201                        changed = true;
202                        Some(f)
203                    }
204                    None => {
205                        dropped_now.push(DroppedExclusion {
206                            artifact: artifact.clone(),
207                            source: "unattributed".to_string(),
208                            rationale: rationale.clone(),
209                            dropped_at: crate::engine::mutation::iso_now(),
210                        });
211                        None
212                    }
213                }
214            }
215        };
216        match source {
217            Some(source) => active.push(ActiveExclusion {
218                artifact,
219                source,
220                rationale,
221            }),
222            None => {
223                state.exclusions.remove(&artifact);
224                state.exclusion_sources.remove(&artifact);
225                changed = true;
226            }
227        }
228    }
229    // Report what was dropped now plus what an earlier reconcile dropped and
230    // no brief has reported yet; the report clears the record.
231    let mut dropped = std::mem::take(&mut state.dropped_exclusions);
232    if !dropped.is_empty() {
233        changed = true;
234    }
235    dropped.extend(dropped_now);
236    if changed {
237        if state.exclusions.is_empty()
238            && state.frozen_slice == Slice::default()
239            && state.dispositions.is_empty()
240        {
241            delete_advance_store(workspace_root, &mem, &name)?;
242        } else {
243            write_advance_store(workspace_root, &mem, &name, &state)?;
244        }
245    }
246    Ok(ExclusionLedger { active, dropped })
247}
248
249/// The verdict marking an artifact **deliberately excluded** from coverage —
250/// mined, warrants no destination entity. When supplied with a rationale (the
251/// [`DispositionInput::Reasoned`] form) it lands in the durable authored
252/// exclusion ledger ([`AdvanceState::exclusions`]) and persists past advance
253/// completion; any other verdict clears a prior exclusion for that artifact.
254pub const EXCLUDED_VERDICT: &str = "excluded";
255
256/// An agent-supplied disposition for one artifact: either a bare verdict
257/// (`"worked"`, `"skipped"`, …) or a verdict carrying an authored rationale.
258///
259/// The rationale-bearing form exists for the durable authored-exclusion record
260/// the option-(a) design names — `(artifact, disposition = "excluded",
261/// rationale)`. It is generic: any verdict may carry reasoning, but only the
262/// [`EXCLUDED_VERDICT`] one is retained past completion (an excluded artifact
263/// has no anchor, so under exhaustive coverage it would otherwise re-surface as
264/// `uncovered` on every subsequent verify). Serde is `untagged` so the common
265/// `"worked"` form and the `{"disposition": "...", "rationale": "..."}` form
266/// both parse from the same `--dispositions` payload.
267#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
268#[serde(untagged)]
269pub enum DispositionInput {
270    /// A bare verdict string, e.g. `"worked"`.
271    Verdict(String),
272    /// A verdict with an authored rationale.
273    Reasoned {
274        /// The verdict proper (e.g. `"excluded"`).
275        disposition: String,
276        /// The agent's reasoning for this disposition.
277        rationale: String,
278    },
279}
280
281impl DispositionInput {
282    /// The verdict string (the disposition proper).
283    pub fn verdict(&self) -> &str {
284        match self {
285            DispositionInput::Verdict(v) => v,
286            DispositionInput::Reasoned { disposition, .. } => disposition,
287        }
288    }
289
290    /// The authored rationale, if the reasoned form was supplied.
291    pub fn rationale(&self) -> Option<&str> {
292        match self {
293            DispositionInput::Verdict(_) => None,
294            DispositionInput::Reasoned { rationale, .. } => Some(rationale),
295        }
296    }
297}
298
299impl AdvanceState {
300    /// Count of accumulated dispositions — the `disposed` figure `memstead
301    /// status` reports for this binding (D11).
302    pub fn disposed(&self) -> usize {
303        self.dispositions.len()
304    }
305
306    /// Count of frozen-slice artifacts not yet disposed — the `pending`
307    /// remainder `memstead status` reports (D11). Same subtraction the
308    /// re-presentation applies ([`subtract_disposed`]), collapsed to a count.
309    pub fn pending(&self) -> usize {
310        artifact_set(&self.frozen_slice)
311            .iter()
312            .filter(|a| !self.dispositions.contains_key(a.as_str()))
313            .count()
314    }
315}
316
317/// The outcome of an [`advance_baseline`] call.
318#[derive(Debug, Clone, PartialEq, Eq)]
319pub struct AdvanceOutcome {
320    /// The binding id advanced.
321    pub binding: String,
322    /// The re-presented remainder — the frozen slice with every disposed
323    /// artifact removed (disposed artifacts absent, D7). Empty when complete.
324    pub remainder: Slice,
325    /// Total dispositions accumulated (this call + prior, persisted).
326    pub disposed: usize,
327    /// Remaining (undisposed) artifact count — `remainder`'s total size.
328    pub pending: usize,
329    /// True when the remainder emptied this call: the `#synced` token(s)
330    /// advanced through the engine writer and the durable store was dropped.
331    pub completed: bool,
332    /// The `sync_state` keys whose baseline token advanced on completion
333    /// (empty on a non-completing call, or when the source had not moved).
334    pub tokens_written: Vec<String>,
335    /// Warnings surfaced by the underlying `set_mem_sync_state` writes (e.g.
336    /// `MEM_RELOADED` drift notices), rendered to strings.
337    pub warnings: Vec<String>,
338}
339
340/// Why [`advance_baseline`] could not complete.
341#[derive(Debug, thiserror::Error)]
342pub enum AdvanceError {
343    /// The binding id is not the canonical `<mem>/<stem>` shape.
344    #[error("malformed binding id '{0}': expected `<mem>/<stem>`")]
345    MalformedId(String),
346    /// One or more disposition ids were never presented by the engine — the
347    /// gate refuses the whole call (no partial write). Names each offending
348    /// id, states the expected id dialect (workspace-relative, exactly as the
349    /// slice printed), and — when prefixing a supplied id with its medium
350    /// root yields an id that IS in the presented slice — carries the
351    /// concrete corrected id. The medium-relative form is never accepted:
352    /// one id dialect holds across enumeration, anchors, coverage, and
353    /// advance.
354    #[error(
355        "disposition names {} artifact id(s) the engine did not present: {}; the advance gate \
356         accepts only ids from the presented slice, verbatim in their workspace-relative form \
357         ({printed} presented){}",
358        artifacts.len(),
359        fmt_list(artifacts),
360        fmt_suggestions(suggestions)
361    )]
362    UnknownArtifact {
363        /// The offending, never-presented ids (sorted).
364        artifacts: Vec<String>,
365        /// How many ids the engine did present (the accepted set size).
366        printed: usize,
367        /// `(supplied, corrected)` pairs for supplied ids that look
368        /// medium-relative: prefixing the binding's medium root yields an id
369        /// the slice DID present. The remedy — never an acceptance.
370        suggestions: Vec<(String, String)>,
371    },
372    /// Reading or writing the durable advance store failed.
373    #[error("advance store error: {0}")]
374    Store(#[source] StoreError),
375    /// The `set_mem_sync_state` baseline write failed on completion.
376    #[error("could not advance baseline token: {0}")]
377    Engine(String),
378}
379
380/// Render an id list for an error message: `a, b, c` or `(none)`.
381fn fmt_list(names: &[String]) -> String {
382    if names.is_empty() {
383        "(none)".to_string()
384    } else {
385        names.join(", ")
386    }
387}
388
389/// Render the medium-relative-dialect remedy for an unknown-artifact refusal:
390/// empty when no correction is derivable, else a `supplied → corrected` list
391/// telling the agent the exact ids to retry with.
392fn fmt_suggestions(suggestions: &[(String, String)]) -> String {
393    if suggestions.is_empty() {
394        return String::new();
395    }
396    let pairs = suggestions
397        .iter()
398        .map(|(supplied, corrected)| format!("`{supplied}` → `{corrected}`"))
399        .collect::<Vec<_>>()
400        .join(", ");
401    format!(
402        ". Some supplied ids look medium-relative; the slice presents them workspace-relative — \
403         retry with {pairs} (the medium-relative form is never accepted)"
404    )
405}
406
407/// For each unknown disposition id, derive the corrected workspace-relative id
408/// when possible: prefix the id with a primary source's medium root and accept
409/// the candidate iff it is in the presented set (`printed`). Purely a remedy
410/// computation — it never widens the gate.
411fn derive_corrected_ids(
412    unknown: &[String],
413    resolved: &ResolvedIngest,
414    printed: &BTreeSet<String>,
415) -> Vec<(String, String)> {
416    let medium_roots: Vec<&str> = resolved
417        .sources
418        .iter()
419        .filter_map(|s| match s {
420            ResolvedSource::Primary(p) if !p.pointer.is_empty() => Some(p.pointer.as_str()),
421            _ => None,
422        })
423        .collect();
424    unknown
425        .iter()
426        .filter_map(|id| {
427            medium_roots.iter().find_map(|root| {
428                let candidate = format!("{}/{id}", root.trim_end_matches('/'));
429                printed
430                    .contains(candidate.as_str())
431                    .then(|| (id.clone(), candidate))
432            })
433        })
434        .collect()
435}
436
437/// Split a canonical binding id `<mem>/<stem>` into its two single-component
438/// halves, or refuse. Mirrors the store's component guard so a caller-supplied
439/// id can never escape the `.memstead/state/advance/` tier.
440fn split_binding_id(binding_id: &str) -> Result<(String, String), AdvanceError> {
441    binding_id
442        .split_once('/')
443        .filter(|(m, n)| is_single_component(m) && is_single_component(n))
444        .map(|(m, n)| (m.to_string(), n.to_string()))
445        .ok_or_else(|| AdvanceError::MalformedId(binding_id.to_string()))
446}
447
448/// Is `value` a single, plain path component — safe as a `<mem>` / `<name>`
449/// directory or file segment? (No separators, traversal segments, drive/stream
450/// colon, or NUL.) Shared with the findings store's identical path guard.
451pub(crate) fn is_single_component(value: &str) -> bool {
452    !value.is_empty()
453        && value != "."
454        && value != ".."
455        && !value.contains('/')
456        && !value.contains('\\')
457        && !value.contains(':')
458        && !value.contains('\0')
459}
460
461/// The durable store path for a binding: `.memstead/state/advance/<mem>/<name>.json`.
462pub fn advance_store_path(workspace_root: &Path, mem: &str, name: &str) -> PathBuf {
463    workspace_root
464        .join(WORKSPACE_STORE_DIR)
465        .join(STATE_DIR)
466        .join(ADVANCE_DIR)
467        .join(mem)
468        .join(format!("{name}.json"))
469}
470
471/// Read the durable advance state for a binding, or `None` when none exists
472/// (never advanced, or completed and dropped). A malformed file surfaces a
473/// typed [`StoreError::Parse`] naming the path.
474pub fn read_advance_store(
475    workspace_root: &Path,
476    mem: &str,
477    name: &str,
478) -> Result<Option<AdvanceState>, StoreError> {
479    let path = advance_store_path(workspace_root, mem, name);
480    match std::fs::read(&path) {
481        Ok(bytes) => serde_json::from_slice(&bytes)
482            .map(Some)
483            .map_err(|e| StoreError::Parse {
484                path,
485                message: e.to_string(),
486            }),
487        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
488        Err(e) => Err(StoreError::Io { path, source: e }),
489    }
490}
491
492/// Persist the durable advance state for a binding (pretty JSON), creating
493/// parent directories.
494pub fn write_advance_store(
495    workspace_root: &Path,
496    mem: &str,
497    name: &str,
498    state: &AdvanceState,
499) -> Result<(), StoreError> {
500    // Self-ignoring subtree: this store is per-checkout engine state
501    // inside a possibly-tracked workspace (see the findings twin).
502    super::findings::ensure_selfignoring_store_dir(
503        &workspace_root
504            .join(WORKSPACE_STORE_DIR)
505            .join(STATE_DIR)
506            .join(ADVANCE_DIR),
507    )?;
508    let path = advance_store_path(workspace_root, mem, name);
509    if let Some(parent) = path.parent() {
510        std::fs::create_dir_all(parent).map_err(|e| StoreError::Io {
511            path: parent.to_path_buf(),
512            source: e,
513        })?;
514    }
515    let bytes = serde_json::to_vec_pretty(state).map_err(|e| StoreError::Parse {
516        path: path.clone(),
517        message: e.to_string(),
518    })?;
519    std::fs::write(&path, bytes).map_err(|e| StoreError::Io { path, source: e })
520}
521
522/// Drop the durable advance store for a binding (called on completion). A
523/// missing file is a successful no-op — completion is idempotent.
524pub fn delete_advance_store(
525    workspace_root: &Path,
526    mem: &str,
527    name: &str,
528) -> Result<(), StoreError> {
529    let path = advance_store_path(workspace_root, mem, name);
530    match std::fs::remove_file(&path) {
531        Ok(()) => Ok(()),
532        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
533        Err(e) => Err(StoreError::Io { path, source: e }),
534    }
535}
536
537/// Union `from` into `into`, keeping each class sorted + de-duplicated.
538fn union_slice(into: &mut Slice, from: &Slice) {
539    into.added.extend(from.added.iter().cloned());
540    into.modified.extend(from.modified.iter().cloned());
541    into.deleted.extend(from.deleted.iter().cloned());
542    for v in [&mut into.added, &mut into.modified, &mut into.deleted] {
543        v.sort();
544        v.dedup();
545    }
546}
547
548/// The full set of artifact ids a slice presents (across all three classes) —
549/// the accepted set for the advance gate.
550fn artifact_set(slice: &Slice) -> BTreeSet<String> {
551    slice
552        .added
553        .iter()
554        .chain(slice.modified.iter())
555        .chain(slice.deleted.iter())
556        .cloned()
557        .collect()
558}
559
560/// The remainder slice: the frozen slice with every disposed id removed from
561/// each class (disposed artifacts absent, D7).
562fn subtract_disposed(frozen: &Slice, dispositions: &BTreeMap<String, String>) -> Slice {
563    let keep = |v: &[String]| -> Vec<String> {
564        v.iter()
565            .filter(|a| !dispositions.contains_key(*a))
566            .cloned()
567            .collect()
568    };
569    Slice {
570        added: keep(&frozen.added),
571        modified: keep(&frozen.modified),
572        deleted: keep(&frozen.deleted),
573    }
574}
575
576/// The disposition-gated baseline advance (D7).
577///
578/// Freezes the currently-presented slice (or reloads a frozen one), appends any
579/// new-HEAD deltas, gates the supplied dispositions against the presented ids
580/// (atomic — an unknown id refuses before any write), accumulates them, and
581/// re-presents the remainder with disposed artifacts absent. When the remainder
582/// empties, the destination mem's `#synced` baseline token(s) advance through
583/// the engine's [`Engine::set_mem_sync_state`] writer — the provenance
584/// piggybacks that write's commit note, adding no new channel — and the durable
585/// store is dropped.
586///
587/// `resolved.name` must be the canonical binding id `<mem>/<stem>` (D3), as
588/// produced by [`super::resolve::resolve_binding_run`]; `dispositions` maps each
589/// judged artifact id to an agent-supplied [`DispositionInput`] — a bare verdict
590/// or a verdict with an authored rationale (in E2 the agent supplies one for
591/// **every** artifact — see the module docs). An `excluded` verdict with a
592/// rationale is recorded in the durable authored-exclusion ledger; any other
593/// verdict clears a prior exclusion for that artifact.
594pub fn advance_baseline(
595    engine: &mut Engine,
596    workspace_root: &Path,
597    resolved: &ResolvedIngest,
598    dispositions: &BTreeMap<String, DispositionInput>,
599) -> Result<AdvanceOutcome, AdvanceError> {
600    let binding_id = resolved.name.clone();
601    let (mem, name) = split_binding_id(&binding_id)?;
602
603    // Current source cursor (immutable borrow ends before the mutating writes).
604    // Its union is the slice relative to the *unchanged* `#synced` baseline, so
605    // when the source moves mid-pass this already reflects freeze + new deltas.
606    let cursor = compute_source_cursor(engine, resolved, workspace_root);
607
608    // Load-or-init the durable store (resumability is on-disk, not in-memory).
609    let mut state = read_advance_store(workspace_root, &mem, &name)
610        .map_err(AdvanceError::Store)?
611        .unwrap_or_else(|| AdvanceState {
612            binding: binding_id.clone(),
613            ..Default::default()
614        });
615
616    // Freeze / append: union the currently-presented slice into the frozen one.
617    union_slice(&mut state.frozen_slice, &cursor.union);
618    let printed = artifact_set(&state.frozen_slice);
619
620    // Gate (atomic): every disposition id must be one the engine presented.
621    // Validate BEFORE any disk write so a refusal leaves the store untouched.
622    let mut unknown: Vec<String> = dispositions
623        .keys()
624        .filter(|a| !printed.contains(a.as_str()))
625        .cloned()
626        .collect();
627    if !unknown.is_empty() {
628        unknown.sort();
629        unknown.dedup();
630        // Remedy, not acceptance: when a supplied id resolves to a presented
631        // one once prefixed with its medium root (the medium-relative-dialect
632        // mistake agents naturally make), the refusal carries the corrected
633        // id — the gate itself never widens.
634        let suggestions = derive_corrected_ids(&unknown, resolved, &printed);
635        return Err(AdvanceError::UnknownArtifact {
636            artifacts: unknown,
637            printed: printed.len(),
638            suggestions,
639        });
640    }
641
642    // Accumulate the new (agent-supplied) dispositions. An `excluded` verdict
643    // with a rationale lands in the durable exclusion ledger (survives
644    // completion); any other verdict clears a prior exclusion for that artifact
645    // (a re-judged artifact must not keep stale "excluded" reasoning).
646    for (artifact, input) in dispositions {
647        state
648            .dispositions
649            .insert(artifact.clone(), input.verdict().to_string());
650        if input.verdict() == EXCLUDED_VERDICT {
651            state.exclusions.insert(
652                artifact.clone(),
653                input.rationale().unwrap_or("").to_string(),
654            );
655        } else {
656            state.exclusions.remove(artifact);
657            state.exclusion_sources.remove(artifact);
658        }
659    }
660
661    // Auto-`worked` (E3a): mark every frozen-slice artifact that an anchor in
662    // the destination mem now references. Reads the anchors sidecar, never a
663    // commit diff (D7's rejected mechanism stays rejected); scoped to the
664    // frozen slice (`printed`) so an anchored write outside the slice
665    // fabricates no entry; skips artifacts already carrying an explicit
666    // disposition (the agent's judgement wins).
667    let auto_worked: Vec<String> = printed
668        .iter()
669        .filter(|art| !state.dispositions.contains_key(art.as_str()))
670        .filter(|art| {
671            // A unit id (`<path>#<key>`, touchpoint B) is disposed by an
672            // anchor over exactly that unit; a file id by any anchor
673            // referencing the path. A file-level anchor never disposes a
674            // unit — reading the file is not reading every unit of it.
675            let (base, key) = crate::preparation::split_unit_id(art);
676            engine
677                .anchors_referencing_artifact(base)
678                .iter()
679                .any(|(eid, a)| {
680                    eid.mem() == resolved.destination_mem.as_str()
681                        && (key.is_none() || a.artifact == **art)
682                })
683        })
684        .cloned()
685        .collect();
686    for art in auto_worked {
687        state.dispositions.insert(art, "worked".to_string());
688    }
689
690    // Re-present the remainder (disposed absent).
691    let remainder = subtract_disposed(&state.frozen_slice, &state.dispositions);
692    let pending = remainder.added.len() + remainder.modified.len() + remainder.deleted.len();
693    let completed = pending == 0;
694
695    let mut warnings: Vec<String> = Vec::new();
696    let mut tokens_written: Vec<String> = Vec::new();
697    if completed {
698        // Advance the baseline token for every facet that moved (current cursor
699        // tokens = the latest HEAD) via the engine writer. Provenance piggybacks
700        // the write's commit note — no new channel (D7).
701        let note = format!(
702            "projection advance {binding_id}: {} artifact(s) disposed, baseline advanced",
703            state.dispositions.len()
704        );
705        for c in cursor.write_commands.iter().chain(cursor.reseed.iter()) {
706            let outcome = engine
707                .set_mem_sync_state(&resolved.destination_mem, &c.key, &c.token, Some(&note))
708                .map_err(|e| AdvanceError::Engine(e.to_string()))?;
709            warnings.extend(outcome.warnings.iter().map(ToString::to_string));
710            tokens_written.push(c.key.clone());
711        }
712        // Transient progress (frozen slice + per-run dispositions) is consumed.
713        // If any durable authored exclusions accumulated, retain a slimmed store
714        // holding only them (empty slice, no transient dispositions) so the
715        // fidelity report keeps consulting them; otherwise drop the store
716        // entirely (completion idempotent — the no-exclusion path is unchanged).
717        if state.exclusions.is_empty() {
718            delete_advance_store(workspace_root, &mem, &name).map_err(AdvanceError::Store)?;
719        } else {
720            let durable = AdvanceState {
721                binding: binding_id.clone(),
722                frozen_slice: Slice::default(),
723                dispositions: BTreeMap::new(),
724                exclusions: state.exclusions.clone(),
725                exclusion_sources: state.exclusion_sources.clone(),
726                dropped_exclusions: state.dropped_exclusions.clone(),
727            };
728            write_advance_store(workspace_root, &mem, &name, &durable)
729                .map_err(AdvanceError::Store)?;
730        }
731    } else {
732        // Persist the accumulated frozen slice + dispositions for resumability.
733        write_advance_store(workspace_root, &mem, &name, &state).map_err(AdvanceError::Store)?;
734    }
735
736    Ok(AdvanceOutcome {
737        binding: binding_id,
738        remainder,
739        disposed: state.dispositions.len(),
740        pending,
741        completed,
742        tokens_written,
743        warnings,
744    })
745}
746
747/// The outcome of a [`record_exclusions`] call.
748#[derive(Debug, Clone, PartialEq, Eq)]
749pub struct ExcludeOutcome {
750    /// The canonical (workspace-relative) id each requested id resolved to,
751    /// in request order: what the ledger now holds, so an agent sees the
752    /// spelling that took effect when it passed the source-relative form.
753    pub recorded: Vec<(String, String)>,
754    /// The binding id whose exclusion ledger was written.
755    pub binding: String,
756    /// Total authored exclusions in the ledger after this call (this call + prior).
757    pub excluded: usize,
758    /// How many supplied artifacts were newly added (not already in the ledger).
759    pub added: usize,
760}
761
762/// Why [`record_exclusions`] could not complete.
763#[derive(Debug, thiserror::Error)]
764pub enum ExcludeError {
765    /// The binding id is not the canonical `<mem>/<stem>` shape.
766    #[error("malformed binding id '{0}': expected `<mem>/<stem>`")]
767    MalformedId(String),
768    /// One or more artifacts are not members of the binding's enumerable source
769    /// `S(D)` — the gate refuses the whole call (no partial write). Names each.
770    #[error(
771        "exclusion names {} artifact id(s) not in the binding's enumerable source S(D): {}; \
772         only an in-scope source member can be declared excluded ({printed} enumerated)",
773        artifacts.len(),
774        fmt_list(artifacts)
775    )]
776    NotSourceMember {
777        /// The offending, non-member ids (sorted).
778        artifacts: Vec<String>,
779        /// How many artifacts `S(D)` did enumerate (the accepted set size).
780        printed: usize,
781        /// For each offending id, the nearest known ids of `S(D)` (by
782        /// shared path suffix, then name similarity), so the agent can
783        /// repair the spelling instead of guessing.
784        nearest: BTreeMap<String, Vec<String>>,
785    },
786    /// The enumeration of `S(D)` is known-incomplete (a malformed or
787    /// retired-dialect scope pattern), so membership cannot be decided: the
788    /// gate would refuse genuinely in-scope artifacts and state the short
789    /// count as if it were the population. Refused whole, nothing written.
790    #[error(
791        "the binding's source enumeration is incomplete — {reason} — so `S(D)` membership \
792         cannot be decided; fix the named scope pattern(s), then re-declare the exclusions"
793    )]
794    PartialEnumeration {
795        /// The facet whose enumeration is partial.
796        facet: String,
797        /// Why the enumeration is incomplete, naming the offending patterns.
798        reason: String,
799    },
800    /// A source-relative id resolves under MORE THAN ONE of the binding's
801    /// primary sources, so recording it would pick a source the caller never
802    /// named. Refused whole, nothing written; the canonical ids are the
803    /// recovery, and either one is unambiguous.
804    #[error(
805        "exclusion id {} is ambiguous: it resolves under {} of the binding's sources, as {}; \
806         re-declare it with one of those workspace-relative ids",
807        fmt_list(&ambiguous.keys().cloned().collect::<Vec<_>>()),
808        ambiguous.values().map(|c| c.len()).max().unwrap_or(0),
809        fmt_list(&ambiguous.values().flatten().cloned().collect::<Vec<_>>())
810    )]
811    AmbiguousArtifact {
812        /// For each ambiguous requested id, every canonical id it resolves
813        /// to, sorted. The caller re-declares with one of them.
814        ambiguous: BTreeMap<String, Vec<String>>,
815    },
816    /// Reading or writing the durable advance store failed.
817    #[error("advance store error: {0}")]
818    Store(#[source] StoreError),
819}
820
821/// Declare **authored exclusions** for in-scope source artifacts — the direct
822/// write path for the durable exclusion ledger [`advance_baseline`] also feeds.
823///
824/// Unlike the advance gate (which accepts only artifacts in the *changed slice*),
825/// this gates on **enumerable `S(D)` membership**: an artifact must be a real
826/// in-scope member of the binding's source, and a *stable, unchanged* artifact
827/// qualifies. That is what a deliberate editorial exclusion is — "this in-scope
828/// artifact is mined and warrants no destination entity, because …" — a decision
829/// independent of change detection. Each accepted `(artifact, rationale)` lands
830/// in the ledger the fidelity report consults, so the artifact stops re-surfacing
831/// as `uncovered` under exhaustive coverage and keeps its reasoning. Atomic: an
832/// artifact outside `S(D)` refuses the whole call before any write. Merges into
833/// any in-flight advance store rather than clobbering it. Generic across every
834/// enumerable binding and medium.
835pub fn record_exclusions(
836    engine: &Engine,
837    workspace_root: &Path,
838    resolved: &ResolvedIngest,
839    exclusions: &BTreeMap<String, String>,
840) -> Result<ExcludeOutcome, ExcludeError> {
841    let binding_id = resolved.name.clone();
842    let (mem, name) =
843        split_binding_id(&binding_id).map_err(|_| ExcludeError::MalformedId(binding_id.clone()))?;
844
845    // Enumerate S(D) — the in-scope source-artifact set, the same enumeration the
846    // fidelity report uses for its coverage denominator. The REPORTED form: a
847    // partial enumeration (a malformed or retired-dialect scope pattern) is not
848    // the population, so deciding membership over it would refuse genuinely
849    // in-scope artifacts and state the short count as if it were `S(D)` —
850    // refuse the call instead, naming the cause.
851    let mut s_d: BTreeSet<String> = BTreeSet::new();
852    // artifact → the facet it enumerates under (the first, in declaration
853    // order): the source half of the exclusion's identity.
854    let mut facet_of: BTreeMap<String, String> = BTreeMap::new();
855    for source in &resolved.sources {
856        if let ResolvedSource::Primary(p) = source {
857            let walked = enumerate_source_artifacts_reported(
858                engine,
859                p,
860                &resolved.deny_paths,
861                workspace_root,
862            );
863            if let Some(reason) = walked.partiality_reason() {
864                return Err(ExcludeError::PartialEnumeration {
865                    facet: p.name.clone(),
866                    reason,
867                });
868            }
869            for f in &walked.files {
870                facet_of.entry(f.clone()).or_insert_with(|| p.name.clone());
871            }
872            s_d.extend(walked.files);
873        }
874    }
875
876    // Gate (atomic): every exclusion id must be an S(D) member. Validate BEFORE
877    // any disk write so a refusal leaves the store untouched.
878    // Resolve each requested id to the canonical (workspace-relative) form
879    // `S(D)` is keyed by: the canonical form itself, or the source-relative
880    // form joined onto a primary source's medium base, the way the anchor
881    // write gate resolves an artifact path (backlog-decisions plan B11).
882    // Stored ids are always canonical, so a ledger written before this
883    // resolution keeps working unchanged.
884    let bases: Vec<PathBuf> = resolved
885        .sources
886        .iter()
887        .filter_map(|s| match s {
888            ResolvedSource::Primary(p) => Some(medium_base(&p.pointer, workspace_root)),
889            ResolvedSource::Reference { .. } => None,
890        })
891        .collect();
892    let mut canonical: BTreeMap<String, String> = BTreeMap::new();
893    let mut not_member: Vec<String> = Vec::new();
894    let mut ambiguous: BTreeMap<String, Vec<String>> = BTreeMap::new();
895    for requested in exclusions.keys() {
896        if s_d.contains(requested.as_str()) {
897            canonical.insert(requested.clone(), requested.clone());
898            continue;
899        }
900        // Every source that resolves it, not the first (C8). Taking the first
901        // meant the source listed earliest in the binding silently won, which
902        // records an exclusion against an artifact the caller never named.
903        // The cross-source rule itself lives beside the within-source one in
904        // `engine::query`; the membership predicate stays here, because what
905        // counts as resolved is this surface's business.
906        match crate::engine::query::resolve_across_sources(bases.iter(), requested, |base, id| {
907            let candidate = relative_path(workspace_root, &normalize_lexical(&base.join(id)))
908                .to_string_lossy()
909                .to_string();
910            s_d.contains(candidate.as_str()).then_some(candidate)
911        }) {
912            crate::engine::query::CrossSourceArtifact::Unique(c) => {
913                canonical.insert(requested.clone(), c);
914            }
915            crate::engine::query::CrossSourceArtifact::Ambiguous(cands) => {
916                ambiguous.insert(requested.clone(), cands);
917            }
918            crate::engine::query::CrossSourceArtifact::Unresolved => {
919                not_member.push(requested.clone())
920            }
921        }
922    }
923    // Ambiguity before non-membership: an id that resolves several ways is a
924    // spelling the caller must narrow, not one they got wrong.
925    if !ambiguous.is_empty() {
926        return Err(ExcludeError::AmbiguousArtifact { ambiguous });
927    }
928    if !not_member.is_empty() {
929        not_member.sort();
930        not_member.dedup();
931        let nearest = not_member
932            .iter()
933            .map(|id| (id.clone(), nearest_known_ids(id, &s_d)))
934            .collect();
935        return Err(ExcludeError::NotSourceMember {
936            artifacts: not_member,
937            printed: s_d.len(),
938            nearest,
939        });
940    }
941
942    // Merge into the durable exclusion ledger, preserving any in-flight advance
943    // progress already in the same store.
944    let mut state = read_advance_store(workspace_root, &mem, &name)
945        .map_err(ExcludeError::Store)?
946        .unwrap_or_else(|| AdvanceState {
947            binding: binding_id.clone(),
948            ..Default::default()
949        });
950    let mut added = 0usize;
951    let mut recorded: Vec<(String, String)> = Vec::new();
952    for (requested, rationale) in exclusions {
953        let artifact = &canonical[requested];
954        if state
955            .exclusions
956            .insert(artifact.clone(), rationale.clone())
957            .is_none()
958        {
959            added += 1;
960        }
961        if let Some(facet) = facet_of.get(artifact) {
962            state
963                .exclusion_sources
964                .insert(artifact.clone(), facet.clone());
965        }
966        recorded.push((requested.clone(), artifact.clone()));
967    }
968    write_advance_store(workspace_root, &mem, &name, &state).map_err(ExcludeError::Store)?;
969
970    Ok(ExcludeOutcome {
971        recorded,
972        binding: binding_id,
973        excluded: state.exclusions.len(),
974        added,
975    })
976}
977
978/// The known ids of `S(D)` nearest to an unknown one: those sharing its
979/// file name first, then those sharing its last path components, at most
980/// five, sorted. A repair hint, never a match.
981fn nearest_known_ids(unknown: &str, known: &BTreeSet<String>) -> Vec<String> {
982    let name = unknown.rsplit('/').next().unwrap_or(unknown);
983    let tail: Vec<&str> = unknown.rsplit('/').take(2).collect();
984    let mut scored: Vec<(usize, &String)> = known
985        .iter()
986        .filter_map(|k| {
987            let kname = k.rsplit('/').next().unwrap_or(k);
988            let ktail: Vec<&str> = k.rsplit('/').take(2).collect();
989            let same_dir = tail.len() > 1 && ktail.get(1) == tail.get(1);
990            let score = if kname == name && same_dir {
991                3
992            } else if kname == name {
993                2
994            } else if same_dir || k.contains(name) || name.contains(kname) {
995                1
996            } else {
997                0
998            };
999            (score > 0).then_some((score, k))
1000        })
1001        .collect();
1002    scored.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(b.1)));
1003    scored.into_iter().take(5).map(|(_, k)| k.clone()).collect()
1004}
1005
1006#[cfg(test)]
1007mod tests {
1008    use super::*;
1009    use crate::binding::BuildMode;
1010    use crate::pipeline::{IngestTrigger, MediumType, PatternEntry, PatternMode};
1011    use crate::storage::FilesystemMemWriter;
1012    use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
1013    use tempfile::TempDir;
1014
1015    // ── pure helpers ─────────────────────────────────────────────────────
1016
1017    fn slice(added: &[&str], modified: &[&str], deleted: &[&str]) -> Slice {
1018        Slice {
1019            added: added.iter().map(|s| s.to_string()).collect(),
1020            modified: modified.iter().map(|s| s.to_string()).collect(),
1021            deleted: deleted.iter().map(|s| s.to_string()).collect(),
1022        }
1023    }
1024
1025    fn disp(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
1026        pairs
1027            .iter()
1028            .map(|(a, d)| (a.to_string(), d.to_string()))
1029            .collect()
1030    }
1031
1032    /// The [`DispositionInput`] map an `advance_baseline` call takes: bare
1033    /// verdicts (the common form).
1034    fn input(pairs: &[(&str, &str)]) -> BTreeMap<String, DispositionInput> {
1035        pairs
1036            .iter()
1037            .map(|(a, d)| (a.to_string(), DispositionInput::Verdict(d.to_string())))
1038            .collect()
1039    }
1040
1041    /// The store round-trips and `delete` is idempotent.
1042    #[test]
1043    fn advance_store_round_trips_and_delete_is_idempotent() {
1044        let tmp = TempDir::new().unwrap();
1045        let root = tmp.path();
1046        assert!(
1047            read_advance_store(root, "engine", "graph")
1048                .unwrap()
1049                .is_none()
1050        );
1051
1052        let state = AdvanceState {
1053            binding: "engine/graph".to_string(),
1054            frozen_slice: slice(&["c.rs"], &["a.rs"], &["b.rs"]),
1055            dispositions: disp(&[("a.rs", "worked")]),
1056            exclusions: BTreeMap::new(),
1057            ..Default::default()
1058        };
1059        write_advance_store(root, "engine", "graph", &state).unwrap();
1060        assert!(
1061            advance_store_path(root, "engine", "graph")
1062                .ends_with("state/advance/engine/graph.json")
1063        );
1064        let back = read_advance_store(root, "engine", "graph")
1065            .unwrap()
1066            .unwrap();
1067        assert_eq!(back, state);
1068
1069        delete_advance_store(root, "engine", "graph").unwrap();
1070        assert!(
1071            read_advance_store(root, "engine", "graph")
1072                .unwrap()
1073                .is_none()
1074        );
1075        // Idempotent: deleting an absent store is a no-op, not an error.
1076        delete_advance_store(root, "engine", "graph").unwrap();
1077    }
1078
1079    /// `subtract_disposed` removes disposed ids from every class.
1080    #[test]
1081    fn subtract_disposed_removes_disposed_from_every_class() {
1082        let frozen = slice(&["c.rs"], &["a.rs"], &["b.rs"]);
1083        let out = subtract_disposed(&frozen, &disp(&[("a.rs", "worked"), ("c.rs", "skipped")]));
1084        assert_eq!(out, slice(&[], &[], &["b.rs"]));
1085    }
1086
1087    // ── AC9 — full engine advance over a moving HEAD ─────────────────────
1088
1089    fn git(repo: &Path, args: &[&str]) {
1090        let out = std::process::Command::new("git")
1091            .args(args)
1092            .current_dir(repo)
1093            .env("GIT_AUTHOR_NAME", "t")
1094            .env("GIT_AUTHOR_EMAIL", "t@t")
1095            .env("GIT_COMMITTER_NAME", "t")
1096            .env("GIT_COMMITTER_EMAIL", "t@t")
1097            .output()
1098            .unwrap();
1099        assert!(
1100            out.status.success(),
1101            "git {args:?}: {}",
1102            String::from_utf8_lossy(&out.stderr)
1103        );
1104    }
1105
1106    fn head_sha(repo: &Path) -> String {
1107        String::from_utf8(
1108            std::process::Command::new("git")
1109                .args(["rev-parse", "HEAD"])
1110                .current_dir(repo)
1111                .output()
1112                .unwrap()
1113                .stdout,
1114        )
1115        .unwrap()
1116        .trim()
1117        .to_string()
1118    }
1119
1120    /// A discovery-mode resolved binding whose one primary source is a git
1121    /// codebase rooted at the workspace root (medium pointer `""`), scoped to
1122    /// `**/*.rs`, keyed `engine/graph` → dest mem `engine`.
1123    fn resolved_engine_graph() -> ResolvedIngest {
1124        use super::super::resolve::{ResolvedSource, Source};
1125        ResolvedIngest {
1126            name: "engine/graph".to_string(),
1127            mode: BuildMode::Discovery,
1128            trigger: IngestTrigger::Loop,
1129            batch_size: 20,
1130            deny_paths: vec![],
1131            projection_ref: "engine/graph".to_string(),
1132            projection_mem: "engine".to_string(),
1133            projection_name: "graph".to_string(),
1134            intent: None,
1135            sources: vec![ResolvedSource::Primary(Source {
1136                name: "source-tree".to_string(),
1137                medium_type: MediumType::Codebase,
1138                pointer: String::new(),
1139                change_detection: Some("git".to_string()),
1140                scope: vec![PatternEntry {
1141                    path: "**/*.rs".to_string(),
1142                    mode: PatternMode::Allow,
1143                }],
1144                engagement: None,
1145                preparation: None,
1146            })],
1147            destination_mem: "engine".to_string(),
1148            rules: None,
1149            post_actions: None,
1150        }
1151    }
1152
1153    /// Build an engine over one writable folder mem `engine` rooted at `root`
1154    /// (which is also the git source tree), with a `.memstead/config.json` so
1155    /// `sync_state` can be read/written.
1156    fn engine_at(root: &Path) -> Engine {
1157        // Seed the mem config **once** — a later rebuild must not clobber the
1158        // `sync_state` a prior engine persisted (that is what makes the
1159        // resumability leg meaningful: each `engine_at` models a fresh process).
1160        let config_path = root.join(".memstead").join("config.json");
1161        if !config_path.exists() {
1162            std::fs::create_dir_all(root.join(".memstead")).unwrap();
1163            std::fs::write(&config_path, br#"{"format":1,"schema":"default@1.0.0"}"#).unwrap();
1164        }
1165        let mount = Mount {
1166            mem: "engine".to_string(),
1167            schema: Some("default@1.0.0".parse().unwrap()),
1168            storage: MountStorage::Folder {
1169                path: root.to_path_buf(),
1170            },
1171            capability: MountCapability::Write,
1172            lifecycle: MountLifecycle::Eager,
1173            cross_linkable: false,
1174            migration_target: None,
1175        };
1176        Engine::from_mounts(vec![(
1177            mount,
1178            Box::new(FilesystemMemWriter::new(root.to_path_buf()))
1179                as Box<dyn crate::backend::MemBackend>,
1180        )])
1181        .unwrap()
1182    }
1183
1184    fn synced_key() -> &'static str {
1185        "engine/graph/source-tree#synced"
1186    }
1187
1188    /// AC9 — `projection advance` is non-stalling under a moving HEAD, and its
1189    /// gate + resumability hold:
1190    ///
1191    /// 1. freeze a slice, dispose part → the remainder is the rest;
1192    /// 2. an unknown artifact id refuses the whole call **atomically** (the
1193    ///    store is byte-identical after the refusal);
1194    /// 3. a fresh process (new engine) honors the on-disk dispositions
1195    ///    (resumability is on-disk, not in-memory);
1196    /// 4. the source HEAD advances mid-pass → the re-presented slice equals
1197    ///    (old remainder + new deltas) with disposed artifacts absent;
1198    /// 5. disposing the rest empties the remainder → the `#synced` token
1199    ///    advances via the engine writer to the current HEAD.
1200    #[test]
1201    fn advance_is_non_stalling_under_a_moving_head_with_gate_and_resumability() {
1202        let tmp = TempDir::new().unwrap();
1203        let root = tmp.path();
1204
1205        // Source git tree: baseline commit with a.rs + b.rs.
1206        git(root, &["init", "-q"]);
1207        std::fs::write(root.join("a.rs"), "one").unwrap();
1208        std::fs::write(root.join("b.rs"), "bee").unwrap();
1209        git(root, &["add", "a.rs", "b.rs"]);
1210        git(root, &["commit", "-qm", "base"]);
1211        let baseline = head_sha(root);
1212
1213        // Move to head1: modify a.rs, delete b.rs.
1214        std::fs::write(root.join("a.rs"), "one-longer").unwrap();
1215        std::fs::remove_file(root.join("b.rs")).unwrap();
1216        git(root, &["add", "-A"]);
1217        git(root, &["commit", "-qm", "head1"]);
1218
1219        let resolved = resolved_engine_graph();
1220
1221        // Seed the `#synced` baseline so the source shows a real moved slice.
1222        {
1223            let mut engine = engine_at(root);
1224            engine
1225                .set_mem_sync_state("engine", synced_key(), &baseline, None)
1226                .unwrap();
1227        }
1228
1229        // (1) Freeze + dispose part (a.rs). Remainder = the rest (b.rs deleted).
1230        {
1231            let mut engine = engine_at(root);
1232            let out = advance_baseline(&mut engine, root, &resolved, &input(&[("a.rs", "worked")]))
1233                .unwrap();
1234            assert!(!out.completed, "one artifact still pending");
1235            assert_eq!(out.remainder, slice(&[], &[], &["b.rs"]));
1236            assert_eq!(out.pending, 1);
1237            assert_eq!(out.disposed, 1);
1238        }
1239        // The dispositions persisted to disk.
1240        let on_disk = read_advance_store(root, "engine", "graph")
1241            .unwrap()
1242            .unwrap();
1243        assert_eq!(on_disk.dispositions, disp(&[("a.rs", "worked")]));
1244
1245        // (2) An unknown artifact id refuses the whole call atomically — the
1246        // store is byte-identical afterwards (no partial write).
1247        let before = std::fs::read(advance_store_path(root, "engine", "graph")).unwrap();
1248        {
1249            let mut engine = engine_at(root);
1250            let err = advance_baseline(
1251                &mut engine,
1252                root,
1253                &resolved,
1254                &input(&[("never-presented.rs", "worked")]),
1255            )
1256            .unwrap_err();
1257            assert!(
1258                matches!(err, AdvanceError::UnknownArtifact { .. }),
1259                "expected UnknownArtifact, got {err:?}"
1260            );
1261        }
1262        let after = std::fs::read(advance_store_path(root, "engine", "graph")).unwrap();
1263        assert_eq!(before, after, "refused call must not touch the store");
1264
1265        // (4) Source moves mid-pass → add c.rs at head2.
1266        std::fs::write(root.join("c.rs"), "cee").unwrap();
1267        git(root, &["add", "-A"]);
1268        git(root, &["commit", "-qm", "head2"]);
1269
1270        // (3)+(4) A fresh engine (new process) honors the on-disk a.rs
1271        // disposition, and re-presents (old remainder [b.rs] + new delta [c.rs])
1272        // with the disposed a.rs absent. Empty dispositions = pure re-present.
1273        {
1274            let mut engine = engine_at(root);
1275            let out = advance_baseline(&mut engine, root, &resolved, &BTreeMap::new()).unwrap();
1276            assert!(!out.completed);
1277            assert_eq!(
1278                out.remainder,
1279                slice(&["c.rs"], &[], &["b.rs"]),
1280                "re-present = old remainder (b.rs) + new delta (c.rs); disposed a.rs absent"
1281            );
1282            assert_eq!(out.disposed, 1, "no new disposition this call");
1283        }
1284
1285        // (5) Dispose the rest → remainder empties → the token advances.
1286        let head2 = head_sha(root);
1287        {
1288            let mut engine = engine_at(root);
1289            let out = advance_baseline(
1290                &mut engine,
1291                root,
1292                &resolved,
1293                &input(&[("b.rs", "worked"), ("c.rs", "worked")]),
1294            )
1295            .unwrap();
1296            assert!(out.completed, "every artifact disposed → complete");
1297            assert_eq!(out.pending, 0);
1298            assert_eq!(out.tokens_written, vec![synced_key().to_string()]);
1299
1300            // The `#synced` baseline advanced to the current HEAD (head2).
1301            let token = engine
1302                .mem_config_for("engine")
1303                .and_then(|c| c.sync_state.get(synced_key()).cloned());
1304            assert_eq!(token.as_deref(), Some(head2.as_str()));
1305        }
1306        // The durable store was dropped on completion.
1307        assert!(
1308            read_advance_store(root, "engine", "graph")
1309                .unwrap()
1310                .is_none()
1311        );
1312    }
1313
1314    /// The durable authored-exclusion ledger survives completion (unlike the
1315    /// transient dispositions/frozen slice), and a later non-excluded verdict for
1316    /// the same artifact clears it — dropping the store when nothing durable is
1317    /// left. This is the persistence the fidelity report relies on so an
1318    /// excluded-on-purpose artifact stops re-surfacing as `uncovered`.
1319    #[test]
1320    fn advance_retains_authored_exclusions_past_completion_and_clears_on_rejudge() {
1321        let tmp = TempDir::new().unwrap();
1322        let root = tmp.path();
1323
1324        // Baseline a.rs; move to head1 (modify a.rs) so the slice = {modified a.rs}.
1325        git(root, &["init", "-q"]);
1326        std::fs::write(root.join("a.rs"), "one").unwrap();
1327        git(root, &["add", "a.rs"]);
1328        git(root, &["commit", "-qm", "base"]);
1329        let baseline = head_sha(root);
1330        std::fs::write(root.join("a.rs"), "one-longer").unwrap();
1331        git(root, &["add", "-A"]);
1332        git(root, &["commit", "-qm", "head1"]);
1333
1334        let resolved = resolved_engine_graph();
1335        {
1336            let mut engine = engine_at(root);
1337            engine
1338                .set_mem_sync_state("engine", synced_key(), &baseline, None)
1339                .unwrap();
1340        }
1341
1342        // Dispose a.rs as EXCLUDED with a rationale → the only slice artifact is
1343        // disposed → the advance completes. Exclusions are non-empty, so the
1344        // store is RETAINED (not dropped) holding only the exclusion.
1345        let excluded = {
1346            let mut m = BTreeMap::new();
1347            m.insert(
1348                "a.rs".to_string(),
1349                DispositionInput::Reasoned {
1350                    disposition: EXCLUDED_VERDICT.to_string(),
1351                    rationale: "mined; warrants no destination entity".to_string(),
1352                },
1353            );
1354            m
1355        };
1356        {
1357            let mut engine = engine_at(root);
1358            let out = advance_baseline(&mut engine, root, &resolved, &excluded).unwrap();
1359            assert!(out.completed, "the sole slice artifact was disposed");
1360        }
1361        let retained = read_advance_store(root, "engine", "graph")
1362            .unwrap()
1363            .expect("an authored exclusion keeps the store alive past completion");
1364        assert!(
1365            retained.frozen_slice == Slice::default() && retained.dispositions.is_empty(),
1366            "transient progress is dropped on completion"
1367        );
1368        assert_eq!(
1369            retained.exclusions.get("a.rs").map(String::as_str),
1370            Some("mined; warrants no destination entity"),
1371            "the durable exclusion + its rationale persist"
1372        );
1373
1374        // Move to head2 (modify a.rs again) → a.rs re-enters the slice → re-judge
1375        // it as `worked`. The non-excluded verdict clears the stale exclusion, and
1376        // with nothing durable left the store is dropped.
1377        std::fs::write(root.join("a.rs"), "one-longer-still").unwrap();
1378        git(root, &["add", "-A"]);
1379        git(root, &["commit", "-qm", "head2"]);
1380        {
1381            let mut engine = engine_at(root);
1382            let out = advance_baseline(&mut engine, root, &resolved, &input(&[("a.rs", "worked")]))
1383                .unwrap();
1384            assert!(out.completed);
1385        }
1386        assert!(
1387            read_advance_store(root, "engine", "graph")
1388                .unwrap()
1389                .is_none(),
1390            "re-judging the artifact cleared the exclusion; nothing durable remains"
1391        );
1392    }
1393
1394    /// Criterion: a **medium-relative** artifact id (the form agents naturally
1395    /// type — `a.rs` when the engine printed `sub/a.rs`) refuses with a typed,
1396    /// remedy-bearing message that names the workspace-relative dialect and
1397    /// the concrete corrected id when derivable. REFUSALS: the gate never
1398    /// widens — the medium-relative form is never accepted, nothing is
1399    /// written; an unknown id with no derivable correction carries no
1400    /// suggestion.
1401    #[test]
1402    fn advance_unknown_artifact_names_dialect_and_suggests_corrected_id() {
1403        let tmp = TempDir::new().unwrap();
1404        let root = tmp.path();
1405
1406        // Source files live under the medium subtree `sub/` — artifact ids in
1407        // the slice are workspace-relative (`sub/a.rs`).
1408        git(root, &["init", "-q"]);
1409        std::fs::create_dir_all(root.join("sub")).unwrap();
1410        std::fs::write(root.join("sub").join("a.rs"), "one").unwrap();
1411        git(root, &["add", "-A"]);
1412        git(root, &["commit", "-qm", "base"]);
1413        let baseline = head_sha(root);
1414        std::fs::write(root.join("sub").join("a.rs"), "one-longer").unwrap();
1415        git(root, &["add", "-A"]);
1416        git(root, &["commit", "-qm", "head1"]);
1417
1418        let mut resolved = resolved_engine_graph();
1419        if let ResolvedSource::Primary(p) = &mut resolved.sources[0] {
1420            p.pointer = "sub".to_string();
1421        }
1422        {
1423            let mut engine = engine_at(root);
1424            engine
1425                .set_mem_sync_state("engine", synced_key(), &baseline, None)
1426                .unwrap();
1427        }
1428
1429        // The medium-relative id refuses; the message names the dialect and
1430        // the corrected id; the details pair maps supplied → corrected. An id
1431        // with no derivable correction rides the same refusal suggestion-free.
1432        {
1433            let mut engine = engine_at(root);
1434            let err = advance_baseline(
1435                &mut engine,
1436                root,
1437                &resolved,
1438                &input(&[("a.rs", "worked"), ("zzz.rs", "worked")]),
1439            )
1440            .unwrap_err();
1441            let AdvanceError::UnknownArtifact {
1442                artifacts,
1443                suggestions,
1444                ..
1445            } = &err
1446            else {
1447                panic!("expected UnknownArtifact, got {err:?}");
1448            };
1449            assert_eq!(artifacts, &vec!["a.rs".to_string(), "zzz.rs".to_string()]);
1450            assert_eq!(
1451                suggestions,
1452                &vec![("a.rs".to_string(), "sub/a.rs".to_string())],
1453                "only the medium-relative id gets a corrected form; zzz.rs has none"
1454            );
1455            let msg = err.to_string();
1456            assert!(
1457                msg.contains("workspace-relative"),
1458                "names the dialect: {msg}"
1459            );
1460            assert!(
1461                msg.contains("`a.rs` → `sub/a.rs`"),
1462                "carries the concrete corrected id: {msg}"
1463            );
1464            assert!(
1465                msg.contains("never accepted"),
1466                "states the dialect does not widen: {msg}"
1467            );
1468        }
1469        // The refusal wrote nothing (the gate stayed atomic).
1470        assert!(
1471            read_advance_store(root, "engine", "graph")
1472                .unwrap()
1473                .is_none(),
1474            "a refused call must not create the advance store"
1475        );
1476
1477        // The corrected workspace-relative id is the one the gate accepts.
1478        {
1479            let mut engine = engine_at(root);
1480            let out = advance_baseline(
1481                &mut engine,
1482                root,
1483                &resolved,
1484                &input(&[("sub/a.rs", "worked")]),
1485            )
1486            .unwrap();
1487            assert!(out.completed, "the sole slice artifact was disposed");
1488        }
1489    }
1490
1491    /// `record_exclusions` gates on enumerable `S(D)` membership (not the changed
1492    /// slice), so a **stable, unchanged** in-scope artifact can be declared
1493    /// excluded — the direct write path the option-(a) migration needs. A
1494    /// non-member refuses the whole call atomically; a re-declare merges.
1495    #[test]
1496    fn record_exclusions_gates_on_source_membership_and_merges() {
1497        let tmp = TempDir::new().unwrap();
1498        let root = tmp.path();
1499
1500        // A source tree with two in-scope `.rs` members. No commits move after
1501        // this — the artifacts are stable, never in a changed slice.
1502        git(root, &["init", "-q"]);
1503        std::fs::write(root.join("a.rs"), "one").unwrap();
1504        std::fs::write(root.join("b.rs"), "two").unwrap();
1505        git(root, &["add", "-A"]);
1506        git(root, &["commit", "-qm", "base"]);
1507
1508        let resolved = resolved_engine_graph();
1509
1510        // Declare a.rs excluded with a rationale — accepted (S(D) member).
1511        let out = record_exclusions(
1512            &Engine::from_mounts(Vec::new()).unwrap(),
1513            root,
1514            &resolved,
1515            &BTreeMap::from([("a.rs".to_string(), "mined; no entity".to_string())]),
1516        )
1517        .unwrap();
1518        assert_eq!((out.added, out.excluded), (1, 1));
1519        let state = read_advance_store(root, "engine", "graph")
1520            .unwrap()
1521            .unwrap();
1522        assert_eq!(
1523            state.exclusions.get("a.rs").map(String::as_str),
1524            Some("mined; no entity")
1525        );
1526
1527        // An artifact outside S(D) refuses the whole call — the store is untouched.
1528        let err = record_exclusions(
1529            &Engine::from_mounts(Vec::new()).unwrap(),
1530            root,
1531            &resolved,
1532            &BTreeMap::from([("does-not-exist.rs".to_string(), "x".to_string())]),
1533        )
1534        .unwrap_err();
1535        assert!(
1536            matches!(err, ExcludeError::NotSourceMember { .. }),
1537            "got {err:?}"
1538        );
1539        assert_eq!(
1540            read_advance_store(root, "engine", "graph")
1541                .unwrap()
1542                .unwrap()
1543                .exclusions
1544                .len(),
1545            1,
1546            "refused call left the ledger unchanged"
1547        );
1548
1549        // Re-declaring merges (b.rs added alongside a.rs).
1550        let out2 = record_exclusions(
1551            &Engine::from_mounts(Vec::new()).unwrap(),
1552            root,
1553            &resolved,
1554            &BTreeMap::from([("b.rs".to_string(), "also mined".to_string())]),
1555        )
1556        .unwrap();
1557        assert_eq!((out2.added, out2.excluded), (1, 2));
1558    }
1559
1560    /// A PARTIAL enumeration refuses the membership gate outright: under a
1561    /// legacy-dialect scope pattern the enumerated set is not the population,
1562    /// so the gate can neither refuse a genuinely in-scope artifact nor state
1563    /// the short count as if it were `S(D)`. Typed refusal, nothing written.
1564    #[test]
1565    fn record_exclusions_refuses_partial_enumeration() {
1566        let tmp = TempDir::new().unwrap();
1567        let root = tmp.path();
1568
1569        git(root, &["init", "-q"]);
1570        std::fs::create_dir_all(root.join("sub")).unwrap();
1571        std::fs::write(root.join("sub").join("a.rs"), "one").unwrap();
1572        git(root, &["add", "-A"]);
1573        git(root, &["commit", "-qm", "base"]);
1574
1575        // Pointer `sub`, MIXED scope: the prefix-free pattern enumerates
1576        // `sub/a.rs`, the retired-dialect pattern's share is silently absent.
1577        let mut resolved = resolved_engine_graph();
1578        if let ResolvedSource::Primary(p) = &mut resolved.sources[0] {
1579            p.pointer = "sub".to_string();
1580            p.scope.push(PatternEntry {
1581                path: "sub/nested.rs".to_string(),
1582                mode: PatternMode::Allow,
1583            });
1584        }
1585
1586        // Even a genuine member of the surviving subset refuses: membership in
1587        // a set that is not the population is not membership in the population.
1588        let err = record_exclusions(
1589            &Engine::from_mounts(Vec::new()).unwrap(),
1590            root,
1591            &resolved,
1592            &BTreeMap::from([("sub/a.rs".to_string(), "mined; no entity".to_string())]),
1593        )
1594        .unwrap_err();
1595        assert!(
1596            matches!(err, ExcludeError::PartialEnumeration { .. }),
1597            "got {err:?}"
1598        );
1599        assert!(
1600            err.to_string().contains("incomplete"),
1601            "the refusal names the partiality: {err}"
1602        );
1603        assert!(
1604            read_advance_store(root, "engine", "graph")
1605                .unwrap()
1606                .is_none(),
1607            "a refused call must not create the advance store"
1608        );
1609    }
1610
1611    /// `DispositionInput` parses both the bare-verdict and the reasoned forms
1612    /// from one `--dispositions` payload (serde `untagged`).
1613    #[test]
1614    fn disposition_input_parses_bare_and_reasoned_forms() {
1615        let map: BTreeMap<String, DispositionInput> = serde_json::from_str(
1616            r#"{"a.rs": "worked", "b.rs": {"disposition": "excluded", "rationale": "generated"}}"#,
1617        )
1618        .unwrap();
1619        assert_eq!(map["a.rs"].verdict(), "worked");
1620        assert_eq!(map["a.rs"].rationale(), None);
1621        assert_eq!(map["b.rs"].verdict(), EXCLUDED_VERDICT);
1622        assert_eq!(map["b.rs"].rationale(), Some("generated"));
1623    }
1624
1625    /// Criterion 4 (backlog-sweep plan 03a): the auto-`worked` matching
1626    /// understands the SOURCE dialect — an anchor written source-relative
1627    /// (`f.rs` + `source` name, decision 26) marks the pointer-joined slice
1628    /// artifact (`srcdir/f.rs`) worked, exactly as a workspace-relative
1629    /// anchor would. Requires a workspace root + pipeline store so the
1630    /// source name resolves to its pointer.
1631    #[test]
1632    fn advance_auto_worked_matches_source_dialect_anchors() {
1633        use crate::binding::{BINDING_VERSION, Binding, Operations};
1634        use crate::vcs::Actor;
1635        use indexmap::IndexMap;
1636
1637        let tmp = TempDir::new().unwrap();
1638        let root = tmp.path();
1639
1640        git(root, &["init", "-q"]);
1641        std::fs::create_dir_all(root.join("srcdir")).unwrap();
1642        std::fs::write(root.join(".keep"), "x").unwrap();
1643        git(root, &["add", ".keep"]);
1644        git(root, &["commit", "-qm", "base"]);
1645        let baseline = head_sha(root);
1646
1647        // The pipeline store carries the binding that maps source name
1648        // `source-tree` → pointer `srcdir` for mem `engine`.
1649        let binding = Binding {
1650            version: BINDING_VERSION,
1651            intent: None,
1652            sources: vec![crate::pipeline::Source {
1653                name: "source-tree".to_string(),
1654                medium_type: crate::pipeline::MediumType::Codebase,
1655                pointer: "srcdir".to_string(),
1656                change_detection: Some("git".to_string()),
1657                scope: vec![PatternEntry {
1658                    path: "**/*.rs".to_string(),
1659                    mode: PatternMode::Allow,
1660                }],
1661                engagement: None,
1662                preparation: None,
1663            }],
1664            reference_mems: Vec::new(),
1665            destination_mem: "engine".to_string(),
1666            deny_paths: Vec::new(),
1667            coverage_semantics: None,
1668            rules: None,
1669            prune: None,
1670            operations: Operations {
1671                build: None,
1672                sync: None,
1673                verify: None,
1674            },
1675        };
1676        let dir = root.join(".memstead").join("projections").join("engine");
1677        std::fs::create_dir_all(&dir).unwrap();
1678        std::fs::write(
1679            dir.join("graph.json"),
1680            serde_json::to_string_pretty(&binding).unwrap(),
1681        )
1682        .unwrap();
1683
1684        // The resolved ingest's source points at `srcdir`, so slice
1685        // artifact ids come out pointer-joined (`srcdir/f.rs`).
1686        let mut resolved = resolved_engine_graph();
1687        if let [ResolvedSource::Primary(p)] = resolved.sources.as_mut_slice() {
1688            p.pointer = "srcdir".to_string();
1689        } else {
1690            panic!("fixture shape");
1691        }
1692
1693        {
1694            let mut engine = engine_at(root);
1695            engine
1696                .set_mem_sync_state("engine", synced_key(), &baseline, None)
1697                .unwrap();
1698        }
1699
1700        std::fs::write(root.join("srcdir").join("f.rs"), "fn f() {}").unwrap();
1701        git(root, &["add", "-A"]);
1702        git(root, &["commit", "-qm", "head1"]);
1703
1704        // Anchored write in the SOURCE dialect: artifact `f.rs`, source
1705        // `source-tree` — no pointer prefix.
1706        let mut sections = IndexMap::new();
1707        sections.insert("identity".to_string(), "Covers f.".to_string());
1708        sections.insert("purpose".to_string(), "Track f.rs.".to_string());
1709        {
1710            let mut engine = engine_at(root);
1711            engine.set_workspace_root(root.to_path_buf());
1712            engine
1713                .create_entity(
1714                    crate::CreateEntityArgs {
1715                        mem: "engine".to_string(),
1716                        title: "Covers F".to_string(),
1717                        entity_type: "spec".to_string(),
1718                        sections,
1719                        metadata: IndexMap::new(),
1720                        relations: Vec::new(),
1721                        anchors: vec![crate::anchor::AnchorInput {
1722                            artifact: Some("f.rs".to_string()),
1723                            grain: Some("file".to_string()),
1724                            class: Some("anchored".to_string()),
1725                            hash: Some("h".to_string()),
1726                            hash_stability: Some("stable".to_string()),
1727                            source: Some("source-tree".to_string()),
1728                            ..Default::default()
1729                        }],
1730                        dry_run: false,
1731                    },
1732                    Actor::Agent,
1733                    None,
1734                    Some("source-dialect anchored write"),
1735                )
1736                .unwrap();
1737        }
1738
1739        // Advance with NO explicit dispositions: `srcdir/f.rs` (the slice
1740        // form) auto-works from the source-dialect anchor.
1741        let mut engine = engine_at(root);
1742        engine.set_workspace_root(root.to_path_buf());
1743        let out = advance_baseline(&mut engine, root, &resolved, &BTreeMap::new()).unwrap();
1744        assert!(
1745            out.completed,
1746            "the source-dialect anchor auto-worked the joined slice artifact: {out:?}"
1747        );
1748        assert_eq!(out.disposed, 1);
1749    }
1750
1751    /// AC9a — an anchored write auto-marks its referenced frozen-slice
1752    /// artifacts `worked`, so `advance` needs an explicit disposition only for
1753    /// the residue, held across a HEAD move. Refusals: an artifact with no
1754    /// anchor is never auto-worked, and an anchor referencing an artifact
1755    /// OUTSIDE the presented slice fabricates no slice entry.
1756    #[test]
1757    fn advance_auto_worked_from_anchors_subtracts_slice_and_never_fabricates() {
1758        use crate::vcs::Actor;
1759        use indexmap::IndexMap;
1760
1761        let tmp = TempDir::new().unwrap();
1762        let root = tmp.path();
1763
1764        // Baseline: a commit carrying no `.rs` files. `#synced` pins it, so the
1765        // moved slice below is purely the added `.rs` sources.
1766        git(root, &["init", "-q"]);
1767        std::fs::write(root.join(".keep"), "x").unwrap();
1768        git(root, &["add", ".keep"]);
1769        git(root, &["commit", "-qm", "base"]);
1770        let baseline = head_sha(root);
1771
1772        let resolved = resolved_engine_graph();
1773        {
1774            let mut engine = engine_at(root);
1775            engine
1776                .set_mem_sync_state("engine", synced_key(), &baseline, None)
1777                .unwrap();
1778        }
1779
1780        // head1: add a.rs + b.rs → slice = added [a.rs, b.rs].
1781        std::fs::write(root.join("a.rs"), "one").unwrap();
1782        std::fs::write(root.join("b.rs"), "bee").unwrap();
1783        git(root, &["add", "a.rs", "b.rs"]);
1784        git(root, &["commit", "-qm", "head1"]);
1785
1786        // An anchored write into the destination mem `engine`: entity
1787        // `covers-a` file-anchors `a.rs` (inside the slice) AND `zzz.rs`
1788        // (outside it — must fabricate nothing).
1789        let make_anchor = |artifact: &str| crate::anchor::AnchorInput {
1790            artifact: Some(artifact.to_string()),
1791            grain: Some("file".to_string()),
1792            class: Some("anchored".to_string()),
1793            hash: Some("h".to_string()),
1794            hash_stability: Some("stable".to_string()),
1795            ..Default::default()
1796        };
1797        let mut sections = IndexMap::new();
1798        sections.insert("identity".to_string(), "Covers a.".to_string());
1799        sections.insert("purpose".to_string(), "Track a.rs.".to_string());
1800        {
1801            let mut engine = engine_at(root);
1802            engine
1803                .create_entity(
1804                    crate::CreateEntityArgs {
1805                        mem: "engine".to_string(),
1806                        title: "Covers A".to_string(),
1807                        entity_type: "spec".to_string(),
1808                        sections,
1809                        metadata: IndexMap::new(),
1810                        relations: Vec::new(),
1811                        anchors: vec![make_anchor("a.rs"), make_anchor("zzz.rs")],
1812                        dry_run: false,
1813                    },
1814                    Actor::Agent,
1815                    None,
1816                    Some("anchored write"),
1817                )
1818                .unwrap();
1819        }
1820
1821        // (1) Advance with NO explicit dispositions → a.rs auto-worked from the
1822        // anchor; b.rs (no anchor) stays pending; zzz.rs (outside the slice)
1823        // fabricates nothing.
1824        {
1825            let mut engine = engine_at(root);
1826            let out = advance_baseline(&mut engine, root, &resolved, &BTreeMap::new()).unwrap();
1827            assert!(!out.completed, "b.rs still pending");
1828            assert_eq!(
1829                out.remainder,
1830                slice(&["b.rs"], &[], &[]),
1831                "a.rs auto-worked from its anchor; zzz.rs never became a slice member"
1832            );
1833            assert_eq!(out.disposed, 1, "only a.rs auto-worked");
1834            assert_eq!(out.pending, 1);
1835        }
1836
1837        // (2) HEAD moves (add c.rs). Re-present with no dispositions → old
1838        // remainder [b.rs] + new delta [c.rs]; a.rs stays absent (its
1839        // auto-`worked` persisted); c.rs is unanchored so it is NOT auto-worked.
1840        std::fs::write(root.join("c.rs"), "cee").unwrap();
1841        git(root, &["add", "-A"]);
1842        git(root, &["commit", "-qm", "head2"]);
1843        {
1844            let mut engine = engine_at(root);
1845            let out = advance_baseline(&mut engine, root, &resolved, &BTreeMap::new()).unwrap();
1846            assert_eq!(
1847                out.remainder,
1848                slice(&["b.rs", "c.rs"], &[], &[]),
1849                "auto-worked a.rs absent; unanchored b.rs + new c.rs pending"
1850            );
1851            assert_eq!(
1852                out.disposed, 1,
1853                "still only a.rs auto-worked; c.rs unanchored"
1854            );
1855            assert!(!out.completed);
1856        }
1857    }
1858
1859    /// A workspace with one folder mem `engine` (default@1.0.0), a git source
1860    /// tree at the root, and `files` written relative to the root. Returns the
1861    /// root; the caller writes the binding.
1862    fn a3_workspace(root: &std::path::Path, files: &[&str]) {
1863        let mem_dir = root.join("mem");
1864        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
1865        std::fs::write(
1866            mem_dir.join(".memstead").join("config.json"),
1867            r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
1868        )
1869        .unwrap();
1870        std::fs::create_dir_all(root.join(".memstead")).unwrap();
1871        std::fs::write(
1872            root.join(".memstead").join("workspace.toml"),
1873            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
1874        )
1875        .unwrap();
1876        let mount = crate::workspace::Mount {
1877            mem: "engine".to_string(),
1878            schema: Some("default@1.0.0".parse().unwrap()),
1879            storage: crate::workspace::MountStorage::Folder {
1880                path: mem_dir.clone(),
1881            },
1882            capability: crate::workspace::MountCapability::Write,
1883            lifecycle: crate::workspace::MountLifecycle::Eager,
1884            cross_linkable: false,
1885            migration_target: None,
1886        };
1887        crate::workspace_store::WorkspaceStoreAdapter::save_state(
1888            &crate::FileWorkspaceStore::new(),
1889            root,
1890            &crate::workspace::Workspace {
1891                mounts: vec![mount],
1892                settings: crate::workspace::WorkspaceSettings::default(),
1893            },
1894        )
1895        .unwrap();
1896        let out = std::process::Command::new("git")
1897            .args(["init", "-q"])
1898            .current_dir(root)
1899            .output()
1900            .unwrap();
1901        assert!(out.status.success());
1902        for f in files {
1903            let p = root.join(f);
1904            std::fs::create_dir_all(p.parent().unwrap()).unwrap();
1905            std::fs::write(p, "fn x() {}\n").unwrap();
1906        }
1907    }
1908
1909    fn a3_binding(
1910        sources: &[(&str, &str)],
1911        deny: &[&str],
1912        batch_size: u32,
1913    ) -> crate::binding::Binding {
1914        crate::binding::Binding {
1915            version: crate::binding::BINDING_VERSION,
1916            intent: None,
1917            sources: sources
1918                .iter()
1919                .map(|(name, glob)| crate::pipeline::Source {
1920                    name: name.to_string(),
1921                    medium_type: crate::pipeline::MediumType::Codebase,
1922                    pointer: String::new(),
1923                    change_detection: Some("git".to_string()),
1924                    scope: vec![crate::pipeline::PatternEntry {
1925                        path: glob.to_string(),
1926                        mode: crate::pipeline::PatternMode::Allow,
1927                    }],
1928                    engagement: None,
1929                    preparation: None,
1930                })
1931                .collect(),
1932            reference_mems: Vec::new(),
1933            destination_mem: "engine".to_string(),
1934            deny_paths: deny.iter().map(|d| d.to_string()).collect(),
1935            coverage_semantics: None,
1936            rules: None,
1937            prune: None,
1938            operations: crate::binding::Operations {
1939                build: Some(crate::binding::BuildOperation {
1940                    mode: crate::binding::BuildMode::Discovery,
1941                    trigger: crate::pipeline::IngestTrigger::Loop,
1942                    batch_size,
1943                    post_actions: None,
1944                }),
1945                sync: None,
1946                verify: Some(crate::binding::VerifyOperation {
1947                    trigger: crate::pipeline::IngestTrigger::Manual,
1948                    batch_size,
1949                    adjudication_cap: crate::binding::DEFAULT_ADJUDICATION_CAP,
1950                    // Scheduled full walks off: the sampled path is under test.
1951                    full_resync_every: 0,
1952                }),
1953            },
1954        }
1955    }
1956
1957    /// A3 AC3: an exclusion survives a binding edit that changes an unrelated
1958    /// field, still carrying its source and rationale; removing its source
1959    /// from the declaration drops it, reported once with the source named.
1960    #[test]
1961    fn exclusions_survive_edits_and_drop_with_their_source() {
1962        let tmp = tempfile::tempdir().unwrap();
1963        let root = tmp.path();
1964        a3_workspace(root, &["src/a.rs", "docs/x.md"]);
1965        let engine = Engine::from_workspace_root(root).unwrap();
1966        let two = |batch: u32| {
1967            a3_binding(
1968                &[("graph", "src/**/*.rs"), ("docs", "docs/**/*.md")],
1969                &[],
1970                batch,
1971            )
1972        };
1973
1974        let b = two(20);
1975        crate::pipeline_store::write_binding(root, "engine", "graph", &b).unwrap();
1976        let resolved = crate::ingest::resolve::resolve_binding_run("engine/graph", &b).unwrap();
1977        let mut ex = BTreeMap::new();
1978        ex.insert("docs/x.md".to_string(), "index page, mined".to_string());
1979        record_exclusions(&engine, root, &resolved, &ex).unwrap();
1980        let ledger = reconcile_exclusions(&engine, root, &resolved).unwrap();
1981        assert_eq!(ledger.active.len(), 1);
1982        assert_eq!(ledger.active[0].source, "docs");
1983        assert_eq!(ledger.active[0].rationale, "index page, mined");
1984        assert!(ledger.dropped.is_empty());
1985
1986        // The edit: batch size only. Same source, same rationale.
1987        let edited = two(5);
1988        crate::pipeline_store::write_binding(root, "engine", "graph", &edited).unwrap();
1989        let resolved =
1990            crate::ingest::resolve::resolve_binding_run("engine/graph", &edited).unwrap();
1991        let ledger = reconcile_exclusions(&engine, root, &resolved).unwrap();
1992        assert_eq!(ledger.active.len(), 1, "{ledger:?}");
1993        assert_eq!(ledger.active[0].artifact, "docs/x.md");
1994        assert_eq!(ledger.active[0].rationale, "index page, mined");
1995        assert!(ledger.dropped.is_empty());
1996
1997        // The source leaves the declaration: dropped, named, once.
1998        let without = a3_binding(&[("graph", "src/**/*.rs")], &[], 5);
1999        crate::pipeline_store::write_binding(root, "engine", "graph", &without).unwrap();
2000        let resolved =
2001            crate::ingest::resolve::resolve_binding_run("engine/graph", &without).unwrap();
2002        let ledger = reconcile_exclusions(&engine, root, &resolved).unwrap();
2003        assert!(ledger.active.is_empty(), "{ledger:?}");
2004        assert_eq!(ledger.dropped.len(), 1);
2005        assert_eq!(ledger.dropped[0].artifact, "docs/x.md");
2006        assert_eq!(ledger.dropped[0].source, "docs");
2007        assert_eq!(ledger.dropped[0].rationale, "index page, mined");
2008        let again = reconcile_exclusions(&engine, root, &resolved).unwrap();
2009        assert!(
2010            again.active.is_empty() && again.dropped.is_empty(),
2011            "reported once: {again:?}"
2012        );
2013        assert!(
2014            read_advance_store(root, "engine", "graph")
2015                .unwrap()
2016                .is_none()
2017        );
2018    }
2019}