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(¬e))
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}