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