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 id.
191    #[error(
192        "disposition names {} artifact id(s) the engine did not present: {}; the advance gate \
193         accepts only ids from the presented slice ({printed} presented)",
194        artifacts.len(),
195        fmt_list(artifacts)
196    )]
197    UnknownArtifact {
198        /// The offending, never-presented ids (sorted).
199        artifacts: Vec<String>,
200        /// How many ids the engine did present (the accepted set size).
201        printed: usize,
202    },
203    /// Reading or writing the durable advance store failed.
204    #[error("advance store error: {0}")]
205    Store(#[source] StoreError),
206    /// The `set_mem_sync_state` baseline write failed on completion.
207    #[error("could not advance baseline token: {0}")]
208    Engine(String),
209}
210
211/// Render an id list for an error message: `a, b, c` or `(none)`.
212fn fmt_list(names: &[String]) -> String {
213    if names.is_empty() {
214        "(none)".to_string()
215    } else {
216        names.join(", ")
217    }
218}
219
220/// Split a canonical binding id `<mem>/<stem>` into its two single-component
221/// halves, or refuse. Mirrors the store's component guard so a caller-supplied
222/// id can never escape the `.memstead/state/advance/` tier.
223fn split_binding_id(binding_id: &str) -> Result<(String, String), AdvanceError> {
224    binding_id
225        .split_once('/')
226        .filter(|(m, n)| is_single_component(m) && is_single_component(n))
227        .map(|(m, n)| (m.to_string(), n.to_string()))
228        .ok_or_else(|| AdvanceError::MalformedId(binding_id.to_string()))
229}
230
231/// Is `value` a single, plain path component — safe as a `<mem>` / `<name>`
232/// directory or file segment? (No separators, traversal segments, drive/stream
233/// colon, or NUL.) Shared with the findings store's identical path guard.
234pub(crate) fn is_single_component(value: &str) -> bool {
235    !value.is_empty()
236        && value != "."
237        && value != ".."
238        && !value.contains('/')
239        && !value.contains('\\')
240        && !value.contains(':')
241        && !value.contains('\0')
242}
243
244/// The durable store path for a binding: `.memstead/state/advance/<mem>/<name>.json`.
245pub fn advance_store_path(workspace_root: &Path, mem: &str, name: &str) -> PathBuf {
246    workspace_root
247        .join(WORKSPACE_STORE_DIR)
248        .join(STATE_DIR)
249        .join(ADVANCE_DIR)
250        .join(mem)
251        .join(format!("{name}.json"))
252}
253
254/// Read the durable advance state for a binding, or `None` when none exists
255/// (never advanced, or completed and dropped). A malformed file surfaces a
256/// typed [`StoreError::Parse`] naming the path.
257pub fn read_advance_store(
258    workspace_root: &Path,
259    mem: &str,
260    name: &str,
261) -> Result<Option<AdvanceState>, StoreError> {
262    let path = advance_store_path(workspace_root, mem, name);
263    match std::fs::read(&path) {
264        Ok(bytes) => serde_json::from_slice(&bytes)
265            .map(Some)
266            .map_err(|e| StoreError::Parse {
267                path,
268                message: e.to_string(),
269            }),
270        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
271        Err(e) => Err(StoreError::Io { path, source: e }),
272    }
273}
274
275/// Persist the durable advance state for a binding (pretty JSON), creating
276/// parent directories.
277pub fn write_advance_store(
278    workspace_root: &Path,
279    mem: &str,
280    name: &str,
281    state: &AdvanceState,
282) -> Result<(), StoreError> {
283    let path = advance_store_path(workspace_root, mem, name);
284    if let Some(parent) = path.parent() {
285        std::fs::create_dir_all(parent).map_err(|e| StoreError::Io {
286            path: parent.to_path_buf(),
287            source: e,
288        })?;
289    }
290    let bytes = serde_json::to_vec_pretty(state).map_err(|e| StoreError::Parse {
291        path: path.clone(),
292        message: e.to_string(),
293    })?;
294    std::fs::write(&path, bytes).map_err(|e| StoreError::Io { path, source: e })
295}
296
297/// Drop the durable advance store for a binding (called on completion). A
298/// missing file is a successful no-op — completion is idempotent.
299pub fn delete_advance_store(
300    workspace_root: &Path,
301    mem: &str,
302    name: &str,
303) -> Result<(), StoreError> {
304    let path = advance_store_path(workspace_root, mem, name);
305    match std::fs::remove_file(&path) {
306        Ok(()) => Ok(()),
307        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
308        Err(e) => Err(StoreError::Io { path, source: e }),
309    }
310}
311
312/// Union `from` into `into`, keeping each class sorted + de-duplicated.
313fn union_slice(into: &mut Slice, from: &Slice) {
314    into.added.extend(from.added.iter().cloned());
315    into.modified.extend(from.modified.iter().cloned());
316    into.deleted.extend(from.deleted.iter().cloned());
317    for v in [&mut into.added, &mut into.modified, &mut into.deleted] {
318        v.sort();
319        v.dedup();
320    }
321}
322
323/// The full set of artifact ids a slice presents (across all three classes) —
324/// the accepted set for the advance gate.
325fn artifact_set(slice: &Slice) -> BTreeSet<String> {
326    slice
327        .added
328        .iter()
329        .chain(slice.modified.iter())
330        .chain(slice.deleted.iter())
331        .cloned()
332        .collect()
333}
334
335/// The remainder slice: the frozen slice with every disposed id removed from
336/// each class (disposed artifacts absent, D7).
337fn subtract_disposed(frozen: &Slice, dispositions: &BTreeMap<String, String>) -> Slice {
338    let keep = |v: &[String]| -> Vec<String> {
339        v.iter()
340            .filter(|a| !dispositions.contains_key(*a))
341            .cloned()
342            .collect()
343    };
344    Slice {
345        added: keep(&frozen.added),
346        modified: keep(&frozen.modified),
347        deleted: keep(&frozen.deleted),
348    }
349}
350
351/// The disposition-gated baseline advance (D7).
352///
353/// Freezes the currently-presented slice (or reloads a frozen one), appends any
354/// new-HEAD deltas, gates the supplied dispositions against the presented ids
355/// (atomic — an unknown id refuses before any write), accumulates them, and
356/// re-presents the remainder with disposed artifacts absent. When the remainder
357/// empties, the destination mem's `#synced` baseline token(s) advance through
358/// the engine's [`Engine::set_mem_sync_state`] writer — the provenance
359/// piggybacks that write's commit note, adding no new channel — and the durable
360/// store is dropped.
361///
362/// `resolved.name` must be the canonical binding id `<mem>/<stem>` (D3), as
363/// produced by [`super::resolve::resolve_binding_run`]; `dispositions` maps each
364/// judged artifact id to an agent-supplied [`DispositionInput`] — a bare verdict
365/// or a verdict with an authored rationale (in E2 the agent supplies one for
366/// **every** artifact — see the module docs). An `excluded` verdict with a
367/// rationale is recorded in the durable authored-exclusion ledger; any other
368/// verdict clears a prior exclusion for that artifact.
369pub fn advance_baseline(
370    engine: &mut Engine,
371    workspace_root: &Path,
372    resolved: &ResolvedIngest,
373    dispositions: &BTreeMap<String, DispositionInput>,
374) -> Result<AdvanceOutcome, AdvanceError> {
375    let binding_id = resolved.name.clone();
376    let (mem, name) = split_binding_id(&binding_id)?;
377
378    // Current source cursor (immutable borrow ends before the mutating writes).
379    // Its union is the slice relative to the *unchanged* `#synced` baseline, so
380    // when the source moves mid-pass this already reflects freeze + new deltas.
381    let cursor = compute_source_cursor(engine, resolved, workspace_root);
382
383    // Load-or-init the durable store (resumability is on-disk, not in-memory).
384    let mut state = read_advance_store(workspace_root, &mem, &name)
385        .map_err(AdvanceError::Store)?
386        .unwrap_or_else(|| AdvanceState {
387            binding: binding_id.clone(),
388            ..Default::default()
389        });
390
391    // Freeze / append: union the currently-presented slice into the frozen one.
392    union_slice(&mut state.frozen_slice, &cursor.union);
393    let printed = artifact_set(&state.frozen_slice);
394
395    // Gate (atomic): every disposition id must be one the engine presented.
396    // Validate BEFORE any disk write so a refusal leaves the store untouched.
397    let mut unknown: Vec<String> = dispositions
398        .keys()
399        .filter(|a| !printed.contains(a.as_str()))
400        .cloned()
401        .collect();
402    if !unknown.is_empty() {
403        unknown.sort();
404        unknown.dedup();
405        return Err(AdvanceError::UnknownArtifact {
406            artifacts: unknown,
407            printed: printed.len(),
408        });
409    }
410
411    // Accumulate the new (agent-supplied) dispositions. An `excluded` verdict
412    // with a rationale lands in the durable exclusion ledger (survives
413    // completion); any other verdict clears a prior exclusion for that artifact
414    // (a re-judged artifact must not keep stale "excluded" reasoning).
415    for (artifact, input) in dispositions {
416        state
417            .dispositions
418            .insert(artifact.clone(), input.verdict().to_string());
419        if input.verdict() == EXCLUDED_VERDICT {
420            state.exclusions.insert(
421                artifact.clone(),
422                input.rationale().unwrap_or("").to_string(),
423            );
424        } else {
425            state.exclusions.remove(artifact);
426        }
427    }
428
429    // Auto-`worked` (E3a): mark every frozen-slice artifact that an anchor in
430    // the destination mem now references. Reads the anchors sidecar, never a
431    // commit diff (D7's rejected mechanism stays rejected); scoped to the
432    // frozen slice (`printed`) so an anchored write outside the slice
433    // fabricates no entry; skips artifacts already carrying an explicit
434    // disposition (the agent's judgement wins).
435    let auto_worked: Vec<String> = printed
436        .iter()
437        .filter(|art| !state.dispositions.contains_key(art.as_str()))
438        .filter(|art| {
439            engine
440                .anchors_referencing_artifact(art)
441                .iter()
442                .any(|(eid, _)| eid.mem() == resolved.destination_mem.as_str())
443        })
444        .cloned()
445        .collect();
446    for art in auto_worked {
447        state.dispositions.insert(art, "worked".to_string());
448    }
449
450    // Re-present the remainder (disposed absent).
451    let remainder = subtract_disposed(&state.frozen_slice, &state.dispositions);
452    let pending = remainder.added.len() + remainder.modified.len() + remainder.deleted.len();
453    let completed = pending == 0;
454
455    let mut warnings: Vec<String> = Vec::new();
456    let mut tokens_written: Vec<String> = Vec::new();
457    if completed {
458        // Advance the baseline token for every facet that moved (current cursor
459        // tokens = the latest HEAD) via the engine writer. Provenance piggybacks
460        // the write's commit note — no new channel (D7).
461        let note = format!(
462            "projection advance {binding_id}: {} artifact(s) disposed, baseline advanced",
463            state.dispositions.len()
464        );
465        for c in cursor.write_commands.iter().chain(cursor.reseed.iter()) {
466            let outcome = engine
467                .set_mem_sync_state(&resolved.destination_mem, &c.key, &c.token, Some(&note))
468                .map_err(|e| AdvanceError::Engine(e.to_string()))?;
469            warnings.extend(outcome.warnings.iter().map(ToString::to_string));
470            tokens_written.push(c.key.clone());
471        }
472        // Transient progress (frozen slice + per-run dispositions) is consumed.
473        // If any durable authored exclusions accumulated, retain a slimmed store
474        // holding only them (empty slice, no transient dispositions) so the
475        // fidelity report keeps consulting them; otherwise drop the store
476        // entirely (completion idempotent — the no-exclusion path is unchanged).
477        if state.exclusions.is_empty() {
478            delete_advance_store(workspace_root, &mem, &name).map_err(AdvanceError::Store)?;
479        } else {
480            let durable = AdvanceState {
481                binding: binding_id.clone(),
482                frozen_slice: Slice::default(),
483                dispositions: BTreeMap::new(),
484                exclusions: state.exclusions.clone(),
485            };
486            write_advance_store(workspace_root, &mem, &name, &durable)
487                .map_err(AdvanceError::Store)?;
488        }
489    } else {
490        // Persist the accumulated frozen slice + dispositions for resumability.
491        write_advance_store(workspace_root, &mem, &name, &state).map_err(AdvanceError::Store)?;
492    }
493
494    Ok(AdvanceOutcome {
495        binding: binding_id,
496        remainder,
497        disposed: state.dispositions.len(),
498        pending,
499        completed,
500        tokens_written,
501        warnings,
502    })
503}
504
505/// The outcome of a [`record_exclusions`] call.
506#[derive(Debug, Clone, PartialEq, Eq)]
507pub struct ExcludeOutcome {
508    /// The binding id whose exclusion ledger was written.
509    pub binding: String,
510    /// Total authored exclusions in the ledger after this call (this call + prior).
511    pub excluded: usize,
512    /// How many supplied artifacts were newly added (not already in the ledger).
513    pub added: usize,
514}
515
516/// Why [`record_exclusions`] could not complete.
517#[derive(Debug, thiserror::Error)]
518pub enum ExcludeError {
519    /// The binding id is not the canonical `<mem>/<stem>` shape.
520    #[error("malformed binding id '{0}': expected `<mem>/<stem>`")]
521    MalformedId(String),
522    /// One or more artifacts are not members of the binding's enumerable source
523    /// `S(D)` — the gate refuses the whole call (no partial write). Names each.
524    #[error(
525        "exclusion names {} artifact id(s) not in the binding's enumerable source S(D): {}; \
526         only an in-scope source member can be declared excluded ({printed} enumerated)",
527        artifacts.len(),
528        fmt_list(artifacts)
529    )]
530    NotSourceMember {
531        /// The offending, non-member ids (sorted).
532        artifacts: Vec<String>,
533        /// How many artifacts `S(D)` did enumerate (the accepted set size).
534        printed: usize,
535    },
536    /// Reading or writing the durable advance store failed.
537    #[error("advance store error: {0}")]
538    Store(#[source] StoreError),
539}
540
541/// Declare **authored exclusions** for in-scope source artifacts — the direct
542/// write path for the durable exclusion ledger [`advance_baseline`] also feeds.
543///
544/// Unlike the advance gate (which accepts only artifacts in the *changed slice*),
545/// this gates on **enumerable `S(D)` membership**: an artifact must be a real
546/// in-scope member of the binding's source, and a *stable, unchanged* artifact
547/// qualifies. That is what a deliberate editorial exclusion is — "this in-scope
548/// artifact is mined and warrants no destination entity, because …" — a decision
549/// independent of change detection. Each accepted `(artifact, rationale)` lands
550/// in the ledger the fidelity report consults, so the artifact stops re-surfacing
551/// as `uncovered` under exhaustive coverage and keeps its reasoning. Atomic: an
552/// artifact outside `S(D)` refuses the whole call before any write. Merges into
553/// any in-flight advance store rather than clobbering it. Generic across every
554/// enumerable binding and medium.
555pub fn record_exclusions(
556    workspace_root: &Path,
557    resolved: &ResolvedIngest,
558    exclusions: &BTreeMap<String, String>,
559) -> Result<ExcludeOutcome, ExcludeError> {
560    let binding_id = resolved.name.clone();
561    let (mem, name) =
562        split_binding_id(&binding_id).map_err(|_| ExcludeError::MalformedId(binding_id.clone()))?;
563
564    // Enumerate S(D) — the in-scope source-artifact set, the same enumeration the
565    // fidelity report uses for its coverage denominator.
566    let mut s_d: BTreeSet<String> = BTreeSet::new();
567    for source in &resolved.sources {
568        if let ResolvedSource::Primary(p) = source {
569            for f in enumerate_facet_files(p, &resolved.deny_paths, workspace_root) {
570                s_d.insert(f);
571            }
572        }
573    }
574
575    // Gate (atomic): every exclusion id must be an S(D) member. Validate BEFORE
576    // any disk write so a refusal leaves the store untouched.
577    let mut not_member: Vec<String> = exclusions
578        .keys()
579        .filter(|a| !s_d.contains(a.as_str()))
580        .cloned()
581        .collect();
582    if !not_member.is_empty() {
583        not_member.sort();
584        not_member.dedup();
585        return Err(ExcludeError::NotSourceMember {
586            artifacts: not_member,
587            printed: s_d.len(),
588        });
589    }
590
591    // Merge into the durable exclusion ledger, preserving any in-flight advance
592    // progress already in the same store.
593    let mut state = read_advance_store(workspace_root, &mem, &name)
594        .map_err(ExcludeError::Store)?
595        .unwrap_or_else(|| AdvanceState {
596            binding: binding_id.clone(),
597            ..Default::default()
598        });
599    let mut added = 0usize;
600    for (artifact, rationale) in exclusions {
601        if state
602            .exclusions
603            .insert(artifact.clone(), rationale.clone())
604            .is_none()
605        {
606            added += 1;
607        }
608    }
609    write_advance_store(workspace_root, &mem, &name, &state).map_err(ExcludeError::Store)?;
610
611    Ok(ExcludeOutcome {
612        binding: binding_id,
613        excluded: state.exclusions.len(),
614        added,
615    })
616}
617
618#[cfg(test)]
619mod tests {
620    use super::*;
621    use crate::binding::BuildMode;
622    use crate::pipeline::{IngestTrigger, MediumType, PatternEntry, PatternMode};
623    use crate::storage::FilesystemMemWriter;
624    use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
625    use tempfile::TempDir;
626
627    // ── pure helpers ─────────────────────────────────────────────────────
628
629    fn slice(added: &[&str], modified: &[&str], deleted: &[&str]) -> Slice {
630        Slice {
631            added: added.iter().map(|s| s.to_string()).collect(),
632            modified: modified.iter().map(|s| s.to_string()).collect(),
633            deleted: deleted.iter().map(|s| s.to_string()).collect(),
634        }
635    }
636
637    fn disp(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
638        pairs
639            .iter()
640            .map(|(a, d)| (a.to_string(), d.to_string()))
641            .collect()
642    }
643
644    /// The [`DispositionInput`] map an `advance_baseline` call takes: bare
645    /// verdicts (the common form).
646    fn input(pairs: &[(&str, &str)]) -> BTreeMap<String, DispositionInput> {
647        pairs
648            .iter()
649            .map(|(a, d)| (a.to_string(), DispositionInput::Verdict(d.to_string())))
650            .collect()
651    }
652
653    /// The store round-trips and `delete` is idempotent.
654    #[test]
655    fn advance_store_round_trips_and_delete_is_idempotent() {
656        let tmp = TempDir::new().unwrap();
657        let root = tmp.path();
658        assert!(
659            read_advance_store(root, "engine", "graph")
660                .unwrap()
661                .is_none()
662        );
663
664        let state = AdvanceState {
665            binding: "engine/graph".to_string(),
666            frozen_slice: slice(&["c.rs"], &["a.rs"], &["b.rs"]),
667            dispositions: disp(&[("a.rs", "worked")]),
668            exclusions: BTreeMap::new(),
669        };
670        write_advance_store(root, "engine", "graph", &state).unwrap();
671        assert!(
672            advance_store_path(root, "engine", "graph")
673                .ends_with("state/advance/engine/graph.json")
674        );
675        let back = read_advance_store(root, "engine", "graph")
676            .unwrap()
677            .unwrap();
678        assert_eq!(back, state);
679
680        delete_advance_store(root, "engine", "graph").unwrap();
681        assert!(
682            read_advance_store(root, "engine", "graph")
683                .unwrap()
684                .is_none()
685        );
686        // Idempotent: deleting an absent store is a no-op, not an error.
687        delete_advance_store(root, "engine", "graph").unwrap();
688    }
689
690    /// `subtract_disposed` removes disposed ids from every class.
691    #[test]
692    fn subtract_disposed_removes_disposed_from_every_class() {
693        let frozen = slice(&["c.rs"], &["a.rs"], &["b.rs"]);
694        let out = subtract_disposed(&frozen, &disp(&[("a.rs", "worked"), ("c.rs", "skipped")]));
695        assert_eq!(out, slice(&[], &[], &["b.rs"]));
696    }
697
698    // ── AC9 — full engine advance over a moving HEAD ─────────────────────
699
700    fn git(repo: &Path, args: &[&str]) {
701        let out = std::process::Command::new("git")
702            .args(args)
703            .current_dir(repo)
704            .env("GIT_AUTHOR_NAME", "t")
705            .env("GIT_AUTHOR_EMAIL", "t@t")
706            .env("GIT_COMMITTER_NAME", "t")
707            .env("GIT_COMMITTER_EMAIL", "t@t")
708            .output()
709            .unwrap();
710        assert!(
711            out.status.success(),
712            "git {args:?}: {}",
713            String::from_utf8_lossy(&out.stderr)
714        );
715    }
716
717    fn head_sha(repo: &Path) -> String {
718        String::from_utf8(
719            std::process::Command::new("git")
720                .args(["rev-parse", "HEAD"])
721                .current_dir(repo)
722                .output()
723                .unwrap()
724                .stdout,
725        )
726        .unwrap()
727        .trim()
728        .to_string()
729    }
730
731    /// A discovery-mode resolved binding whose one primary source is a git
732    /// codebase rooted at the workspace root (medium pointer `""`), scoped to
733    /// `**/*.rs`, keyed `engine/graph` → dest mem `engine`.
734    fn resolved_engine_graph() -> ResolvedIngest {
735        use super::super::resolve::{ResolvedPrimarySource, ResolvedSource};
736        ResolvedIngest {
737            name: "engine/graph".to_string(),
738            mode: BuildMode::Discovery,
739            trigger: IngestTrigger::Loop,
740            batch_size: 20,
741            deny_paths: vec![],
742            projection_ref: "engine/graph".to_string(),
743            projection_mem: "engine".to_string(),
744            projection_name: "graph".to_string(),
745            intent: None,
746            sources: vec![ResolvedSource::Primary(ResolvedPrimarySource {
747                facet_ref: "source-tree".to_string(),
748                medium: "src".to_string(),
749                medium_type: MediumType::Codebase,
750                medium_pointer: String::new(),
751                declared_change_detection: Some("git".to_string()),
752                scope: vec![PatternEntry {
753                    path: "**/*.rs".to_string(),
754                    mode: PatternMode::Allow,
755                }],
756                preparation: None,
757            })],
758            destination_mem: "engine".to_string(),
759            rules: None,
760            post_actions: None,
761        }
762    }
763
764    /// Build an engine over one writable folder mem `engine` rooted at `root`
765    /// (which is also the git source tree), with a `.memstead/config.json` so
766    /// `sync_state` can be read/written.
767    fn engine_at(root: &Path) -> Engine {
768        // Seed the mem config **once** — a later rebuild must not clobber the
769        // `sync_state` a prior engine persisted (that is what makes the
770        // resumability leg meaningful: each `engine_at` models a fresh process).
771        let config_path = root.join(".memstead").join("config.json");
772        if !config_path.exists() {
773            std::fs::create_dir_all(root.join(".memstead")).unwrap();
774            std::fs::write(&config_path, br#"{"format":1,"schema":"default@1.0.0"}"#).unwrap();
775        }
776        let mount = Mount {
777            mem: "engine".to_string(),
778            schema: Some("default@1.0.0".parse().unwrap()),
779            storage: MountStorage::Folder {
780                path: root.to_path_buf(),
781            },
782            capability: MountCapability::Write,
783            lifecycle: MountLifecycle::Eager,
784            cross_linkable: false,
785            migration_target: None,
786        };
787        Engine::from_mounts(vec![(
788            mount,
789            Box::new(FilesystemMemWriter::new(root.to_path_buf()))
790                as Box<dyn crate::backend::MemBackend>,
791        )])
792        .unwrap()
793    }
794
795    fn synced_key() -> &'static str {
796        "engine/graph/source-tree#synced"
797    }
798
799    /// AC9 — `projection advance` is non-stalling under a moving HEAD, and its
800    /// gate + resumability hold:
801    ///
802    /// 1. freeze a slice, dispose part → the remainder is the rest;
803    /// 2. an unknown artifact id refuses the whole call **atomically** (the
804    ///    store is byte-identical after the refusal);
805    /// 3. a fresh process (new engine) honors the on-disk dispositions
806    ///    (resumability is on-disk, not in-memory);
807    /// 4. the source HEAD advances mid-pass → the re-presented slice equals
808    ///    (old remainder + new deltas) with disposed artifacts absent;
809    /// 5. disposing the rest empties the remainder → the `#synced` token
810    ///    advances via the engine writer to the current HEAD.
811    #[test]
812    fn advance_is_non_stalling_under_a_moving_head_with_gate_and_resumability() {
813        let tmp = TempDir::new().unwrap();
814        let root = tmp.path();
815
816        // Source git tree: baseline commit with a.rs + b.rs.
817        git(root, &["init", "-q"]);
818        std::fs::write(root.join("a.rs"), "one").unwrap();
819        std::fs::write(root.join("b.rs"), "bee").unwrap();
820        git(root, &["add", "a.rs", "b.rs"]);
821        git(root, &["commit", "-qm", "base"]);
822        let baseline = head_sha(root);
823
824        // Move to head1: modify a.rs, delete b.rs.
825        std::fs::write(root.join("a.rs"), "one-longer").unwrap();
826        std::fs::remove_file(root.join("b.rs")).unwrap();
827        git(root, &["add", "-A"]);
828        git(root, &["commit", "-qm", "head1"]);
829
830        let resolved = resolved_engine_graph();
831
832        // Seed the `#synced` baseline so the source shows a real moved slice.
833        {
834            let mut engine = engine_at(root);
835            engine
836                .set_mem_sync_state("engine", synced_key(), &baseline, None)
837                .unwrap();
838        }
839
840        // (1) Freeze + dispose part (a.rs). Remainder = the rest (b.rs deleted).
841        {
842            let mut engine = engine_at(root);
843            let out = advance_baseline(&mut engine, root, &resolved, &input(&[("a.rs", "worked")]))
844                .unwrap();
845            assert!(!out.completed, "one artifact still pending");
846            assert_eq!(out.remainder, slice(&[], &[], &["b.rs"]));
847            assert_eq!(out.pending, 1);
848            assert_eq!(out.disposed, 1);
849        }
850        // The dispositions persisted to disk.
851        let on_disk = read_advance_store(root, "engine", "graph")
852            .unwrap()
853            .unwrap();
854        assert_eq!(on_disk.dispositions, disp(&[("a.rs", "worked")]));
855
856        // (2) An unknown artifact id refuses the whole call atomically — the
857        // store is byte-identical afterwards (no partial write).
858        let before = std::fs::read(advance_store_path(root, "engine", "graph")).unwrap();
859        {
860            let mut engine = engine_at(root);
861            let err = advance_baseline(
862                &mut engine,
863                root,
864                &resolved,
865                &input(&[("never-presented.rs", "worked")]),
866            )
867            .unwrap_err();
868            assert!(
869                matches!(err, AdvanceError::UnknownArtifact { .. }),
870                "expected UnknownArtifact, got {err:?}"
871            );
872        }
873        let after = std::fs::read(advance_store_path(root, "engine", "graph")).unwrap();
874        assert_eq!(before, after, "refused call must not touch the store");
875
876        // (4) Source moves mid-pass → add c.rs at head2.
877        std::fs::write(root.join("c.rs"), "cee").unwrap();
878        git(root, &["add", "-A"]);
879        git(root, &["commit", "-qm", "head2"]);
880
881        // (3)+(4) A fresh engine (new process) honors the on-disk a.rs
882        // disposition, and re-presents (old remainder [b.rs] + new delta [c.rs])
883        // with the disposed a.rs absent. Empty dispositions = pure re-present.
884        {
885            let mut engine = engine_at(root);
886            let out = advance_baseline(&mut engine, root, &resolved, &BTreeMap::new()).unwrap();
887            assert!(!out.completed);
888            assert_eq!(
889                out.remainder,
890                slice(&["c.rs"], &[], &["b.rs"]),
891                "re-present = old remainder (b.rs) + new delta (c.rs); disposed a.rs absent"
892            );
893            assert_eq!(out.disposed, 1, "no new disposition this call");
894        }
895
896        // (5) Dispose the rest → remainder empties → the token advances.
897        let head2 = head_sha(root);
898        {
899            let mut engine = engine_at(root);
900            let out = advance_baseline(
901                &mut engine,
902                root,
903                &resolved,
904                &input(&[("b.rs", "worked"), ("c.rs", "worked")]),
905            )
906            .unwrap();
907            assert!(out.completed, "every artifact disposed → complete");
908            assert_eq!(out.pending, 0);
909            assert_eq!(out.tokens_written, vec![synced_key().to_string()]);
910
911            // The `#synced` baseline advanced to the current HEAD (head2).
912            let token = engine
913                .mem_config_for("engine")
914                .and_then(|c| c.sync_state.get(synced_key()).cloned());
915            assert_eq!(token.as_deref(), Some(head2.as_str()));
916        }
917        // The durable store was dropped on completion.
918        assert!(
919            read_advance_store(root, "engine", "graph")
920                .unwrap()
921                .is_none()
922        );
923    }
924
925    /// The durable authored-exclusion ledger survives completion (unlike the
926    /// transient dispositions/frozen slice), and a later non-excluded verdict for
927    /// the same artifact clears it — dropping the store when nothing durable is
928    /// left. This is the persistence the fidelity report relies on so an
929    /// excluded-on-purpose artifact stops re-surfacing as `uncovered`.
930    #[test]
931    fn advance_retains_authored_exclusions_past_completion_and_clears_on_rejudge() {
932        let tmp = TempDir::new().unwrap();
933        let root = tmp.path();
934
935        // Baseline a.rs; move to head1 (modify a.rs) so the slice = {modified a.rs}.
936        git(root, &["init", "-q"]);
937        std::fs::write(root.join("a.rs"), "one").unwrap();
938        git(root, &["add", "a.rs"]);
939        git(root, &["commit", "-qm", "base"]);
940        let baseline = head_sha(root);
941        std::fs::write(root.join("a.rs"), "one-longer").unwrap();
942        git(root, &["add", "-A"]);
943        git(root, &["commit", "-qm", "head1"]);
944
945        let resolved = resolved_engine_graph();
946        {
947            let mut engine = engine_at(root);
948            engine
949                .set_mem_sync_state("engine", synced_key(), &baseline, None)
950                .unwrap();
951        }
952
953        // Dispose a.rs as EXCLUDED with a rationale → the only slice artifact is
954        // disposed → the advance completes. Exclusions are non-empty, so the
955        // store is RETAINED (not dropped) holding only the exclusion.
956        let excluded = {
957            let mut m = BTreeMap::new();
958            m.insert(
959                "a.rs".to_string(),
960                DispositionInput::Reasoned {
961                    disposition: EXCLUDED_VERDICT.to_string(),
962                    rationale: "mined; warrants no destination entity".to_string(),
963                },
964            );
965            m
966        };
967        {
968            let mut engine = engine_at(root);
969            let out = advance_baseline(&mut engine, root, &resolved, &excluded).unwrap();
970            assert!(out.completed, "the sole slice artifact was disposed");
971        }
972        let retained = read_advance_store(root, "engine", "graph")
973            .unwrap()
974            .expect("an authored exclusion keeps the store alive past completion");
975        assert!(
976            retained.frozen_slice == Slice::default() && retained.dispositions.is_empty(),
977            "transient progress is dropped on completion"
978        );
979        assert_eq!(
980            retained.exclusions.get("a.rs").map(String::as_str),
981            Some("mined; warrants no destination entity"),
982            "the durable exclusion + its rationale persist"
983        );
984
985        // Move to head2 (modify a.rs again) → a.rs re-enters the slice → re-judge
986        // it as `worked`. The non-excluded verdict clears the stale exclusion, and
987        // with nothing durable left the store is dropped.
988        std::fs::write(root.join("a.rs"), "one-longer-still").unwrap();
989        git(root, &["add", "-A"]);
990        git(root, &["commit", "-qm", "head2"]);
991        {
992            let mut engine = engine_at(root);
993            let out = advance_baseline(&mut engine, root, &resolved, &input(&[("a.rs", "worked")]))
994                .unwrap();
995            assert!(out.completed);
996        }
997        assert!(
998            read_advance_store(root, "engine", "graph")
999                .unwrap()
1000                .is_none(),
1001            "re-judging the artifact cleared the exclusion; nothing durable remains"
1002        );
1003    }
1004
1005    /// `record_exclusions` gates on enumerable `S(D)` membership (not the changed
1006    /// slice), so a **stable, unchanged** in-scope artifact can be declared
1007    /// excluded — the direct write path the option-(a) migration needs. A
1008    /// non-member refuses the whole call atomically; a re-declare merges.
1009    #[test]
1010    fn record_exclusions_gates_on_source_membership_and_merges() {
1011        let tmp = TempDir::new().unwrap();
1012        let root = tmp.path();
1013
1014        // A source tree with two in-scope `.rs` members. No commits move after
1015        // this — the artifacts are stable, never in a changed slice.
1016        git(root, &["init", "-q"]);
1017        std::fs::write(root.join("a.rs"), "one").unwrap();
1018        std::fs::write(root.join("b.rs"), "two").unwrap();
1019        git(root, &["add", "-A"]);
1020        git(root, &["commit", "-qm", "base"]);
1021
1022        let resolved = resolved_engine_graph();
1023
1024        // Declare a.rs excluded with a rationale — accepted (S(D) member).
1025        let out = record_exclusions(
1026            root,
1027            &resolved,
1028            &BTreeMap::from([("a.rs".to_string(), "mined; no entity".to_string())]),
1029        )
1030        .unwrap();
1031        assert_eq!((out.added, out.excluded), (1, 1));
1032        let state = read_advance_store(root, "engine", "graph")
1033            .unwrap()
1034            .unwrap();
1035        assert_eq!(
1036            state.exclusions.get("a.rs").map(String::as_str),
1037            Some("mined; no entity")
1038        );
1039
1040        // An artifact outside S(D) refuses the whole call — the store is untouched.
1041        let err = record_exclusions(
1042            root,
1043            &resolved,
1044            &BTreeMap::from([("does-not-exist.rs".to_string(), "x".to_string())]),
1045        )
1046        .unwrap_err();
1047        assert!(
1048            matches!(err, ExcludeError::NotSourceMember { .. }),
1049            "got {err:?}"
1050        );
1051        assert_eq!(
1052            read_advance_store(root, "engine", "graph")
1053                .unwrap()
1054                .unwrap()
1055                .exclusions
1056                .len(),
1057            1,
1058            "refused call left the ledger unchanged"
1059        );
1060
1061        // Re-declaring merges (b.rs added alongside a.rs).
1062        let out2 = record_exclusions(
1063            root,
1064            &resolved,
1065            &BTreeMap::from([("b.rs".to_string(), "also mined".to_string())]),
1066        )
1067        .unwrap();
1068        assert_eq!((out2.added, out2.excluded), (1, 2));
1069    }
1070
1071    /// `DispositionInput` parses both the bare-verdict and the reasoned forms
1072    /// from one `--dispositions` payload (serde `untagged`).
1073    #[test]
1074    fn disposition_input_parses_bare_and_reasoned_forms() {
1075        let map: BTreeMap<String, DispositionInput> = serde_json::from_str(
1076            r#"{"a.rs": "worked", "b.rs": {"disposition": "excluded", "rationale": "generated"}}"#,
1077        )
1078        .unwrap();
1079        assert_eq!(map["a.rs"].verdict(), "worked");
1080        assert_eq!(map["a.rs"].rationale(), None);
1081        assert_eq!(map["b.rs"].verdict(), EXCLUDED_VERDICT);
1082        assert_eq!(map["b.rs"].rationale(), Some("generated"));
1083    }
1084
1085    /// AC9a — an anchored write auto-marks its referenced frozen-slice
1086    /// artifacts `worked`, so `advance` needs an explicit disposition only for
1087    /// the residue, held across a HEAD move. Refusals: an artifact with no
1088    /// anchor is never auto-worked, and an anchor referencing an artifact
1089    /// OUTSIDE the presented slice fabricates no slice entry.
1090    #[test]
1091    fn advance_auto_worked_from_anchors_subtracts_slice_and_never_fabricates() {
1092        use crate::vcs::Actor;
1093        use indexmap::IndexMap;
1094
1095        let tmp = TempDir::new().unwrap();
1096        let root = tmp.path();
1097
1098        // Baseline: a commit carrying no `.rs` files. `#synced` pins it, so the
1099        // moved slice below is purely the added `.rs` sources.
1100        git(root, &["init", "-q"]);
1101        std::fs::write(root.join(".keep"), "x").unwrap();
1102        git(root, &["add", ".keep"]);
1103        git(root, &["commit", "-qm", "base"]);
1104        let baseline = head_sha(root);
1105
1106        let resolved = resolved_engine_graph();
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        // head1: add a.rs + b.rs → slice = added [a.rs, b.rs].
1115        std::fs::write(root.join("a.rs"), "one").unwrap();
1116        std::fs::write(root.join("b.rs"), "bee").unwrap();
1117        git(root, &["add", "a.rs", "b.rs"]);
1118        git(root, &["commit", "-qm", "head1"]);
1119
1120        // An anchored write into the destination mem `engine`: entity
1121        // `covers-a` file-anchors `a.rs` (inside the slice) AND `zzz.rs`
1122        // (outside it — must fabricate nothing).
1123        let make_anchor = |artifact: &str| crate::anchor::AnchorInput {
1124            artifact: Some(artifact.to_string()),
1125            grain: Some("file".to_string()),
1126            class: Some("anchored".to_string()),
1127            hash: Some("h".to_string()),
1128            hash_stability: Some("stable".to_string()),
1129            ..Default::default()
1130        };
1131        let mut sections = IndexMap::new();
1132        sections.insert("identity".to_string(), "Covers a.".to_string());
1133        sections.insert("purpose".to_string(), "Track a.rs.".to_string());
1134        {
1135            let mut engine = engine_at(root);
1136            engine
1137                .create_entity(
1138                    crate::CreateEntityArgs {
1139                        mem: "engine".to_string(),
1140                        title: "Covers A".to_string(),
1141                        entity_type: "spec".to_string(),
1142                        sections,
1143                        metadata: IndexMap::new(),
1144                        relations: Vec::new(),
1145                        anchors: vec![make_anchor("a.rs"), make_anchor("zzz.rs")],
1146                        dry_run: false,
1147                    },
1148                    Actor::Agent,
1149                    None,
1150                    Some("anchored write"),
1151                )
1152                .unwrap();
1153        }
1154
1155        // (1) Advance with NO explicit dispositions → a.rs auto-worked from the
1156        // anchor; b.rs (no anchor) stays pending; zzz.rs (outside the slice)
1157        // fabricates nothing.
1158        {
1159            let mut engine = engine_at(root);
1160            let out = advance_baseline(&mut engine, root, &resolved, &BTreeMap::new()).unwrap();
1161            assert!(!out.completed, "b.rs still pending");
1162            assert_eq!(
1163                out.remainder,
1164                slice(&["b.rs"], &[], &[]),
1165                "a.rs auto-worked from its anchor; zzz.rs never became a slice member"
1166            );
1167            assert_eq!(out.disposed, 1, "only a.rs auto-worked");
1168            assert_eq!(out.pending, 1);
1169        }
1170
1171        // (2) HEAD moves (add c.rs). Re-present with no dispositions → old
1172        // remainder [b.rs] + new delta [c.rs]; a.rs stays absent (its
1173        // auto-`worked` persisted); c.rs is unanchored so it is NOT auto-worked.
1174        std::fs::write(root.join("c.rs"), "cee").unwrap();
1175        git(root, &["add", "-A"]);
1176        git(root, &["commit", "-qm", "head2"]);
1177        {
1178            let mut engine = engine_at(root);
1179            let out = advance_baseline(&mut engine, root, &resolved, &BTreeMap::new()).unwrap();
1180            assert_eq!(
1181                out.remainder,
1182                slice(&["b.rs", "c.rs"], &[], &[]),
1183                "auto-worked a.rs absent; unanchored b.rs + new c.rs pending"
1184            );
1185            assert_eq!(
1186                out.disposed, 1,
1187                "still only a.rs auto-worked; c.rs unanchored"
1188            );
1189            assert!(!out.completed);
1190        }
1191    }
1192}