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