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};
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            engine
514                .anchors_referencing_artifact(art)
515                .iter()
516                .any(|(eid, _)| eid.mem() == resolved.destination_mem.as_str())
517        })
518        .cloned()
519        .collect();
520    for art in auto_worked {
521        state.dispositions.insert(art, "worked".to_string());
522    }
523
524    // Re-present the remainder (disposed absent).
525    let remainder = subtract_disposed(&state.frozen_slice, &state.dispositions);
526    let pending = remainder.added.len() + remainder.modified.len() + remainder.deleted.len();
527    let completed = pending == 0;
528
529    let mut warnings: Vec<String> = Vec::new();
530    let mut tokens_written: Vec<String> = Vec::new();
531    if completed {
532        // Advance the baseline token for every facet that moved (current cursor
533        // tokens = the latest HEAD) via the engine writer. Provenance piggybacks
534        // the write's commit note — no new channel (D7).
535        let note = format!(
536            "projection advance {binding_id}: {} artifact(s) disposed, baseline advanced",
537            state.dispositions.len()
538        );
539        for c in cursor.write_commands.iter().chain(cursor.reseed.iter()) {
540            let outcome = engine
541                .set_mem_sync_state(&resolved.destination_mem, &c.key, &c.token, Some(&note))
542                .map_err(|e| AdvanceError::Engine(e.to_string()))?;
543            warnings.extend(outcome.warnings.iter().map(ToString::to_string));
544            tokens_written.push(c.key.clone());
545        }
546        // Transient progress (frozen slice + per-run dispositions) is consumed.
547        // If any durable authored exclusions accumulated, retain a slimmed store
548        // holding only them (empty slice, no transient dispositions) so the
549        // fidelity report keeps consulting them; otherwise drop the store
550        // entirely (completion idempotent — the no-exclusion path is unchanged).
551        if state.exclusions.is_empty() {
552            delete_advance_store(workspace_root, &mem, &name).map_err(AdvanceError::Store)?;
553        } else {
554            let durable = AdvanceState {
555                binding: binding_id.clone(),
556                frozen_slice: Slice::default(),
557                dispositions: BTreeMap::new(),
558                exclusions: state.exclusions.clone(),
559            };
560            write_advance_store(workspace_root, &mem, &name, &durable)
561                .map_err(AdvanceError::Store)?;
562        }
563    } else {
564        // Persist the accumulated frozen slice + dispositions for resumability.
565        write_advance_store(workspace_root, &mem, &name, &state).map_err(AdvanceError::Store)?;
566    }
567
568    Ok(AdvanceOutcome {
569        binding: binding_id,
570        remainder,
571        disposed: state.dispositions.len(),
572        pending,
573        completed,
574        tokens_written,
575        warnings,
576    })
577}
578
579/// The outcome of a [`record_exclusions`] call.
580#[derive(Debug, Clone, PartialEq, Eq)]
581pub struct ExcludeOutcome {
582    /// The binding id whose exclusion ledger was written.
583    pub binding: String,
584    /// Total authored exclusions in the ledger after this call (this call + prior).
585    pub excluded: usize,
586    /// How many supplied artifacts were newly added (not already in the ledger).
587    pub added: usize,
588}
589
590/// Why [`record_exclusions`] could not complete.
591#[derive(Debug, thiserror::Error)]
592pub enum ExcludeError {
593    /// The binding id is not the canonical `<mem>/<stem>` shape.
594    #[error("malformed binding id '{0}': expected `<mem>/<stem>`")]
595    MalformedId(String),
596    /// One or more artifacts are not members of the binding's enumerable source
597    /// `S(D)` — the gate refuses the whole call (no partial write). Names each.
598    #[error(
599        "exclusion names {} artifact id(s) not in the binding's enumerable source S(D): {}; \
600         only an in-scope source member can be declared excluded ({printed} enumerated)",
601        artifacts.len(),
602        fmt_list(artifacts)
603    )]
604    NotSourceMember {
605        /// The offending, non-member ids (sorted).
606        artifacts: Vec<String>,
607        /// How many artifacts `S(D)` did enumerate (the accepted set size).
608        printed: usize,
609    },
610    /// Reading or writing the durable advance store failed.
611    #[error("advance store error: {0}")]
612    Store(#[source] StoreError),
613}
614
615/// Declare **authored exclusions** for in-scope source artifacts — the direct
616/// write path for the durable exclusion ledger [`advance_baseline`] also feeds.
617///
618/// Unlike the advance gate (which accepts only artifacts in the *changed slice*),
619/// this gates on **enumerable `S(D)` membership**: an artifact must be a real
620/// in-scope member of the binding's source, and a *stable, unchanged* artifact
621/// qualifies. That is what a deliberate editorial exclusion is — "this in-scope
622/// artifact is mined and warrants no destination entity, because …" — a decision
623/// independent of change detection. Each accepted `(artifact, rationale)` lands
624/// in the ledger the fidelity report consults, so the artifact stops re-surfacing
625/// as `uncovered` under exhaustive coverage and keeps its reasoning. Atomic: an
626/// artifact outside `S(D)` refuses the whole call before any write. Merges into
627/// any in-flight advance store rather than clobbering it. Generic across every
628/// enumerable binding and medium.
629pub fn record_exclusions(
630    engine: &Engine,
631    workspace_root: &Path,
632    resolved: &ResolvedIngest,
633    exclusions: &BTreeMap<String, String>,
634) -> Result<ExcludeOutcome, ExcludeError> {
635    let binding_id = resolved.name.clone();
636    let (mem, name) =
637        split_binding_id(&binding_id).map_err(|_| ExcludeError::MalformedId(binding_id.clone()))?;
638
639    // Enumerate S(D) — the in-scope source-artifact set, the same enumeration the
640    // fidelity report uses for its coverage denominator.
641    let mut s_d: BTreeSet<String> = BTreeSet::new();
642    for source in &resolved.sources {
643        if let ResolvedSource::Primary(p) = source {
644            for f in enumerate_source_artifacts(engine, p, &resolved.deny_paths, workspace_root) {
645                s_d.insert(f);
646            }
647        }
648    }
649
650    // Gate (atomic): every exclusion id must be an S(D) member. Validate BEFORE
651    // any disk write so a refusal leaves the store untouched.
652    let mut not_member: Vec<String> = exclusions
653        .keys()
654        .filter(|a| !s_d.contains(a.as_str()))
655        .cloned()
656        .collect();
657    if !not_member.is_empty() {
658        not_member.sort();
659        not_member.dedup();
660        return Err(ExcludeError::NotSourceMember {
661            artifacts: not_member,
662            printed: s_d.len(),
663        });
664    }
665
666    // Merge into the durable exclusion ledger, preserving any in-flight advance
667    // progress already in the same store.
668    let mut state = read_advance_store(workspace_root, &mem, &name)
669        .map_err(ExcludeError::Store)?
670        .unwrap_or_else(|| AdvanceState {
671            binding: binding_id.clone(),
672            ..Default::default()
673        });
674    let mut added = 0usize;
675    for (artifact, rationale) in exclusions {
676        if state
677            .exclusions
678            .insert(artifact.clone(), rationale.clone())
679            .is_none()
680        {
681            added += 1;
682        }
683    }
684    write_advance_store(workspace_root, &mem, &name, &state).map_err(ExcludeError::Store)?;
685
686    Ok(ExcludeOutcome {
687        binding: binding_id,
688        excluded: state.exclusions.len(),
689        added,
690    })
691}
692
693#[cfg(test)]
694mod tests {
695    use super::*;
696    use crate::binding::BuildMode;
697    use crate::pipeline::{IngestTrigger, MediumType, PatternEntry, PatternMode};
698    use crate::storage::FilesystemMemWriter;
699    use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
700    use tempfile::TempDir;
701
702    // ── pure helpers ─────────────────────────────────────────────────────
703
704    fn slice(added: &[&str], modified: &[&str], deleted: &[&str]) -> Slice {
705        Slice {
706            added: added.iter().map(|s| s.to_string()).collect(),
707            modified: modified.iter().map(|s| s.to_string()).collect(),
708            deleted: deleted.iter().map(|s| s.to_string()).collect(),
709        }
710    }
711
712    fn disp(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
713        pairs
714            .iter()
715            .map(|(a, d)| (a.to_string(), d.to_string()))
716            .collect()
717    }
718
719    /// The [`DispositionInput`] map an `advance_baseline` call takes: bare
720    /// verdicts (the common form).
721    fn input(pairs: &[(&str, &str)]) -> BTreeMap<String, DispositionInput> {
722        pairs
723            .iter()
724            .map(|(a, d)| (a.to_string(), DispositionInput::Verdict(d.to_string())))
725            .collect()
726    }
727
728    /// The store round-trips and `delete` is idempotent.
729    #[test]
730    fn advance_store_round_trips_and_delete_is_idempotent() {
731        let tmp = TempDir::new().unwrap();
732        let root = tmp.path();
733        assert!(
734            read_advance_store(root, "engine", "graph")
735                .unwrap()
736                .is_none()
737        );
738
739        let state = AdvanceState {
740            binding: "engine/graph".to_string(),
741            frozen_slice: slice(&["c.rs"], &["a.rs"], &["b.rs"]),
742            dispositions: disp(&[("a.rs", "worked")]),
743            exclusions: BTreeMap::new(),
744        };
745        write_advance_store(root, "engine", "graph", &state).unwrap();
746        assert!(
747            advance_store_path(root, "engine", "graph")
748                .ends_with("state/advance/engine/graph.json")
749        );
750        let back = read_advance_store(root, "engine", "graph")
751            .unwrap()
752            .unwrap();
753        assert_eq!(back, state);
754
755        delete_advance_store(root, "engine", "graph").unwrap();
756        assert!(
757            read_advance_store(root, "engine", "graph")
758                .unwrap()
759                .is_none()
760        );
761        // Idempotent: deleting an absent store is a no-op, not an error.
762        delete_advance_store(root, "engine", "graph").unwrap();
763    }
764
765    /// `subtract_disposed` removes disposed ids from every class.
766    #[test]
767    fn subtract_disposed_removes_disposed_from_every_class() {
768        let frozen = slice(&["c.rs"], &["a.rs"], &["b.rs"]);
769        let out = subtract_disposed(&frozen, &disp(&[("a.rs", "worked"), ("c.rs", "skipped")]));
770        assert_eq!(out, slice(&[], &[], &["b.rs"]));
771    }
772
773    // ── AC9 — full engine advance over a moving HEAD ─────────────────────
774
775    fn git(repo: &Path, args: &[&str]) {
776        let out = std::process::Command::new("git")
777            .args(args)
778            .current_dir(repo)
779            .env("GIT_AUTHOR_NAME", "t")
780            .env("GIT_AUTHOR_EMAIL", "t@t")
781            .env("GIT_COMMITTER_NAME", "t")
782            .env("GIT_COMMITTER_EMAIL", "t@t")
783            .output()
784            .unwrap();
785        assert!(
786            out.status.success(),
787            "git {args:?}: {}",
788            String::from_utf8_lossy(&out.stderr)
789        );
790    }
791
792    fn head_sha(repo: &Path) -> String {
793        String::from_utf8(
794            std::process::Command::new("git")
795                .args(["rev-parse", "HEAD"])
796                .current_dir(repo)
797                .output()
798                .unwrap()
799                .stdout,
800        )
801        .unwrap()
802        .trim()
803        .to_string()
804    }
805
806    /// A discovery-mode resolved binding whose one primary source is a git
807    /// codebase rooted at the workspace root (medium pointer `""`), scoped to
808    /// `**/*.rs`, keyed `engine/graph` → dest mem `engine`.
809    fn resolved_engine_graph() -> ResolvedIngest {
810        use super::super::resolve::{ResolvedSource, Source};
811        ResolvedIngest {
812            name: "engine/graph".to_string(),
813            mode: BuildMode::Discovery,
814            trigger: IngestTrigger::Loop,
815            batch_size: 20,
816            deny_paths: vec![],
817            projection_ref: "engine/graph".to_string(),
818            projection_mem: "engine".to_string(),
819            projection_name: "graph".to_string(),
820            intent: None,
821            sources: vec![ResolvedSource::Primary(Source {
822                name: "source-tree".to_string(),
823                medium_type: MediumType::Codebase,
824                pointer: String::new(),
825                change_detection: Some("git".to_string()),
826                scope: vec![PatternEntry {
827                    path: "**/*.rs".to_string(),
828                    mode: PatternMode::Allow,
829                }],
830                engagement: None,
831                preparation: None,
832            })],
833            destination_mem: "engine".to_string(),
834            rules: None,
835            post_actions: None,
836        }
837    }
838
839    /// Build an engine over one writable folder mem `engine` rooted at `root`
840    /// (which is also the git source tree), with a `.memstead/config.json` so
841    /// `sync_state` can be read/written.
842    fn engine_at(root: &Path) -> Engine {
843        // Seed the mem config **once** — a later rebuild must not clobber the
844        // `sync_state` a prior engine persisted (that is what makes the
845        // resumability leg meaningful: each `engine_at` models a fresh process).
846        let config_path = root.join(".memstead").join("config.json");
847        if !config_path.exists() {
848            std::fs::create_dir_all(root.join(".memstead")).unwrap();
849            std::fs::write(&config_path, br#"{"format":1,"schema":"default@1.0.0"}"#).unwrap();
850        }
851        let mount = Mount {
852            mem: "engine".to_string(),
853            schema: Some("default@1.0.0".parse().unwrap()),
854            storage: MountStorage::Folder {
855                path: root.to_path_buf(),
856            },
857            capability: MountCapability::Write,
858            lifecycle: MountLifecycle::Eager,
859            cross_linkable: false,
860            migration_target: None,
861        };
862        Engine::from_mounts(vec![(
863            mount,
864            Box::new(FilesystemMemWriter::new(root.to_path_buf()))
865                as Box<dyn crate::backend::MemBackend>,
866        )])
867        .unwrap()
868    }
869
870    fn synced_key() -> &'static str {
871        "engine/graph/source-tree#synced"
872    }
873
874    /// AC9 — `projection advance` is non-stalling under a moving HEAD, and its
875    /// gate + resumability hold:
876    ///
877    /// 1. freeze a slice, dispose part → the remainder is the rest;
878    /// 2. an unknown artifact id refuses the whole call **atomically** (the
879    ///    store is byte-identical after the refusal);
880    /// 3. a fresh process (new engine) honors the on-disk dispositions
881    ///    (resumability is on-disk, not in-memory);
882    /// 4. the source HEAD advances mid-pass → the re-presented slice equals
883    ///    (old remainder + new deltas) with disposed artifacts absent;
884    /// 5. disposing the rest empties the remainder → the `#synced` token
885    ///    advances via the engine writer to the current HEAD.
886    #[test]
887    fn advance_is_non_stalling_under_a_moving_head_with_gate_and_resumability() {
888        let tmp = TempDir::new().unwrap();
889        let root = tmp.path();
890
891        // Source git tree: baseline commit with a.rs + b.rs.
892        git(root, &["init", "-q"]);
893        std::fs::write(root.join("a.rs"), "one").unwrap();
894        std::fs::write(root.join("b.rs"), "bee").unwrap();
895        git(root, &["add", "a.rs", "b.rs"]);
896        git(root, &["commit", "-qm", "base"]);
897        let baseline = head_sha(root);
898
899        // Move to head1: modify a.rs, delete b.rs.
900        std::fs::write(root.join("a.rs"), "one-longer").unwrap();
901        std::fs::remove_file(root.join("b.rs")).unwrap();
902        git(root, &["add", "-A"]);
903        git(root, &["commit", "-qm", "head1"]);
904
905        let resolved = resolved_engine_graph();
906
907        // Seed the `#synced` baseline so the source shows a real moved slice.
908        {
909            let mut engine = engine_at(root);
910            engine
911                .set_mem_sync_state("engine", synced_key(), &baseline, None)
912                .unwrap();
913        }
914
915        // (1) Freeze + dispose part (a.rs). Remainder = the rest (b.rs deleted).
916        {
917            let mut engine = engine_at(root);
918            let out = advance_baseline(&mut engine, root, &resolved, &input(&[("a.rs", "worked")]))
919                .unwrap();
920            assert!(!out.completed, "one artifact still pending");
921            assert_eq!(out.remainder, slice(&[], &[], &["b.rs"]));
922            assert_eq!(out.pending, 1);
923            assert_eq!(out.disposed, 1);
924        }
925        // The dispositions persisted to disk.
926        let on_disk = read_advance_store(root, "engine", "graph")
927            .unwrap()
928            .unwrap();
929        assert_eq!(on_disk.dispositions, disp(&[("a.rs", "worked")]));
930
931        // (2) An unknown artifact id refuses the whole call atomically — the
932        // store is byte-identical afterwards (no partial write).
933        let before = std::fs::read(advance_store_path(root, "engine", "graph")).unwrap();
934        {
935            let mut engine = engine_at(root);
936            let err = advance_baseline(
937                &mut engine,
938                root,
939                &resolved,
940                &input(&[("never-presented.rs", "worked")]),
941            )
942            .unwrap_err();
943            assert!(
944                matches!(err, AdvanceError::UnknownArtifact { .. }),
945                "expected UnknownArtifact, got {err:?}"
946            );
947        }
948        let after = std::fs::read(advance_store_path(root, "engine", "graph")).unwrap();
949        assert_eq!(before, after, "refused call must not touch the store");
950
951        // (4) Source moves mid-pass → add c.rs at head2.
952        std::fs::write(root.join("c.rs"), "cee").unwrap();
953        git(root, &["add", "-A"]);
954        git(root, &["commit", "-qm", "head2"]);
955
956        // (3)+(4) A fresh engine (new process) honors the on-disk a.rs
957        // disposition, and re-presents (old remainder [b.rs] + new delta [c.rs])
958        // with the disposed a.rs absent. Empty dispositions = pure re-present.
959        {
960            let mut engine = engine_at(root);
961            let out = advance_baseline(&mut engine, root, &resolved, &BTreeMap::new()).unwrap();
962            assert!(!out.completed);
963            assert_eq!(
964                out.remainder,
965                slice(&["c.rs"], &[], &["b.rs"]),
966                "re-present = old remainder (b.rs) + new delta (c.rs); disposed a.rs absent"
967            );
968            assert_eq!(out.disposed, 1, "no new disposition this call");
969        }
970
971        // (5) Dispose the rest → remainder empties → the token advances.
972        let head2 = head_sha(root);
973        {
974            let mut engine = engine_at(root);
975            let out = advance_baseline(
976                &mut engine,
977                root,
978                &resolved,
979                &input(&[("b.rs", "worked"), ("c.rs", "worked")]),
980            )
981            .unwrap();
982            assert!(out.completed, "every artifact disposed → complete");
983            assert_eq!(out.pending, 0);
984            assert_eq!(out.tokens_written, vec![synced_key().to_string()]);
985
986            // The `#synced` baseline advanced to the current HEAD (head2).
987            let token = engine
988                .mem_config_for("engine")
989                .and_then(|c| c.sync_state.get(synced_key()).cloned());
990            assert_eq!(token.as_deref(), Some(head2.as_str()));
991        }
992        // The durable store was dropped on completion.
993        assert!(
994            read_advance_store(root, "engine", "graph")
995                .unwrap()
996                .is_none()
997        );
998    }
999
1000    /// The durable authored-exclusion ledger survives completion (unlike the
1001    /// transient dispositions/frozen slice), and a later non-excluded verdict for
1002    /// the same artifact clears it — dropping the store when nothing durable is
1003    /// left. This is the persistence the fidelity report relies on so an
1004    /// excluded-on-purpose artifact stops re-surfacing as `uncovered`.
1005    #[test]
1006    fn advance_retains_authored_exclusions_past_completion_and_clears_on_rejudge() {
1007        let tmp = TempDir::new().unwrap();
1008        let root = tmp.path();
1009
1010        // Baseline a.rs; move to head1 (modify a.rs) so the slice = {modified a.rs}.
1011        git(root, &["init", "-q"]);
1012        std::fs::write(root.join("a.rs"), "one").unwrap();
1013        git(root, &["add", "a.rs"]);
1014        git(root, &["commit", "-qm", "base"]);
1015        let baseline = head_sha(root);
1016        std::fs::write(root.join("a.rs"), "one-longer").unwrap();
1017        git(root, &["add", "-A"]);
1018        git(root, &["commit", "-qm", "head1"]);
1019
1020        let resolved = resolved_engine_graph();
1021        {
1022            let mut engine = engine_at(root);
1023            engine
1024                .set_mem_sync_state("engine", synced_key(), &baseline, None)
1025                .unwrap();
1026        }
1027
1028        // Dispose a.rs as EXCLUDED with a rationale → the only slice artifact is
1029        // disposed → the advance completes. Exclusions are non-empty, so the
1030        // store is RETAINED (not dropped) holding only the exclusion.
1031        let excluded = {
1032            let mut m = BTreeMap::new();
1033            m.insert(
1034                "a.rs".to_string(),
1035                DispositionInput::Reasoned {
1036                    disposition: EXCLUDED_VERDICT.to_string(),
1037                    rationale: "mined; warrants no destination entity".to_string(),
1038                },
1039            );
1040            m
1041        };
1042        {
1043            let mut engine = engine_at(root);
1044            let out = advance_baseline(&mut engine, root, &resolved, &excluded).unwrap();
1045            assert!(out.completed, "the sole slice artifact was disposed");
1046        }
1047        let retained = read_advance_store(root, "engine", "graph")
1048            .unwrap()
1049            .expect("an authored exclusion keeps the store alive past completion");
1050        assert!(
1051            retained.frozen_slice == Slice::default() && retained.dispositions.is_empty(),
1052            "transient progress is dropped on completion"
1053        );
1054        assert_eq!(
1055            retained.exclusions.get("a.rs").map(String::as_str),
1056            Some("mined; warrants no destination entity"),
1057            "the durable exclusion + its rationale persist"
1058        );
1059
1060        // Move to head2 (modify a.rs again) → a.rs re-enters the slice → re-judge
1061        // it as `worked`. The non-excluded verdict clears the stale exclusion, and
1062        // with nothing durable left the store is dropped.
1063        std::fs::write(root.join("a.rs"), "one-longer-still").unwrap();
1064        git(root, &["add", "-A"]);
1065        git(root, &["commit", "-qm", "head2"]);
1066        {
1067            let mut engine = engine_at(root);
1068            let out = advance_baseline(&mut engine, root, &resolved, &input(&[("a.rs", "worked")]))
1069                .unwrap();
1070            assert!(out.completed);
1071        }
1072        assert!(
1073            read_advance_store(root, "engine", "graph")
1074                .unwrap()
1075                .is_none(),
1076            "re-judging the artifact cleared the exclusion; nothing durable remains"
1077        );
1078    }
1079
1080    /// Criterion: a **medium-relative** artifact id (the form agents naturally
1081    /// type — `a.rs` when the engine printed `sub/a.rs`) refuses with a typed,
1082    /// remedy-bearing message that names the workspace-relative dialect and
1083    /// the concrete corrected id when derivable. REFUSALS: the gate never
1084    /// widens — the medium-relative form is never accepted, nothing is
1085    /// written; an unknown id with no derivable correction carries no
1086    /// suggestion.
1087    #[test]
1088    fn advance_unknown_artifact_names_dialect_and_suggests_corrected_id() {
1089        let tmp = TempDir::new().unwrap();
1090        let root = tmp.path();
1091
1092        // Source files live under the medium subtree `sub/` — artifact ids in
1093        // the slice are workspace-relative (`sub/a.rs`).
1094        git(root, &["init", "-q"]);
1095        std::fs::create_dir_all(root.join("sub")).unwrap();
1096        std::fs::write(root.join("sub").join("a.rs"), "one").unwrap();
1097        git(root, &["add", "-A"]);
1098        git(root, &["commit", "-qm", "base"]);
1099        let baseline = head_sha(root);
1100        std::fs::write(root.join("sub").join("a.rs"), "one-longer").unwrap();
1101        git(root, &["add", "-A"]);
1102        git(root, &["commit", "-qm", "head1"]);
1103
1104        let mut resolved = resolved_engine_graph();
1105        if let ResolvedSource::Primary(p) = &mut resolved.sources[0] {
1106            p.pointer = "sub".to_string();
1107        }
1108        {
1109            let mut engine = engine_at(root);
1110            engine
1111                .set_mem_sync_state("engine", synced_key(), &baseline, None)
1112                .unwrap();
1113        }
1114
1115        // The medium-relative id refuses; the message names the dialect and
1116        // the corrected id; the details pair maps supplied → corrected. An id
1117        // with no derivable correction rides the same refusal suggestion-free.
1118        {
1119            let mut engine = engine_at(root);
1120            let err = advance_baseline(
1121                &mut engine,
1122                root,
1123                &resolved,
1124                &input(&[("a.rs", "worked"), ("zzz.rs", "worked")]),
1125            )
1126            .unwrap_err();
1127            let AdvanceError::UnknownArtifact {
1128                artifacts,
1129                suggestions,
1130                ..
1131            } = &err
1132            else {
1133                panic!("expected UnknownArtifact, got {err:?}");
1134            };
1135            assert_eq!(artifacts, &vec!["a.rs".to_string(), "zzz.rs".to_string()]);
1136            assert_eq!(
1137                suggestions,
1138                &vec![("a.rs".to_string(), "sub/a.rs".to_string())],
1139                "only the medium-relative id gets a corrected form; zzz.rs has none"
1140            );
1141            let msg = err.to_string();
1142            assert!(
1143                msg.contains("workspace-relative"),
1144                "names the dialect: {msg}"
1145            );
1146            assert!(
1147                msg.contains("`a.rs` → `sub/a.rs`"),
1148                "carries the concrete corrected id: {msg}"
1149            );
1150            assert!(
1151                msg.contains("never accepted"),
1152                "states the dialect does not widen: {msg}"
1153            );
1154        }
1155        // The refusal wrote nothing (the gate stayed atomic).
1156        assert!(
1157            read_advance_store(root, "engine", "graph")
1158                .unwrap()
1159                .is_none(),
1160            "a refused call must not create the advance store"
1161        );
1162
1163        // The corrected workspace-relative id is the one the gate accepts.
1164        {
1165            let mut engine = engine_at(root);
1166            let out = advance_baseline(
1167                &mut engine,
1168                root,
1169                &resolved,
1170                &input(&[("sub/a.rs", "worked")]),
1171            )
1172            .unwrap();
1173            assert!(out.completed, "the sole slice artifact was disposed");
1174        }
1175    }
1176
1177    /// `record_exclusions` gates on enumerable `S(D)` membership (not the changed
1178    /// slice), so a **stable, unchanged** in-scope artifact can be declared
1179    /// excluded — the direct write path the option-(a) migration needs. A
1180    /// non-member refuses the whole call atomically; a re-declare merges.
1181    #[test]
1182    fn record_exclusions_gates_on_source_membership_and_merges() {
1183        let tmp = TempDir::new().unwrap();
1184        let root = tmp.path();
1185
1186        // A source tree with two in-scope `.rs` members. No commits move after
1187        // this — the artifacts are stable, never in a changed slice.
1188        git(root, &["init", "-q"]);
1189        std::fs::write(root.join("a.rs"), "one").unwrap();
1190        std::fs::write(root.join("b.rs"), "two").unwrap();
1191        git(root, &["add", "-A"]);
1192        git(root, &["commit", "-qm", "base"]);
1193
1194        let resolved = resolved_engine_graph();
1195
1196        // Declare a.rs excluded with a rationale — accepted (S(D) member).
1197        let out = record_exclusions(
1198            &Engine::from_mounts(Vec::new()).unwrap(),
1199            root,
1200            &resolved,
1201            &BTreeMap::from([("a.rs".to_string(), "mined; no entity".to_string())]),
1202        )
1203        .unwrap();
1204        assert_eq!((out.added, out.excluded), (1, 1));
1205        let state = read_advance_store(root, "engine", "graph")
1206            .unwrap()
1207            .unwrap();
1208        assert_eq!(
1209            state.exclusions.get("a.rs").map(String::as_str),
1210            Some("mined; no entity")
1211        );
1212
1213        // An artifact outside S(D) refuses the whole call — the store is untouched.
1214        let err = record_exclusions(
1215            &Engine::from_mounts(Vec::new()).unwrap(),
1216            root,
1217            &resolved,
1218            &BTreeMap::from([("does-not-exist.rs".to_string(), "x".to_string())]),
1219        )
1220        .unwrap_err();
1221        assert!(
1222            matches!(err, ExcludeError::NotSourceMember { .. }),
1223            "got {err:?}"
1224        );
1225        assert_eq!(
1226            read_advance_store(root, "engine", "graph")
1227                .unwrap()
1228                .unwrap()
1229                .exclusions
1230                .len(),
1231            1,
1232            "refused call left the ledger unchanged"
1233        );
1234
1235        // Re-declaring merges (b.rs added alongside a.rs).
1236        let out2 = record_exclusions(
1237            &Engine::from_mounts(Vec::new()).unwrap(),
1238            root,
1239            &resolved,
1240            &BTreeMap::from([("b.rs".to_string(), "also mined".to_string())]),
1241        )
1242        .unwrap();
1243        assert_eq!((out2.added, out2.excluded), (1, 2));
1244    }
1245
1246    /// `DispositionInput` parses both the bare-verdict and the reasoned forms
1247    /// from one `--dispositions` payload (serde `untagged`).
1248    #[test]
1249    fn disposition_input_parses_bare_and_reasoned_forms() {
1250        let map: BTreeMap<String, DispositionInput> = serde_json::from_str(
1251            r#"{"a.rs": "worked", "b.rs": {"disposition": "excluded", "rationale": "generated"}}"#,
1252        )
1253        .unwrap();
1254        assert_eq!(map["a.rs"].verdict(), "worked");
1255        assert_eq!(map["a.rs"].rationale(), None);
1256        assert_eq!(map["b.rs"].verdict(), EXCLUDED_VERDICT);
1257        assert_eq!(map["b.rs"].rationale(), Some("generated"));
1258    }
1259
1260    /// Criterion 4 (backlog-sweep plan 03a): the auto-`worked` matching
1261    /// understands the SOURCE dialect — an anchor written source-relative
1262    /// (`f.rs` + `source` name, decision 26) marks the pointer-joined slice
1263    /// artifact (`srcdir/f.rs`) worked, exactly as a workspace-relative
1264    /// anchor would. Requires a workspace root + pipeline store so the
1265    /// source name resolves to its pointer.
1266    #[test]
1267    fn advance_auto_worked_matches_source_dialect_anchors() {
1268        use crate::binding::{BINDING_VERSION, Binding, Operations};
1269        use crate::vcs::Actor;
1270        use indexmap::IndexMap;
1271
1272        let tmp = TempDir::new().unwrap();
1273        let root = tmp.path();
1274
1275        git(root, &["init", "-q"]);
1276        std::fs::create_dir_all(root.join("srcdir")).unwrap();
1277        std::fs::write(root.join(".keep"), "x").unwrap();
1278        git(root, &["add", ".keep"]);
1279        git(root, &["commit", "-qm", "base"]);
1280        let baseline = head_sha(root);
1281
1282        // The pipeline store carries the binding that maps source name
1283        // `source-tree` → pointer `srcdir` for mem `engine`.
1284        let binding = Binding {
1285            version: BINDING_VERSION,
1286            intent: None,
1287            sources: vec![crate::pipeline::Source {
1288                name: "source-tree".to_string(),
1289                medium_type: crate::pipeline::MediumType::Codebase,
1290                pointer: "srcdir".to_string(),
1291                change_detection: Some("git".to_string()),
1292                scope: vec![PatternEntry {
1293                    path: "**/*.rs".to_string(),
1294                    mode: PatternMode::Allow,
1295                }],
1296                engagement: None,
1297                preparation: None,
1298            }],
1299            reference_mems: Vec::new(),
1300            destination_mem: "engine".to_string(),
1301            deny_paths: Vec::new(),
1302            coverage_semantics: None,
1303            rules: None,
1304            prune: None,
1305            operations: Operations {
1306                build: None,
1307                sync: None,
1308                verify: None,
1309            },
1310        };
1311        let dir = root.join(".memstead").join("projections").join("engine");
1312        std::fs::create_dir_all(&dir).unwrap();
1313        std::fs::write(
1314            dir.join("graph.json"),
1315            serde_json::to_string_pretty(&binding).unwrap(),
1316        )
1317        .unwrap();
1318
1319        // The resolved ingest's source points at `srcdir`, so slice
1320        // artifact ids come out pointer-joined (`srcdir/f.rs`).
1321        let mut resolved = resolved_engine_graph();
1322        if let [ResolvedSource::Primary(p)] = resolved.sources.as_mut_slice() {
1323            p.pointer = "srcdir".to_string();
1324        } else {
1325            panic!("fixture shape");
1326        }
1327
1328        {
1329            let mut engine = engine_at(root);
1330            engine
1331                .set_mem_sync_state("engine", synced_key(), &baseline, None)
1332                .unwrap();
1333        }
1334
1335        std::fs::write(root.join("srcdir").join("f.rs"), "fn f() {}").unwrap();
1336        git(root, &["add", "-A"]);
1337        git(root, &["commit", "-qm", "head1"]);
1338
1339        // Anchored write in the SOURCE dialect: artifact `f.rs`, source
1340        // `source-tree` — no pointer prefix.
1341        let mut sections = IndexMap::new();
1342        sections.insert("identity".to_string(), "Covers f.".to_string());
1343        sections.insert("purpose".to_string(), "Track f.rs.".to_string());
1344        {
1345            let mut engine = engine_at(root);
1346            engine.set_workspace_root(root.to_path_buf());
1347            engine
1348                .create_entity(
1349                    crate::CreateEntityArgs {
1350                        mem: "engine".to_string(),
1351                        title: "Covers F".to_string(),
1352                        entity_type: "spec".to_string(),
1353                        sections,
1354                        metadata: IndexMap::new(),
1355                        relations: Vec::new(),
1356                        anchors: vec![crate::anchor::AnchorInput {
1357                            artifact: Some("f.rs".to_string()),
1358                            grain: Some("file".to_string()),
1359                            class: Some("anchored".to_string()),
1360                            hash: Some("h".to_string()),
1361                            hash_stability: Some("stable".to_string()),
1362                            source: Some("source-tree".to_string()),
1363                            ..Default::default()
1364                        }],
1365                        dry_run: false,
1366                    },
1367                    Actor::Agent,
1368                    None,
1369                    Some("source-dialect anchored write"),
1370                )
1371                .unwrap();
1372        }
1373
1374        // Advance with NO explicit dispositions: `srcdir/f.rs` (the slice
1375        // form) auto-works from the source-dialect anchor.
1376        let mut engine = engine_at(root);
1377        engine.set_workspace_root(root.to_path_buf());
1378        let out = advance_baseline(&mut engine, root, &resolved, &BTreeMap::new()).unwrap();
1379        assert!(
1380            out.completed,
1381            "the source-dialect anchor auto-worked the joined slice artifact: {out:?}"
1382        );
1383        assert_eq!(out.disposed, 1);
1384    }
1385
1386    /// AC9a — an anchored write auto-marks its referenced frozen-slice
1387    /// artifacts `worked`, so `advance` needs an explicit disposition only for
1388    /// the residue, held across a HEAD move. Refusals: an artifact with no
1389    /// anchor is never auto-worked, and an anchor referencing an artifact
1390    /// OUTSIDE the presented slice fabricates no slice entry.
1391    #[test]
1392    fn advance_auto_worked_from_anchors_subtracts_slice_and_never_fabricates() {
1393        use crate::vcs::Actor;
1394        use indexmap::IndexMap;
1395
1396        let tmp = TempDir::new().unwrap();
1397        let root = tmp.path();
1398
1399        // Baseline: a commit carrying no `.rs` files. `#synced` pins it, so the
1400        // moved slice below is purely the added `.rs` sources.
1401        git(root, &["init", "-q"]);
1402        std::fs::write(root.join(".keep"), "x").unwrap();
1403        git(root, &["add", ".keep"]);
1404        git(root, &["commit", "-qm", "base"]);
1405        let baseline = head_sha(root);
1406
1407        let resolved = resolved_engine_graph();
1408        {
1409            let mut engine = engine_at(root);
1410            engine
1411                .set_mem_sync_state("engine", synced_key(), &baseline, None)
1412                .unwrap();
1413        }
1414
1415        // head1: add a.rs + b.rs → slice = added [a.rs, b.rs].
1416        std::fs::write(root.join("a.rs"), "one").unwrap();
1417        std::fs::write(root.join("b.rs"), "bee").unwrap();
1418        git(root, &["add", "a.rs", "b.rs"]);
1419        git(root, &["commit", "-qm", "head1"]);
1420
1421        // An anchored write into the destination mem `engine`: entity
1422        // `covers-a` file-anchors `a.rs` (inside the slice) AND `zzz.rs`
1423        // (outside it — must fabricate nothing).
1424        let make_anchor = |artifact: &str| crate::anchor::AnchorInput {
1425            artifact: Some(artifact.to_string()),
1426            grain: Some("file".to_string()),
1427            class: Some("anchored".to_string()),
1428            hash: Some("h".to_string()),
1429            hash_stability: Some("stable".to_string()),
1430            ..Default::default()
1431        };
1432        let mut sections = IndexMap::new();
1433        sections.insert("identity".to_string(), "Covers a.".to_string());
1434        sections.insert("purpose".to_string(), "Track a.rs.".to_string());
1435        {
1436            let mut engine = engine_at(root);
1437            engine
1438                .create_entity(
1439                    crate::CreateEntityArgs {
1440                        mem: "engine".to_string(),
1441                        title: "Covers A".to_string(),
1442                        entity_type: "spec".to_string(),
1443                        sections,
1444                        metadata: IndexMap::new(),
1445                        relations: Vec::new(),
1446                        anchors: vec![make_anchor("a.rs"), make_anchor("zzz.rs")],
1447                        dry_run: false,
1448                    },
1449                    Actor::Agent,
1450                    None,
1451                    Some("anchored write"),
1452                )
1453                .unwrap();
1454        }
1455
1456        // (1) Advance with NO explicit dispositions → a.rs auto-worked from the
1457        // anchor; b.rs (no anchor) stays pending; zzz.rs (outside the slice)
1458        // fabricates nothing.
1459        {
1460            let mut engine = engine_at(root);
1461            let out = advance_baseline(&mut engine, root, &resolved, &BTreeMap::new()).unwrap();
1462            assert!(!out.completed, "b.rs still pending");
1463            assert_eq!(
1464                out.remainder,
1465                slice(&["b.rs"], &[], &[]),
1466                "a.rs auto-worked from its anchor; zzz.rs never became a slice member"
1467            );
1468            assert_eq!(out.disposed, 1, "only a.rs auto-worked");
1469            assert_eq!(out.pending, 1);
1470        }
1471
1472        // (2) HEAD moves (add c.rs). Re-present with no dispositions → old
1473        // remainder [b.rs] + new delta [c.rs]; a.rs stays absent (its
1474        // auto-`worked` persisted); c.rs is unanchored so it is NOT auto-worked.
1475        std::fs::write(root.join("c.rs"), "cee").unwrap();
1476        git(root, &["add", "-A"]);
1477        git(root, &["commit", "-qm", "head2"]);
1478        {
1479            let mut engine = engine_at(root);
1480            let out = advance_baseline(&mut engine, root, &resolved, &BTreeMap::new()).unwrap();
1481            assert_eq!(
1482                out.remainder,
1483                slice(&["b.rs", "c.rs"], &[], &[]),
1484                "auto-worked a.rs absent; unanchored b.rs + new c.rs pending"
1485            );
1486            assert_eq!(
1487                out.disposed, 1,
1488                "still only a.rs auto-worked; c.rs unanchored"
1489            );
1490            assert!(!out.completed);
1491        }
1492    }
1493}