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