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