Skip to main content

memstead_base/ingest/
findings.rs

1//! The engine-owned durable **findings store** and the thin `projection verify`
2//! write path that populates it (bundle plan `05-verify-sync-engine`, group A).
3//!
4//! Verify **measures** fidelity and records durable findings; it mutates no
5//! entity in the destination mem (though a completed run does write this
6//! store, backfill observed anchor hashes, and record a `#verified`
7//! baseline). The store is the real home behind plan 03's findings
8//! schema stub ([`crate::binding`]'s removed `FindingKey` / `FindingRecord`).
9//!
10//! ## Keying: `hash(D)` alone — findings survive head movement
11//!
12//! The store keys on the binding's **`hash(D)` alone**: a binding-declaration
13//! edit still mechanically partitions findings into a fresh keyspace (prior
14//! findings are never presented as current, only segregated as superseded —
15//! A3), but a **source-head move does not**. Each finding records the
16//! `source_head` it was observed at as metadata (its [`Finding::key`]), and
17//! sync briefs present **all** open findings under the current `hash(D)`
18//! regardless of recorded head — an open finding survives source movement and
19//! keeps appearing until an agent's repair lets a verify observe it clean, or
20//! a verify supersedes it. (Originally the key was `(hash(D), source_head)`,
21//! which leaked exactly the findings sync exists to consume: once the source
22//! advanced, open findings recorded at the previous head went invisible to
23//! every subsequent brief.) The store does not grow unboundedly: verify
24//! re-observes every anchor each pass and closes what resolves clean, and a
25//! carried coverage finding whose artifact left `S(D)` or gained an anchor is
26//! closed, not carried (see [`merge_with_prior`]). On-disk format is unchanged
27//! — pre-re-key stores (batches keyed `(hash(D), source_head)`) load as-is;
28//! same-hash batches from different heads collapse under the hash-alone view
29//! (the latest-recorded batch is current, the rest superseded until the next
30//! verify rewrites the hash's batch).
31//!
32//! ## Durability & location (A1, engine-state convention)
33//!
34//! The store is engine-owned state, **not a mem**. It lives at
35//! `<workspace>/.memstead/state/findings/<mem>/<name>.json` — a sibling of the
36//! durable advance store (`state/advance/`) and `state/mounts.json`, under the
37//! `.memstead/state/` tier every engine-state consumer shares. It is read fresh
38//! from disk per call, so findings survive a process restart and a later
39//! sync-brief render (a fresh process) reads them back. This is deliberately the
40//! `state/` tier, **not** the ephemeral `.memstead.cache/` tier the mtime memo,
41//! backoff, and the `next_batch` rotation use — those are recomputable; findings
42//! are not.
43//!
44//! ## One writer (A4/A5)
45//!
46//! Only the engine verify/sync/advance code paths write this store. There is no
47//! CLI/skill/temp-file side channel: the refinement scout/writer temp-findings
48//! handover (a `.md` file under `.memstead.cache/ingest/refinement/` with a
49//! 10-minute-staleness contract) is gone — [`super::refinement`] retains only
50//! the `next_batch` rotation machinery, consumed here solely to **schedule**
51//! verify samples. [`verify_binding`] takes `&Engine` (shared, not mutable): it
52//! is structurally incapable of a destination-mem mutation. Any repair routes
53//! through the sync brief (group C), never through findings recording/reading.
54//! Two sanctioned post-run writes exist, both explicit separate steps the
55//! caller performs only after a pass returns `Ok` (so an aborted or failed
56//! run never records either), and both measurement bookkeeping — never entity
57//! content: the **verified baseline** ([`record_verified_baseline`] records
58//! `<binding>/<facet>#verified` per observed facet head through the lifecycle
59//! sync-state writer) and the **prepared-hash backfill**
60//! ([`record_anchor_hash_backfill`] records this pass's observed
61//! prepared-content hashes onto hash-less hash-bearing anchors in the
62//! engine-owned anchors sidecar).
63
64use std::collections::{BTreeMap, BTreeSet};
65use std::path::{Path, PathBuf};
66use std::time::{SystemTime, UNIX_EPOCH};
67
68use serde::{Deserialize, Serialize};
69
70use crate::Engine;
71use crate::anchor::{Anchor, AnchorState, ObservedArtifactHash};
72use crate::binding::{
73    Binding, DEFAULT_ADJUDICATION_CAP, DEFAULT_FULL_RESYNC_EVERY, hash_binding, medium_capabilities,
74};
75use crate::workspace_store::{StoreError, WORKSPACE_STORE_DIR};
76
77use super::advance::is_single_component;
78use super::cursor::{compute_source_cursor, enumerate_source_artifacts};
79use super::refinement::{
80    ROTATION_ANCHOR_ADJUDICATION, bump_verify_runs, next_batch, next_rotation_batch,
81};
82use super::resolve::{ResolvedIngest, ResolvedSource};
83
84/// The engine-owned state directory root, under the workspace store:
85/// `<root>/.memstead/state/`. Mirrors [`super::advance`]'s `STATE_DIR`.
86const STATE_DIR: &str = "state";
87/// The findings store's subtree: `<root>/.memstead/state/findings/`.
88const FINDINGS_DIR: &str = "findings";
89
90// ---------------------------------------------------------------------------
91// Key
92// ---------------------------------------------------------------------------
93
94/// A binding's `hash(D)` plus the `source_head` a finding was observed at.
95///
96/// Only the **`binding_hash` half keys the store**: a changed `hash(D)` (a
97/// binding-declaration edit) invalidates prior findings by construction —
98/// segregated as superseded, never silently mixed into the current view (A3).
99/// The `source_head` half is **observation metadata**, carried on every
100/// finding so it stays self-describing about when it was observed — a moved
101/// head does NOT invalidate a finding (findings survive head movement; see
102/// the module docs).
103///
104/// The real key behind plan 03's schema stub (which lived, IO-less, in
105/// [`crate::binding`]).
106#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
107pub struct FindingKey {
108    /// The binding's `hash(D)` (lowercase hex SHA-256; see
109    /// [`crate::binding::hash_binding`]) — the store key.
110    pub binding_hash: String,
111    /// The composite source-head token the finding was observed at — the
112    /// per-facet baseline tokens current at observation time. Metadata, not
113    /// part of the store key.
114    pub source_head: String,
115}
116
117// ---------------------------------------------------------------------------
118// Finding
119// ---------------------------------------------------------------------------
120
121/// The class of a verify finding (A2). A closed vocabulary: `drifted` and
122/// `queued-for-adjudication` come only from **hash-drift adjudication** (over
123/// hash-bearing anchors — never `authored` / `informed-by`, see
124/// [`adjudicate_anchor`]); `unresolvable-anchor` is an existence failure;
125/// `uncovered` marks a source artifact with no anchor; `wrong` is reserved for
126/// an adjudicated content mismatch the group-B report renders.
127#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
128#[serde(rename_all = "kebab-case")]
129pub enum FindingClass {
130    /// A hash-bearing anchor's prepared-content hash drifted from the recorded
131    /// one on a `stable` medium.
132    Drifted,
133    /// An adjudicated content mismatch (reserved for the group-B report path).
134    Wrong,
135    /// A source artifact in scope carries no anchor in the destination mem.
136    Uncovered,
137    /// An anchor's referenced artifact is no longer present in the medium.
138    UnresolvableAnchor,
139    /// Hash adjudication is deferred (capped, or `recheck`) and queued in the
140    /// store; the remainder is the tier-3 backlog.
141    QueuedForAdjudication,
142}
143
144impl FindingClass {
145    /// Every wire string, in declaration order.
146    pub const WIRE_VALUES: &'static [&'static str] = &[
147        "drifted",
148        "wrong",
149        "uncovered",
150        "unresolvable-anchor",
151        "queued-for-adjudication",
152    ];
153
154    /// Stable wire form.
155    pub fn as_wire(&self) -> &'static str {
156        match self {
157            FindingClass::Drifted => "drifted",
158            FindingClass::Wrong => "wrong",
159            FindingClass::Uncovered => "uncovered",
160            FindingClass::UnresolvableAnchor => "unresolvable-anchor",
161            FindingClass::QueuedForAdjudication => "queued-for-adjudication",
162        }
163    }
164
165    /// Inverse of [`Self::as_wire`]; `None` for an unknown string.
166    pub fn from_wire(s: &str) -> Option<Self> {
167        match s {
168            "drifted" => Some(FindingClass::Drifted),
169            "wrong" => Some(FindingClass::Wrong),
170            "uncovered" => Some(FindingClass::Uncovered),
171            "unresolvable-anchor" => Some(FindingClass::UnresolvableAnchor),
172            "queued-for-adjudication" => Some(FindingClass::QueuedForAdjudication),
173            _ => None,
174        }
175    }
176}
177
178/// What a finding is about (A2): an anchor reference, or — for an uncovered
179/// artifact that has no anchor — the source artifact id itself.
180#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
181#[serde(tag = "kind", rename_all = "kebab-case")]
182pub enum FindingTarget {
183    /// An anchor reference: the entity id carrying the anchor and the artifact
184    /// the anchor points at.
185    Anchor {
186        /// The entity id (`mem--slug`) the anchor belongs to.
187        entity: String,
188        /// The anchor's artifact reference (path / `path@commit` / url / entity id).
189        artifact: String,
190    },
191    /// An uncovered source artifact — no anchor references it, so there is no
192    /// anchor to name (A2's "artifact ID for uncovered artifacts").
193    Artifact {
194        /// The source-side artifact id.
195        artifact: String,
196    },
197}
198
199/// A single durable verify finding (A2). Carries its target, its class, and —
200/// self-describingly — the [`FindingKey`] it was recorded under, so a finding
201/// pulled out of the store always states which `(hash(D), source_head)` it
202/// belongs to.
203#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
204pub struct Finding {
205    /// The key this finding was recorded under (A2). Redundant with its
206    /// enclosing [`FindingsBatch::key`], carried on the finding so it stays
207    /// self-describing when detached.
208    pub key: FindingKey,
209    /// The source facet the finding concerns (best-effort label in the thin
210    /// verify — the group-B report refines per-facet attribution).
211    pub facet: String,
212    /// What the finding is about.
213    pub target: FindingTarget,
214    /// The finding class.
215    pub class: FindingClass,
216    /// Human/agent-readable detail.
217    pub detail: String,
218    /// When the finding was recorded (opaque timestamp string — unix seconds).
219    pub created_at: String,
220}
221
222// ---------------------------------------------------------------------------
223// Store
224// ---------------------------------------------------------------------------
225
226/// One batch of findings recorded for a single `hash(D)` in one verify pass.
227/// A new pass under the same `hash(D)` replaces the batch (after
228/// [`verify_binding`]'s merge carried forward what stays open); a pass under a
229/// different `hash(D)` lands as a separate batch — the prior one is retained,
230/// segregated, never overwritten (A3). The batch's `key.source_head` is the
231/// head the batch was last **recorded** at; each finding's own key records the
232/// head *it* was observed at (a carried finding keeps its original).
233#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
234pub struct FindingsBatch {
235    /// The key this batch was recorded under (`binding_hash` is the store
236    /// key; `source_head` is the recording head, metadata).
237    pub key: FindingKey,
238    /// When the batch was last recorded (opaque timestamp string).
239    pub recorded_at: String,
240    /// The findings in this batch.
241    pub findings: Vec<Finding>,
242}
243
244/// One binding's durable findings store (A1). Persisted at
245/// `.memstead/state/findings/<mem>/<name>.json`, read fresh per call. Holds
246/// findings grouped by the `hash(D)` they were recorded under so declaration
247/// invalidation is mechanical: [`Self::current`] presents the current hash's
248/// batch — regardless of source head; [`Self::superseded`] surfaces everything
249/// else, segregated (A3).
250///
251/// The on-disk shape predates the hash-alone re-key and is unchanged: a store
252/// written when batches were keyed `(hash(D), source_head)` loads without loss.
253/// Such a legacy store may hold several batches sharing one `binding_hash`
254/// (recorded at different heads); the hash-alone view treats the
255/// latest-recorded of them as current and the next [`Self::record`] collapses
256/// them into one.
257#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
258pub struct FindingsStore {
259    /// The canonical binding id `<mem>/<stem>` this store belongs to.
260    pub binding: String,
261    /// Findings grouped by recording key, most-recent recording order not
262    /// guaranteed — look up by key.
263    #[serde(default)]
264    pub batches: Vec<FindingsBatch>,
265}
266
267impl FindingsStore {
268    /// Index of the store's current batch for `binding_hash`: the
269    /// latest-recorded batch carrying that hash (ties break toward the later
270    /// entry — [`Self::record`] appends). Usually unique; a legacy per-head
271    /// store may hold several.
272    fn current_batch_index(&self, binding_hash: &str) -> Option<usize> {
273        self.batches
274            .iter()
275            .enumerate()
276            .filter(|(_, b)| b.key.binding_hash == binding_hash)
277            .max_by_key(|(i, b)| (b.recorded_at.parse::<u64>().unwrap_or(0), *i))
278            .map(|(i, _)| i)
279    }
280
281    /// Record `findings` under `key.binding_hash`, replacing **every** prior
282    /// batch recorded under that hash (including legacy per-head siblings) and
283    /// leaving every other hash's batch untouched (A3 segregation — a changed
284    /// `hash(D)` never overwrites the old batch).
285    pub fn record(&mut self, key: FindingKey, recorded_at: String, findings: Vec<Finding>) {
286        self.batches
287            .retain(|b| b.key.binding_hash != key.binding_hash);
288        self.batches.push(FindingsBatch {
289            key,
290            recorded_at,
291            findings,
292        });
293    }
294
295    /// The findings recorded under `key.binding_hash` — the **only** findings
296    /// ever presented as current (A3), **regardless of `key.source_head`**: an
297    /// open finding recorded at a previous head stays presented after the
298    /// source advances. Empty when nothing was recorded under this hash.
299    pub fn current(&self, key: &FindingKey) -> &[Finding] {
300        self.current_batch_index(&key.binding_hash)
301            .map(|i| self.batches[i].findings.as_slice())
302            .unwrap_or(&[])
303    }
304
305    /// Every finding **outside** the current view of `key.binding_hash` —
306    /// superseded by a `hash(D)` change (or stranded in an older legacy
307    /// per-head batch of the same hash), segregated so a consumer can show
308    /// them as stale without mixing them into the current view (A3).
309    pub fn superseded(&self, key: &FindingKey) -> Vec<&Finding> {
310        let current = self.current_batch_index(&key.binding_hash);
311        self.batches
312            .iter()
313            .enumerate()
314            .filter(|(i, _)| Some(*i) != current)
315            .flat_map(|(_, b)| b.findings.iter())
316            .collect()
317    }
318}
319
320// ---------------------------------------------------------------------------
321// Store IO — mirrors `super::advance`'s durable-store shape
322// ---------------------------------------------------------------------------
323
324/// The durable store path for a binding:
325/// `.memstead/state/findings/<mem>/<name>.json`.
326pub fn findings_store_path(workspace_root: &Path, mem: &str, name: &str) -> PathBuf {
327    workspace_root
328        .join(WORKSPACE_STORE_DIR)
329        .join(STATE_DIR)
330        .join(FINDINGS_DIR)
331        .join(mem)
332        .join(format!("{name}.json"))
333}
334
335/// The mem-scoped findings key for binding-less (standalone) anchor
336/// verification (agent-trust plan 14). A distinguished constant that
337/// can never collide with a real `hash(D)` (which is always 64 hex
338/// chars): a hand-authored mem with no binding persists its verify
339/// findings under this key, in its own store file
340/// (`state/findings/<mem>/standalone.json`), closing the
341/// observe-and-forget gap. Binding-backed stores keep their `hash(D)`
342/// key and semantics untouched — the two keyspaces coexist and never
343/// share a file.
344pub const STANDALONE_KEY: &str = "standalone";
345
346/// One standalone finding with its already-seen annotation: `true`
347/// when the previous standalone pass recorded the same target and
348/// class — the re-serving that makes a second pass say "known" rather
349/// than rediscovering.
350#[derive(Debug, Clone, Serialize)]
351pub struct AnnotatedStandaloneFinding {
352    #[serde(flatten)]
353    pub finding: Finding,
354    pub already_seen: bool,
355}
356
357/// Persist a standalone (binding-less) anchor-verification pass's
358/// flagged findings under the mem-scoped [`STANDALONE_KEY`], and
359/// annotate each against the previous pass. `drifted` and
360/// `unresolvable` anchors become durable findings (`recheck` is
361/// transient by definition and `resolved` is not a finding); a pass
362/// whose flagged set is empty still records — the empty batch IS the
363/// "everything resolved clean" statement that closes prior findings.
364pub fn record_standalone_findings(
365    workspace_root: &Path,
366    report: &crate::engine::query::MemAnchorVerification,
367) -> Result<Vec<AnnotatedStandaloneFinding>, StoreError> {
368    let mem = &report.mem;
369    let key = FindingKey {
370        binding_hash: STANDALONE_KEY.to_string(),
371        source_head: String::new(),
372    };
373    let now = SystemTime::now()
374        .duration_since(UNIX_EPOCH)
375        .map(|d| d.as_secs())
376        .unwrap_or(0)
377        .to_string();
378
379    let findings: Vec<Finding> = report
380        .anchors
381        .iter()
382        .filter_map(|a| {
383            // `unobserved` is deliberately absent (consistency-sweep 03/05).
384            // A finding asserts a MEASURED condition, and an unobserved row is
385            // the absence of a measurement: recording it as
386            // `UnresolvableAnchor` claimed the artifact was gone when nobody
387            // had looked, which is the collapse criterion 2 removes. It is not
388            // dropped silently either — the population statement and
389            // `fully_adjudicated` on this same surface report it, and the
390            // binding report raises it as a blind spot that blocks a clean
391            // verdict.
392            let class = match a.state.as_str() {
393                "drifted" => FindingClass::Drifted,
394                "unresolvable" => FindingClass::UnresolvableAnchor,
395                _ => return None,
396            };
397            Some(Finding {
398                key: key.clone(),
399                facet: STANDALONE_KEY.to_string(),
400                target: FindingTarget::Anchor {
401                    entity: a.entity_id.clone(),
402                    artifact: a.artifact.clone(),
403                },
404                class,
405                detail: format!("{} ({} {})", a.state, a.class, a.grain),
406                created_at: now.clone(),
407            })
408        })
409        .collect();
410
411    let mut store =
412        read_findings_store(workspace_root, mem, STANDALONE_KEY)?.unwrap_or_else(|| {
413            FindingsStore {
414                binding: format!("{mem}/{STANDALONE_KEY}"),
415                ..Default::default()
416            }
417        });
418    let prior: BTreeSet<(String, String)> = store
419        .current(&key)
420        .iter()
421        .map(|f| {
422            (
423                serde_json::to_string(&f.target).unwrap_or_default(),
424                f.class.as_wire().to_string(),
425            )
426        })
427        .collect();
428    let annotated: Vec<AnnotatedStandaloneFinding> = findings
429        .iter()
430        .map(|f| AnnotatedStandaloneFinding {
431            finding: f.clone(),
432            already_seen: prior.contains(&(
433                serde_json::to_string(&f.target).unwrap_or_default(),
434                f.class.as_wire().to_string(),
435            )),
436        })
437        .collect();
438    store.record(key, now, findings);
439    write_findings_store(workspace_root, mem, STANDALONE_KEY, &store)?;
440    Ok(annotated)
441}
442
443/// Read the durable findings store for a binding, or `None` when none exists.
444/// A malformed file surfaces a typed [`StoreError::Parse`] naming the path.
445pub fn read_findings_store(
446    workspace_root: &Path,
447    mem: &str,
448    name: &str,
449) -> Result<Option<FindingsStore>, StoreError> {
450    let path = findings_store_path(workspace_root, mem, name);
451    match std::fs::read(&path) {
452        Ok(bytes) => serde_json::from_slice(&bytes)
453            .map(Some)
454            .map_err(|e| StoreError::Parse {
455                path,
456                message: e.to_string(),
457            }),
458        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
459        Err(e) => Err(StoreError::Io { path, source: e }),
460    }
461}
462
463/// Create an engine-owned store subtree and drop a self-ignoring
464/// `.gitignore` (`*`) at its root if none exists. The `state/findings/`
465/// and `state/advance/` stores are per-checkout ephemeral engine state
466/// living inside a possibly-tracked workspace (where `state/mounts.json`
467/// IS tracked) — without the ignore they surface as untracked noise and
468/// would churn if committed. Best-effort: an ignore-write failure never
469/// fails the store write itself.
470pub(crate) fn ensure_selfignoring_store_dir(subtree_root: &Path) -> Result<(), StoreError> {
471    std::fs::create_dir_all(subtree_root).map_err(|e| StoreError::Io {
472        path: subtree_root.to_path_buf(),
473        source: e,
474    })?;
475    let gitignore = subtree_root.join(".gitignore");
476    if !gitignore.exists() {
477        let _ = std::fs::write(&gitignore, "*\n");
478    }
479    Ok(())
480}
481
482/// Persist the durable findings store for a binding (pretty JSON), creating
483/// parent directories.
484pub fn write_findings_store(
485    workspace_root: &Path,
486    mem: &str,
487    name: &str,
488    store: &FindingsStore,
489) -> Result<(), StoreError> {
490    ensure_selfignoring_store_dir(
491        &workspace_root
492            .join(WORKSPACE_STORE_DIR)
493            .join(STATE_DIR)
494            .join(FINDINGS_DIR),
495    )?;
496    let path = findings_store_path(workspace_root, mem, name);
497    if let Some(parent) = path.parent() {
498        std::fs::create_dir_all(parent).map_err(|e| StoreError::Io {
499            path: parent.to_path_buf(),
500            source: e,
501        })?;
502    }
503    let bytes = serde_json::to_vec_pretty(store).map_err(|e| StoreError::Parse {
504        path: path.clone(),
505        message: e.to_string(),
506    })?;
507    std::fs::write(&path, bytes).map_err(|e| StoreError::Io { path, source: e })
508}
509
510/// Drop the durable findings store for a binding. A missing file is a
511/// successful no-op.
512pub fn delete_findings_store(
513    workspace_root: &Path,
514    mem: &str,
515    name: &str,
516) -> Result<(), StoreError> {
517    let path = findings_store_path(workspace_root, mem, name);
518    match std::fs::remove_file(&path) {
519        Ok(()) => Ok(()),
520        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
521        Err(e) => Err(StoreError::Io { path, source: e }),
522    }
523}
524
525// ---------------------------------------------------------------------------
526// Verify write path
527// ---------------------------------------------------------------------------
528
529/// Why [`verify_binding`] could not complete.
530#[derive(Debug, thiserror::Error)]
531pub enum FindingsError {
532    /// The binding id is not the canonical `<mem>/<stem>` shape.
533    #[error("malformed binding id '{0}': expected `<mem>/<stem>`")]
534    MalformedId(String),
535    /// Reading or writing the durable findings store failed.
536    #[error("findings store error: {0}")]
537    Store(#[source] StoreError),
538    /// A path-based primary source's base directory does not exist — a
539    /// vanished or unmounted source. Verify refuses rather than measures:
540    /// enumerating a missing tree yields an empty stat map whose aggregate
541    /// (the hash of nothing) is indistinguishable from a genuinely empty
542    /// source and would overwrite a real `#verified` baseline with fake
543    /// state. Typed and visible, mirroring the D3 non-enumerable refusal.
544    #[error("source '{source_name}' unreachable: `{path}` does not exist")]
545    SourceUnreachable {
546        /// The source whose pointer resolved to the missing path.
547        source_name: String,
548        /// The resolved base path that does not exist.
549        path: String,
550    },
551    /// A full measurement ([`verify_binding_full`]) was requested over a
552    /// facet whose medium the capability matrix marks **non-enumerable**: the
553    /// full `S(D)` walk cannot cover it, so the whole run refuses — typed,
554    /// carrying the same [`FullResyncRefusal`] shape the scheduled walk emits
555    /// — rather than render a report with fabricated completeness. (The
556    /// *scheduled* walk refuses per facet and walks the rest; an explicit
557    /// full measurement promises complete figures, so a partial walk is not
558    /// an answer.)
559    #[error(
560        "full verify refused: facet '{}' over medium type '{}' cannot be fully walked — {}",
561        .0.facet, .0.medium_type, .0.reason
562    )]
563    FullWalkNonEnumerable(FullResyncRefusal),
564}
565
566/// The outcome of a [`verify_binding`] pass.
567#[derive(Debug, Clone, PartialEq, Eq)]
568pub struct VerifyOutcome {
569    /// The binding id verified.
570    pub binding: String,
571    /// The key the findings were recorded under this pass.
572    pub key: FindingKey,
573    /// How many findings were recorded under the current key.
574    pub recorded: usize,
575    /// How many findings remain under prior (superseded) keys (A3).
576    pub superseded: usize,
577    /// The tier-3 backlog depth — findings queued for adjudication.
578    pub backlog: usize,
579    /// The full-enumeration scheduling decision for this run (D3) — whether a
580    /// scheduled full walk fired, is not yet due, is disabled, and any typed
581    /// non-enumerable refusals. Surfaced (never a silent skip) to the caller.
582    pub full_resync: FullResyncDecision,
583    /// Each source facet's current head token as observed by this run — the
584    /// per-facet decomposition of `key.source_head`. The completed-run
585    /// baseline [`record_verified_baseline`] writes as `#verified`.
586    pub facet_heads: BTreeMap<String, String>,
587    /// Prepared-content hashes this pass observed for **hash-less**
588    /// hash-bearing (`anchored` / `derived`) anchors whose artifact resolved —
589    /// the backfill worklist. The caller records them onto the anchors via
590    /// [`record_anchor_hash_backfill`] after the pass returns `Ok` (the same
591    /// sanctioned post-run-write pattern as [`record_verified_baseline`]);
592    /// once recorded, subsequent verifies adjudicate those anchors
593    /// deterministically and this list comes back empty. `authored` /
594    /// `informed-by` anchors never appear here — the observation computes no
595    /// hash for them.
596    pub hash_backfill: Vec<ObservedArtifactHash>,
597}
598
599/// Record a **completed** verify run's baseline: for each facet head the run
600/// observed, `<binding>/<facet>#verified = <token>` on the destination mem,
601/// through the engine's lifecycle sync-state writer (the backlog-prescribed
602/// `#verified` writer — the counterpart of the advance path's `#synced`).
603///
604/// Deliberately a separate step from [`verify_binding`], which keeps its
605/// shared `&Engine` borrow (A5 — measurement is structurally incapable of a
606/// mem mutation): the caller invokes this **only after** a verify pass
607/// returned `Ok`, so an aborted or failed run never advances the token. The
608/// selection loop reads the token to decide when a verify is due again; the
609/// CLI `status`/report paths render it.
610///
611/// Returns the written sync-state keys. A binding whose run observed no facet
612/// head (nothing recorded, nothing moved) writes nothing.
613pub fn record_verified_baseline(
614    engine: &mut Engine,
615    destination_mem: &str,
616    outcome: &VerifyOutcome,
617    note: Option<&str>,
618) -> Result<Vec<String>, crate::engine::EngineError> {
619    let mut written = Vec::with_capacity(outcome.facet_heads.len());
620    for (facet, token) in &outcome.facet_heads {
621        let key = format!("{}/{facet}#verified", outcome.binding);
622        engine.set_mem_sync_state(destination_mem, &key, token, note)?;
623        written.push(key);
624    }
625    Ok(written)
626}
627
628/// Record a **completed** verify run's prepared-hash backfill: every hash the
629/// pass observed for a hash-less hash-bearing anchor
630/// ([`VerifyOutcome::hash_backfill`]) is written onto that anchor in the
631/// destination mem's engine-owned anchors sidecar, through
632/// [`Engine::record_anchor_observed_hashes`].
633///
634/// Measurement bookkeeping only: the write touches the sidecar and nothing
635/// else — no entity content, no section, no `_hash`. Deliberately a separate
636/// step from [`verify_binding`] (which keeps its shared `&Engine` borrow —
637/// A5), mirroring [`record_verified_baseline`]: the caller invokes this only
638/// after a verify pass returned `Ok`, so an aborted or failed run never
639/// records a hash. Idempotent — the engine writer skips anchors that already
640/// carry a hash, and a pass over fully-backfilled anchors observes an empty
641/// worklist, so re-verifying stages nothing and produces no commit.
642///
643/// Returns how many anchors gained a recorded hash.
644pub fn record_anchor_hash_backfill(
645    engine: &mut Engine,
646    destination_mem: &str,
647    outcome: &VerifyOutcome,
648    note: Option<&str>,
649) -> Result<usize, crate::engine::EngineError> {
650    engine.record_anchor_observed_hashes(destination_mem, &outcome.hash_backfill, note)
651}
652
653/// Split a canonical binding id `<mem>/<stem>` into its two path-safe halves,
654/// or refuse. Uses the same guard as the advance store so a caller-supplied id
655/// can never escape the `.memstead/state/findings/` tier.
656fn split_binding_id(binding_id: &str) -> Result<(String, String), FindingsError> {
657    binding_id
658        .split_once('/')
659        .filter(|(m, n)| is_single_component(m) && is_single_component(n))
660        .map(|(m, n)| (m.to_string(), n.to_string()))
661        .ok_or_else(|| FindingsError::MalformedId(binding_id.to_string()))
662}
663
664/// A single facet label for the thin verify: the lone primary facet when there
665/// is exactly one, else a comma-join. Per-anchor facet attribution is a
666/// group-B refinement.
667fn source_facet_label(resolved: &ResolvedIngest) -> String {
668    let facets: Vec<&str> = resolved
669        .sources
670        .iter()
671        .filter_map(|s| match s {
672            ResolvedSource::Primary(p) => Some(p.name.as_str()),
673            ResolvedSource::Reference { .. } => None,
674        })
675        .collect();
676    facets.join(",")
677}
678
679/// Opaque recording timestamp — unix seconds as a decimal string.
680fn now_seconds() -> String {
681    let secs = SystemTime::now()
682        .duration_since(UNIX_EPOCH)
683        .map(|d| d.as_secs())
684        .unwrap_or(0);
685    secs.to_string()
686}
687
688/// Each source facet's **current head token**, keyed by facet. Starts from the
689/// destination mem's recorded `#synced` tokens for the binding, then overlays
690/// the cursor's current-head tokens for any facet that has moved or is newly
691/// seen — so the map reflects the source's current state. These are the tokens
692/// [`current_source_head`] joins into the composite key, and the per-facet
693/// values [`record_verified_baseline`] writes as `#verified` after a completed
694/// verify run.
695fn current_facet_heads(
696    engine: &Engine,
697    workspace_root: &Path,
698    resolved: &ResolvedIngest,
699) -> BTreeMap<String, String> {
700    let binding_id = &resolved.name;
701    let prefix = format!("{binding_id}/");
702    let mut tokens: BTreeMap<String, String> = BTreeMap::new();
703
704    // Recorded baselines for facets that have not moved since the last sync.
705    if let Some(cfg) = engine.mem_config_for(&resolved.destination_mem) {
706        for (k, v) in &cfg.sync_state {
707            if let Some(rest) = k.strip_prefix(&prefix)
708                && let Some(facet) = rest.strip_suffix("#synced")
709            {
710                tokens.insert(facet.to_string(), v.clone());
711            }
712        }
713    }
714
715    // Current-head tokens for facets that moved / reseeded this pass win.
716    let cursor = compute_source_cursor(engine, resolved, workspace_root);
717    for c in cursor.write_commands.iter().chain(cursor.reseed.iter()) {
718        if let Some(rest) = c.key.strip_prefix(&prefix)
719            && let Some(facet) = rest.strip_suffix("#synced")
720        {
721            tokens.insert(facet.to_string(), c.token.clone());
722        }
723    }
724
725    tokens
726}
727
728/// Join a facet-head map into the composite source-head token,
729/// deterministically (`facet=token;facet=token`).
730fn join_facet_heads(tokens: &BTreeMap<String, String>) -> String {
731    tokens
732        .iter()
733        .map(|(facet, token)| format!("{facet}={token}"))
734        .collect::<Vec<_>>()
735        .join(";")
736}
737
738/// The composite current source-head token: each source facet's current
739/// baseline token, joined deterministically — the value changes iff any
740/// facet's head changes (the A3 "source head moved" trigger).
741fn current_source_head(
742    engine: &Engine,
743    workspace_root: &Path,
744    resolved: &ResolvedIngest,
745) -> String {
746    join_facet_heads(&current_facet_heads(engine, workspace_root, resolved))
747}
748
749/// `hash(D)` for a v2 binding — the record alone carries every content
750/// input, so the resolved shape is not needed.
751fn binding_hash_of(binding: &Binding, _resolved: &ResolvedIngest) -> String {
752    hash_binding(binding)
753}
754
755/// The current [`FindingKey`] for a binding — `hash(D)` (the half that
756/// keys the store) plus the current `source_head` (observation
757/// metadata carried on each finding, not part of the store key).
758fn current_key(
759    engine: &Engine,
760    workspace_root: &Path,
761    binding: &Binding,
762    resolved: &ResolvedIngest,
763) -> FindingKey {
764    FindingKey {
765        binding_hash: binding_hash_of(binding, resolved),
766        source_head: current_source_head(engine, workspace_root, resolved),
767    }
768}
769
770/// The current `(hash(D), source_head)` key plus the open findings under the
771/// key's `hash(D)` for a binding — the read the **sync brief** (group C)
772/// consumes. It resolves the current key exactly as [`verify_binding`] does,
773/// reads the durable store, and returns the `current(key)` slice cloned —
774/// which presents **all open findings regardless of the head they were
775/// recorded at** (findings survive source movement; each carries its observed
776/// head on its own key). **Read-only** on the destination mem (shared
777/// `&Engine`): no findings recording, no mutation. A binding whose store does
778/// not exist yet yields the key and an empty vec.
779///
780/// The durable authored-exclusion ledger is consulted HERE, not only at
781/// recording time: an `uncovered` finding whose artifact the ledger names is
782/// dropped from the slice, so an exclusion `projection advance` /
783/// `projection exclude` just accepted stops presenting on the very next
784/// brief — without waiting for a verify pass to rewrite the stored batch.
785/// (Recording has consulted the ledger since 2026-08-28; a batch recorded
786/// before an exclusion landed still carried the finding, and three
787/// independent runs read that as a repair that did not take.)
788pub fn current_findings(
789    engine: &Engine,
790    workspace_root: &Path,
791    binding: &Binding,
792    resolved: &ResolvedIngest,
793) -> Result<(FindingKey, Vec<Finding>), FindingsError> {
794    let (mem, name) = split_binding_id(&resolved.name)?;
795    let key = current_key(engine, workspace_root, binding, resolved);
796    let mut findings = read_findings_store(workspace_root, &mem, &name)
797        .map_err(FindingsError::Store)?
798        .map(|s| s.current(&key).to_vec())
799        .unwrap_or_default();
800    let excluded: BTreeSet<String> =
801        crate::ingest::advance::read_advance_store(workspace_root, &mem, &name)
802            .ok()
803            .flatten()
804            .map(|state| state.exclusions.keys().cloned().collect())
805            .unwrap_or_default();
806    if !excluded.is_empty() {
807        findings.retain(|f| {
808            !(f.class == FindingClass::Uncovered
809                && matches!(&f.target, FindingTarget::Artifact { artifact } if excluded.contains(artifact)))
810        });
811    }
812    Ok((key, findings))
813}
814
815/// Adjudicate one resolved anchor into a finding, or `None` when it resolves
816/// clean.
817///
818/// **A2 enforcement — hash-drift exclusion.** A `drifted` / `recheck` state is
819/// turned into a finding **only** for a hash-bearing class (`anchored` /
820/// `derived`). An `authored` or `informed-by` anchor is excluded from hash-drift
821/// adjudication by design: it never yields a `drifted` / `queued-for-adjudication`
822/// finding here, whatever its content did. (Existence failures — `orphaned` —
823/// are class-independent and reported for any class: a vanished artifact is not
824/// a hash-drift claim.)
825pub fn adjudicate_anchor(
826    key: &FindingKey,
827    facet: &str,
828    entity: &str,
829    anchor: &Anchor,
830    state: AnchorState,
831    created_at: &str,
832) -> Option<Finding> {
833    let (class, detail) = match state {
834        AnchorState::Resolves => return None,
835        AnchorState::Orphaned => (
836            FindingClass::UnresolvableAnchor,
837            format!(
838                "artifact '{}' the anchor references is no longer present in the medium",
839                anchor.artifact
840            ),
841        ),
842        AnchorState::Drifted | AnchorState::Recheck => {
843            // Hash-drift adjudication — excluded for non-hash-bearing classes (A2).
844            if !anchor.class.is_hash_bearing() {
845                return None;
846            }
847            match state {
848                AnchorState::Drifted => (
849                    FindingClass::Drifted,
850                    format!(
851                        "prepared-content hash of '{}' drifted from the anchored hash",
852                        anchor.artifact
853                    ),
854                ),
855                _ => (
856                    FindingClass::QueuedForAdjudication,
857                    format!(
858                        "hash adjudication of '{}' deferred (recheck); queued",
859                        anchor.artifact
860                    ),
861                ),
862            }
863        }
864    };
865    Some(Finding {
866        key: key.clone(),
867        facet: facet.to_string(),
868        target: FindingTarget::Anchor {
869            entity: entity.to_string(),
870            artifact: anchor.artifact.clone(),
871        },
872        class,
873        detail,
874        created_at: created_at.to_string(),
875    })
876}
877
878// ---------------------------------------------------------------------------
879// Tier-3 caps + scheduling (group D)
880// ---------------------------------------------------------------------------
881
882/// One source facet's enumerability — the input the full-resync scheduler
883/// reasons over (D3). Built from the capability matrix per primary facet.
884#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
885pub struct FacetEnumerability {
886    /// The source facet.
887    pub facet: String,
888    /// The medium type wire string.
889    pub medium_type: String,
890    /// Whether the medium's scope is enumerable (`S(D)` computable).
891    pub enumerable: bool,
892}
893
894/// A typed refusal from the scheduled full-enumeration walk (D3): a source facet
895/// whose medium the capability matrix marks **non-enumerable**, which the walk
896/// cannot cover. Emitted instead of a silent skip or a fabricated full-coverage
897/// claim.
898#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
899pub struct FullResyncRefusal {
900    /// The refused facet.
901    pub facet: String,
902    /// The non-enumerable medium type.
903    pub medium_type: String,
904    /// Why the scheduled walk refuses this facet.
905    pub reason: String,
906}
907
908/// The full-enumeration scheduling decision for a verify run (D3). A closed,
909/// serialized vocabulary so the caller (and the fidelity report) can render the
910/// outcome without inferring it.
911#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
912#[serde(tag = "state", rename_all = "kebab-case")]
913pub enum FullResyncDecision {
914    /// `full_resync_every == 0` — scheduled full walks are disabled; the run
915    /// uses the rotating sample only.
916    Disabled,
917    /// Scheduled but not due this run — the rotating sample runs; the counter
918    /// advances toward the next full walk.
919    NotDue {
920        /// This run's 1-based verify-run count.
921        run_count: u64,
922        /// The configured cadence.
923        every: u32,
924        /// How many further runs until the next scheduled full walk.
925        runs_until_due: u32,
926    },
927    /// Due this run: a full-enumeration walk fires for the **enumerable** facets
928    /// (guaranteeing a complete coverage picture), and every **non-enumerable**
929    /// facet is refused with a typed signal — never a silent skip, never a
930    /// fabricated full-coverage claim.
931    Due {
932        /// This run's 1-based verify-run count.
933        run_count: u64,
934        /// The configured cadence.
935        every: u32,
936        /// The facets a full enumeration walk covers this run.
937        walked_facets: Vec<String>,
938        /// The non-enumerable facets the walk refuses (typed).
939        refused: Vec<FullResyncRefusal>,
940    },
941    /// A full walk was **explicitly requested** ([`verify_binding_full`] —
942    /// the CLI's `--full`), not schedule-triggered: the whole enumerable
943    /// `S(D)` is walked, the sampling scheduler is bypassed, and the
944    /// adjudication cap is treated as unlimited. Only ever constructed after
945    /// the every-facet-enumerable gate, so it carries no per-facet refusal
946    /// list — a non-enumerable facet refuses the entire run instead
947    /// ([`FindingsError::FullWalkNonEnumerable`]).
948    Forced {
949        /// The facets the full enumeration walk covers.
950        walked_facets: Vec<String>,
951    },
952}
953
954impl FullResyncDecision {
955    /// Whether this run performs a full-enumeration walk (a scheduled sweep
956    /// is due, or an explicit full measurement was requested). `false` for
957    /// `Disabled` / `NotDue`.
958    pub fn is_full_walk(&self) -> bool {
959        matches!(
960            self,
961            FullResyncDecision::Due { .. } | FullResyncDecision::Forced { .. }
962        )
963    }
964}
965
966/// Decide the `full_resync_every` scheduling outcome for a verify run (D3) —
967/// pure and level-triggered on the persisted run counter. `every == 0` disables
968/// scheduled walks; otherwise the walk is **due** when `run_count` is a multiple
969/// of `every`. When due, enumerable facets are walked and non-enumerable facets
970/// are refused with a typed [`FullResyncRefusal`] (never silently skipped).
971pub fn schedule_full_resync(
972    every: u32,
973    run_count: u64,
974    facets: &[FacetEnumerability],
975) -> FullResyncDecision {
976    if every == 0 {
977        return FullResyncDecision::Disabled;
978    }
979    let modulo = run_count % u64::from(every);
980    if modulo != 0 {
981        return FullResyncDecision::NotDue {
982            run_count,
983            every,
984            runs_until_due: (u64::from(every) - modulo) as u32,
985        };
986    }
987    let mut walked_facets = Vec::new();
988    let mut refused = Vec::new();
989    for f in facets {
990        if f.enumerable {
991            walked_facets.push(f.facet.clone());
992        } else {
993            refused.push(FullResyncRefusal {
994                facet: f.facet.clone(),
995                medium_type: f.medium_type.clone(),
996                reason: format!(
997                    "medium type '{}' is non-enumerable — a full-enumeration walk cannot cover \
998                     it; the scheduled full resync refuses rather than claim full coverage",
999                    f.medium_type
1000                ),
1001            });
1002        }
1003    }
1004    FullResyncDecision::Due {
1005        run_count,
1006        every,
1007        walked_facets,
1008        refused,
1009    }
1010}
1011
1012/// The rotation item key a drift-adjudication candidate is selected under (D2) —
1013/// stable across runs for a given `(entity, artifact)` so the rotating window
1014/// covers a reproducible sequence.
1015fn candidate_key(entity: &str, anchor: &Anchor) -> String {
1016    format!("{entity}\u{1f}{}", anchor.artifact)
1017}
1018
1019/// Adjudicate the hash-drift **candidates** under the per-run cap (D1). Each
1020/// candidate is an anchor observation that hash-drift adjudication applies to
1021/// (a hash-bearing anchor in a `drifted` / `recheck` state). `window` is the
1022/// rotation-selected key set this run adjudicates (D2); a candidate whose
1023/// [`candidate_key`] is **not** in the window is **queued** as
1024/// `queued-for-adjudication` (the tier-3 backlog remainder) rather than
1025/// adjudicated. `window = None` means uncapped — every candidate is adjudicated.
1026///
1027/// Existence failures (`orphaned`) are **not** candidates: they are cheap
1028/// existence checks, always reported by [`verify_binding`] regardless of the
1029/// cap. Non-hash-bearing classes never reach here (they produce no adjudication).
1030fn adjudicate_candidates(
1031    key: &FindingKey,
1032    facet: &str,
1033    candidates: &[(String, Anchor, AnchorState)],
1034    window: Option<&BTreeSet<String>>,
1035    created_at: &str,
1036) -> Vec<Finding> {
1037    let mut out = Vec::new();
1038    for (entity, anchor, state) in candidates {
1039        let ck = candidate_key(entity, anchor);
1040        let adjudicate_now = window.is_none_or(|w| w.contains(&ck));
1041        if adjudicate_now {
1042            if let Some(f) = adjudicate_anchor(key, facet, entity, anchor, *state, created_at) {
1043                out.push(f);
1044            }
1045        } else {
1046            // Beyond the per-run cap: queue the remainder (D1) — it re-presents
1047            // in a later run's rotation window (D2), so the whole candidate set
1048            // is covered over a full rotation.
1049            out.push(Finding {
1050                key: key.clone(),
1051                facet: facet.to_string(),
1052                target: FindingTarget::Anchor {
1053                    entity: entity.clone(),
1054                    artifact: anchor.artifact.clone(),
1055                },
1056                class: FindingClass::QueuedForAdjudication,
1057                detail: format!(
1058                    "adjudication of '{}' deferred (per-run adjudication cap reached); queued",
1059                    anchor.artifact
1060                ),
1061                created_at: created_at.to_string(),
1062            });
1063        }
1064    }
1065    out
1066}
1067
1068/// Stable identity of a finding's subject, class-independent — the unit the
1069/// head-durable merge ([`merge_with_prior`]) matches prior and fresh findings
1070/// on.
1071fn target_key(target: &FindingTarget) -> String {
1072    match target {
1073        FindingTarget::Anchor { entity, artifact } => format!("a\u{1f}{entity}\u{1f}{artifact}"),
1074        FindingTarget::Artifact { artifact } => format!("f\u{1f}{artifact}"),
1075    }
1076}
1077
1078/// What one verify pass **observed** and what still exists — the inputs the
1079/// head-durable merge judges prior findings against.
1080struct PassObservation {
1081    /// Anchor targets ([`target_key`] form) whose live state this pass
1082    /// resolved (`Some(state)`).
1083    anchors_observed: BTreeSet<String>,
1084    /// Anchor targets still present in the mem's sidecar — any state,
1085    /// observed or not.
1086    anchors_existing: BTreeSet<String>,
1087    /// Artifact ids the coverage leg looked at this pass (the sample window,
1088    /// or the whole of `S(D)` on a full walk).
1089    files_observed: BTreeSet<String>,
1090    /// The binding's enumerable source set `S(D)`.
1091    s_d: BTreeSet<String>,
1092}
1093
1094/// Merge this pass's fresh findings with the prior open batch — the write half
1095/// of head-durable findings (the store keys on `hash(D)` alone; see the module
1096/// docs).
1097///
1098/// A **re-observed** target's outcome is this pass's: a prior finding for it
1099/// is closed (observed clean — no fresh finding) or replaced (observed still
1100/// wrong — fresh finding wins). One exception keeps supersession honest: a
1101/// fresh `queued-for-adjudication` entry is a scheduling deferral, not an
1102/// observation, so it never downgrades a prior substantive adjudication —
1103/// a prior `drifted`/`wrong` verdict stands in its place.
1104///
1105/// An **unobserved** prior finding carries forward iff its subject is still
1106/// open:
1107/// - an anchor finding carries while its anchor still exists but was
1108///   unobservable this pass; a vanished anchor closes it;
1109/// - a coverage (artifact) finding carries while the artifact is still in
1110///   `S(D)` and still carries no covering anchor (`covered_now`); departure
1111///   from `S(D)` or gained coverage closes it.
1112///
1113/// Carried findings keep their original [`Finding::key`] (the head they were
1114/// observed at). The carry rules are the growth bound: nothing is carried
1115/// whose subject left the source or re-adjudicated clean, so the open set
1116/// cannot grow without bound — and a closed/superseded finding is never
1117/// resurrected (it is simply absent from the recorded batch).
1118fn merge_with_prior(
1119    mut fresh: Vec<Finding>,
1120    prior: &[Finding],
1121    obs: &PassObservation,
1122    covered_now: impl Fn(&str) -> bool,
1123) -> Vec<Finding> {
1124    let fresh_idx: BTreeMap<String, usize> = fresh
1125        .iter()
1126        .enumerate()
1127        .map(|(i, f)| (target_key(&f.target), i))
1128        .collect();
1129    let mut carried: Vec<Finding> = Vec::new();
1130    for f in prior {
1131        let tkey = target_key(&f.target);
1132        let observed = match &f.target {
1133            FindingTarget::Anchor { .. } => obs.anchors_observed.contains(&tkey),
1134            FindingTarget::Artifact { artifact } => obs.files_observed.contains(artifact),
1135        };
1136        if observed {
1137            // Deferral must not supersede a substantive prior verdict.
1138            if matches!(f.class, FindingClass::Drifted | FindingClass::Wrong)
1139                && let Some(&i) = fresh_idx.get(&tkey)
1140                && fresh[i].class == FindingClass::QueuedForAdjudication
1141            {
1142                fresh[i] = f.clone();
1143            }
1144            continue;
1145        }
1146        if fresh_idx.contains_key(&tkey) {
1147            continue; // a fresh outcome exists for this target anyway
1148        }
1149        let still_open = match &f.target {
1150            FindingTarget::Anchor { .. } => obs.anchors_existing.contains(&tkey),
1151            FindingTarget::Artifact { artifact } => {
1152                obs.s_d.contains(artifact) && !covered_now(artifact)
1153            }
1154        };
1155        if still_open {
1156            carried.push(f.clone());
1157        }
1158    }
1159    fresh.extend(carried);
1160    fresh
1161}
1162
1163/// The thin `projection verify` write path (group A). Measures a binding's
1164/// fidelity and records durable findings under the current `(hash(D),
1165/// source_head)` key; **read-only on the destination mem** — the `&Engine`
1166/// (shared, not `&mut`) makes a mem mutation structurally impossible (A5).
1167///
1168/// It does two things a real verify does, enough to populate and exercise the
1169/// store (A1/A2): it adjudicates the destination mem's anchors against their
1170/// live source observation (via [`adjudicate_anchor`], honouring the A2
1171/// hash-drift exclusion), and it samples in-scope source artifacts through the
1172/// retained [`next_batch`] rotation (A4 — the rotation's sole surviving
1173/// consumer, used only to schedule which artifacts a pass looks at) to surface
1174/// uncovered ones. The full tier-1 fidelity report and the sync brief are
1175/// group B/C — this path deliberately renders neither.
1176pub fn verify_binding(
1177    engine: &Engine,
1178    workspace_root: &Path,
1179    binding: &Binding,
1180    resolved: &ResolvedIngest,
1181) -> Result<VerifyOutcome, FindingsError> {
1182    run_verify(engine, workspace_root, binding, resolved, false)
1183}
1184
1185/// [`verify_binding`]'s **full-measurement** mode (the CLI's `--full`):
1186/// enumerate the whole `S(D)` (the sampling scheduler is bypassed — the
1187/// rotation state is neither consulted nor advanced), treat the per-run
1188/// adjudication cap as unlimited, and observe every anchor — so the recorded
1189/// findings, and the tier-1 report computed over them, carry no
1190/// sampling/truncation caveat: coverage and accuracy are computed, not
1191/// sampled. The prepared-hash backfill worklist rides the outcome exactly as
1192/// on a sampled pass.
1193///
1194/// REFUSAL: a facet whose medium the capability matrix marks non-enumerable
1195/// refuses the **whole** run with the typed
1196/// [`FindingsError::FullWalkNonEnumerable`] — an explicit full measurement
1197/// promises complete figures, so a partial walk is never silently substituted
1198/// and a fabricated-complete report is never rendered. The sampled path
1199/// ([`verify_binding`]) is untouched by this mode's existence.
1200pub fn verify_binding_full(
1201    engine: &Engine,
1202    workspace_root: &Path,
1203    binding: &Binding,
1204    resolved: &ResolvedIngest,
1205) -> Result<VerifyOutcome, FindingsError> {
1206    run_verify(engine, workspace_root, binding, resolved, true)
1207}
1208
1209/// The shared verify pass behind [`verify_binding`] (`full = false`, the
1210/// capped/sampled loop economics) and [`verify_binding_full`] (`full = true`,
1211/// the uncapped whole-`S(D)` measurement).
1212fn run_verify(
1213    engine: &Engine,
1214    workspace_root: &Path,
1215    binding: &Binding,
1216    resolved: &ResolvedIngest,
1217    full: bool,
1218) -> Result<VerifyOutcome, FindingsError> {
1219    let binding_id = resolved.name.clone();
1220    let (mem, name) = split_binding_id(&binding_id)?;
1221
1222    // Full measurement requires every primary facet to be enumerable — refuse
1223    // the whole run typed before observing anything (never a fake-complete
1224    // report over a partially-walkable source).
1225    if full {
1226        for source in &resolved.sources {
1227            if let ResolvedSource::Primary(p) = source {
1228                let medium_type = medium_type_wire(p.medium_type);
1229                if !medium_capabilities(p.medium_type).enumerable {
1230                    return Err(FindingsError::FullWalkNonEnumerable(FullResyncRefusal {
1231                        facet: p.name.clone(),
1232                        medium_type: medium_type.clone(),
1233                        reason: format!(
1234                            "medium type '{medium_type}' is non-enumerable — a full-enumeration \
1235                             walk cannot cover it; the full measurement refuses rather than \
1236                             render a report with fabricated completeness"
1237                        ),
1238                    }));
1239                }
1240            }
1241        }
1242
1243        // The matrix claiming enumerability is not evidence that a walk
1244        // happened. When a medium is declared enumerable but its walk yields
1245        // nothing, `--full` used to sail through the gate above and return
1246        // clean over a zero-artifact measurement — coverage 0/0, every anchor
1247        // unobserved, verdict green. That is the exact shape a full
1248        // measurement exists to make impossible, so refuse it.
1249        //
1250        // This guard survives the enumerator being fixed: it is the standing
1251        // check that a future medium cannot be added to the matrix as
1252        // enumerable without an enumeration arm and still report green.
1253        // Checked PER FACET. A binding-level union hides the mixed case: one
1254        // facet that walks makes the union non-empty, so `--full` returned
1255        // clean while a sibling enumerable facet was never walked at all —
1256        // complete coverage claimed over a scope nobody looked at. Each
1257        // enumerable facet must produce something of its own.
1258        for source in &resolved.sources {
1259            if let ResolvedSource::Primary(p) = source
1260                && medium_capabilities(p.medium_type).enumerable
1261            {
1262                let walked = super::cursor::enumerate_source_artifacts_reported(
1263                    engine,
1264                    p,
1265                    &resolved.deny_paths,
1266                    workspace_root,
1267                );
1268                let medium_type = medium_type_wire(p.medium_type);
1269                // A PARTIAL walk is the case the empty-check above cannot
1270                // see: some patterns resolved, so the facet is non-empty and
1271                // the gate waved it through, and `--full` then reported
1272                // complete coverage over a denominator missing whatever the
1273                // skipped patterns would have contributed. A full measurement
1274                // promises complete figures; a known-incomplete enumeration
1275                // cannot deliver one.
1276                if let Some(why) = walked.partiality_reason() {
1277                    return Err(FindingsError::FullWalkNonEnumerable(FullResyncRefusal {
1278                        facet: p.name.clone(),
1279                        medium_type: medium_type.clone(),
1280                        reason: format!(
1281                            "this facet's enumeration is incomplete — {why} — so a full \
1282                             measurement would claim complete coverage over a denominator \
1283                             that is not the population. Fix those patterns first"
1284                        ),
1285                    }));
1286                }
1287                if walked.files.is_empty() {
1288                    // The remedy text has to name the real cause. "Check that
1289                    // its scope patterns actually select something" is wrong
1290                    // advice when the patterns DO select artifacts and merely
1291                    // speak the retired workspace-relative dialect.
1292                    let remedy = if walked.legacy_dialect.is_empty() {
1293                        "Check that its scope patterns actually select something".to_string()
1294                    } else {
1295                        format!(
1296                            "its scope pattern(s) are still written against the workspace root \
1297                             rather than the source pointer ({}), so they select nothing under \
1298                             the pointer join — rewrite them relative to the pointer",
1299                            walked
1300                                .legacy_dialect
1301                                .iter()
1302                                .map(|n| n.pattern.as_str())
1303                                .collect::<Vec<_>>()
1304                                .join(", ")
1305                        )
1306                    };
1307                    return Err(FindingsError::FullWalkNonEnumerable(FullResyncRefusal {
1308                        facet: p.name.clone(),
1309                        medium_type: medium_type.clone(),
1310                        reason: format!(
1311                            "medium type '{medium_type}' claims to be enumerable, but this \
1312                             facet's enumeration yielded no artifacts — a full measurement over \
1313                             an empty walk would report complete coverage of nothing. {remedy}"
1314                        ),
1315                    }));
1316                }
1317            }
1318        }
1319    }
1320
1321    // Refuse a vanished or unmounted path-based source before observing
1322    // anything: a missing tree would otherwise degrade to an empty
1323    // enumeration whose head token (the digest of nothing) masquerades as
1324    // a real observation — and the caller's completed-run baseline write
1325    // would clobber a genuine `#verified` token with it.
1326    for source in &resolved.sources {
1327        if let ResolvedSource::Primary(p) = source
1328            && matches!(
1329                p.medium_type,
1330                crate::pipeline::MediumType::Codebase
1331                    | crate::pipeline::MediumType::Filesystem
1332                    | crate::pipeline::MediumType::Git
1333            )
1334        {
1335            let base = super::resolve::source_base_path(p, workspace_root);
1336            // Unreachable is not only "absent". A directory that exists but
1337            // cannot be entered (permissions, a broken mount) enumerates
1338            // nothing, and the pass then reports every anchor unresolvable —
1339            // drift, in the verdict, blamed on a mem that did not move. The
1340            // read attempt is the test: existence alone let that through.
1341            // These mediums (codebase / filesystem / git) are all
1342            // directory-shaped — their scope globs enumerate under a tree —
1343            // so reachable means it IS a readable directory. A regular file
1344            // where the pointer promises a tree enumerates nothing and used
1345            // to slip through to be reported as drift, though the refusal
1346            // text already promised "present but not enumerable".
1347            let reachable = base.is_dir() && std::fs::read_dir(&base).is_ok();
1348            if !reachable {
1349                return Err(FindingsError::SourceUnreachable {
1350                    source_name: p.name.clone(),
1351                    path: base.display().to_string(),
1352                });
1353            }
1354        }
1355    }
1356
1357    // The same refusal for a graph source, which needs it just as badly and
1358    // for a worse reason. A graph source's "tree" is a mounted mem; if that
1359    // mem is absent from the workspace, every entity anchor into it misses
1360    // the store and observes as ABSENT — a definite `orphaned`, not an
1361    // honest "unobserved". The pass would then report drift, tell the reader
1362    // to repoint or unset anchors that are perfectly fine, and — because
1363    // `orphaned` is the one state that satisfies prune's all-orphaned gate —
1364    // let prune propose deleting the destination entities. An unmounted mem
1365    // must never be indistinguishable from a deleted one.
1366    for source in &resolved.sources {
1367        if let ResolvedSource::Primary(p) = source
1368            && p.medium_type == crate::pipeline::MediumType::Graph
1369            && !engine.mem_names().iter().any(|m| *m == p.pointer)
1370        {
1371            return Err(FindingsError::SourceUnreachable {
1372                source_name: p.name.clone(),
1373                path: format!("mem `{}` (not mounted in this workspace)", p.pointer),
1374            });
1375        }
1376    }
1377
1378    // The facet-head map is the key's per-facet decomposition: computed once,
1379    // joined into `key.source_head`, and returned on the outcome so a
1380    // completed run's baseline write records exactly what this run observed.
1381    let facet_heads = current_facet_heads(engine, workspace_root, resolved);
1382    let key = FindingKey {
1383        binding_hash: binding_hash_of(binding, resolved),
1384        source_head: join_facet_heads(&facet_heads),
1385    };
1386    let now = now_seconds();
1387    let facet = source_facet_label(resolved);
1388    let cache_root = workspace_root.join(".memstead.cache").join("ingest");
1389
1390    // Tier-3 operations knobs (group D): the per-run adjudication cap (D1), the
1391    // scheduled full-walk cadence (D3), and the sample window size. All come off
1392    // the `verify` block, defaulting to the dogfood-tuned engine defaults when it
1393    // is absent (verify has no mutating operation to gate — an absent block is
1394    // defaults, never a refusal).
1395    let verify_op = binding.operations.verify.as_ref();
1396    let cap = verify_op.map_or(DEFAULT_ADJUDICATION_CAP, |v| v.adjudication_cap);
1397    let full_resync_every = verify_op.map_or(DEFAULT_FULL_RESYNC_EVERY, |v| v.full_resync_every);
1398    let sample_batch = verify_op
1399        .map_or(resolved.batch_size, |v| v.batch_size)
1400        .max(1) as usize;
1401
1402    // Level-trigger clock + full-resync schedule (D3) — the counter ticks every
1403    // run (even a non-enumerable one) so the schedule can refuse on time. An
1404    // explicit full measurement ticks the same clock (it is a verify run) but
1405    // its walk decision is `Forced`, not schedule-derived: the every-facet-
1406    // enumerable gate above already held, so no per-facet refusal list exists.
1407    let run_count = bump_verify_runs(&cache_root, &binding_id);
1408    let facet_enum: Vec<FacetEnumerability> = resolved
1409        .sources
1410        .iter()
1411        .filter_map(|s| match s {
1412            ResolvedSource::Primary(p) => Some(FacetEnumerability {
1413                facet: p.name.clone(),
1414                medium_type: medium_type_wire(p.medium_type),
1415                enumerable: medium_capabilities(p.medium_type).enumerable,
1416            }),
1417            ResolvedSource::Reference { .. } => None,
1418        })
1419        .collect();
1420    let full_resync = if full {
1421        FullResyncDecision::Forced {
1422            walked_facets: facet_enum.iter().map(|f| f.facet.clone()).collect(),
1423        }
1424    } else {
1425        schedule_full_resync(full_resync_every, run_count, &facet_enum)
1426    };
1427    // A SCHEDULED due walk consults partiality the way `--full` does: the
1428    // scheduler branches on enumerability alone (it is pure and has no
1429    // filesystem), so a facet whose enumeration is known-incomplete — a
1430    // malformed or retired-dialect scope pattern — would be walked and
1431    // announced as full over a denominator that is not the population. Demote
1432    // such a facet into the typed refusal list instead, exactly where the
1433    // non-enumerable ones already land. The enumeration performed here is the
1434    // walk itself — its files feed the coverage pass below, so nothing is
1435    // enumerated twice. (`Forced` needs no demotion: the explicit-full gate
1436    // already refused the whole run on any partial facet.)
1437    let mut full_walk_files: Vec<String> = Vec::new();
1438    let full_resync = match full_resync {
1439        FullResyncDecision::Due {
1440            run_count,
1441            every,
1442            walked_facets,
1443            mut refused,
1444        } => {
1445            let mut kept: Vec<String> = Vec::new();
1446            for source in &resolved.sources {
1447                if let ResolvedSource::Primary(p) = source
1448                    && walked_facets.iter().any(|f| f == &p.name)
1449                {
1450                    let walked = super::cursor::enumerate_source_artifacts_reported(
1451                        engine,
1452                        p,
1453                        &resolved.deny_paths,
1454                        workspace_root,
1455                    );
1456                    if let Some(why) = walked.partiality_reason() {
1457                        refused.push(FullResyncRefusal {
1458                            facet: p.name.clone(),
1459                            medium_type: medium_type_wire(p.medium_type),
1460                            reason: format!(
1461                                "this facet's enumeration is incomplete — {why} — so the \
1462                                 scheduled full walk refuses it rather than announce complete \
1463                                 coverage over a denominator that is not the population"
1464                            ),
1465                        });
1466                    } else {
1467                        kept.push(p.name.clone());
1468                        full_walk_files.extend(walked.files);
1469                    }
1470                }
1471            }
1472            FullResyncDecision::Due {
1473                run_count,
1474                every,
1475                walked_facets: kept,
1476                refused,
1477            }
1478        }
1479        FullResyncDecision::Forced { walked_facets } => {
1480            for source in &resolved.sources {
1481                if let ResolvedSource::Primary(p) = source
1482                    && medium_capabilities(p.medium_type).enumerable
1483                {
1484                    full_walk_files.extend(enumerate_source_artifacts(
1485                        engine,
1486                        p,
1487                        &resolved.deny_paths,
1488                        workspace_root,
1489                    ));
1490                }
1491            }
1492            FullResyncDecision::Forced { walked_facets }
1493        }
1494        other => other,
1495    };
1496
1497    let mut findings: Vec<Finding> = Vec::new();
1498
1499    // 1. Adjudicate the destination mem's anchors against the live source, under
1500    //    the per-run cap (D1) with a rotating window (D2). Existence failures
1501    //    (orphaned) are cheap and always reported; hash-drift candidates are
1502    //    bounded — the cap-sized rotation window is adjudicated, the remainder
1503    //    queued, and successive runs rotate the window so the whole anchor set is
1504    //    covered over a full rotation.
1505    let mut existence: Vec<(String, Anchor, AnchorState)> = Vec::new();
1506    let mut candidates: Vec<(String, Anchor, AnchorState)> = Vec::new();
1507    // First-observation backfill worklist: a hash-less hash-bearing anchor
1508    // whose artifact resolved and yielded a prepared-content hash is not a
1509    // drift candidate (there is no recorded hash to compare — recorded ==
1510    // observed by construction once the backfill lands); it resolves clean
1511    // this pass and the observed hash rides the outcome for the caller's
1512    // [`record_anchor_hash_backfill`] write. From the next pass on the
1513    // anchor adjudicates deterministically — the recheck queue drains
1514    // instead of re-queueing forever.
1515    let mut hash_backfill: Vec<ObservedArtifactHash> = Vec::new();
1516    let mut backfill_seen: BTreeSet<(String, String)> = BTreeSet::new();
1517    // Observation bookkeeping for the head-durable merge: which anchor
1518    // targets exist, and which of them this pass actually resolved.
1519    let mut anchors_existing: BTreeSet<String> = BTreeSet::new();
1520    let mut anchors_observed: BTreeSet<String> = BTreeSet::new();
1521    // Scoped to this binding's population (consistency-sweep 03/01). An
1522    // excluded anchor must never raise a finding against a binding that did
1523    // not write it or has disclaimed the file; the report names the exclusions.
1524    let population = crate::ingest::anchor_population::population_for(
1525        engine,
1526        resolved,
1527        Some(binding_hash_of(binding, resolved).as_str()),
1528    );
1529    for (eid, resolved_anchor) in population.included {
1530        let tkey = target_key(&FindingTarget::Anchor {
1531            entity: eid.as_ref().to_string(),
1532            artifact: resolved_anchor.anchor.artifact.clone(),
1533        });
1534        anchors_existing.insert(tkey.clone());
1535        let Some(state) = resolved_anchor.state else {
1536            continue;
1537        };
1538        anchors_observed.insert(tkey);
1539        let observed_hash = resolved_anchor.observed_hash;
1540        let anchor = resolved_anchor.anchor;
1541        match state {
1542            AnchorState::Resolves => {}
1543            AnchorState::Orphaned => existence.push((eid.as_ref().to_string(), anchor, state)),
1544            AnchorState::Drifted | AnchorState::Recheck => {
1545                // Only hash-bearing anchors are hash-drift candidates (A2); a
1546                // non-hash-bearing class yields no adjudication.
1547                if !anchor.class.is_hash_bearing() {
1548                    continue;
1549                }
1550                if anchor.hash.is_none()
1551                    && let Some(hash) = observed_hash
1552                {
1553                    // First observation of a hash-less anchor on a resolvable
1554                    // artifact: backfill, not adjudication.
1555                    if backfill_seen.insert((eid.as_ref().to_string(), anchor.artifact.clone())) {
1556                        hash_backfill.push(ObservedArtifactHash {
1557                            entity: eid.as_ref().to_string(),
1558                            artifact: anchor.artifact.clone(),
1559                            hash,
1560                        });
1561                    }
1562                    continue;
1563                }
1564                candidates.push((eid.as_ref().to_string(), anchor, state));
1565            }
1566        }
1567    }
1568    for (entity, anchor, state) in &existence {
1569        if let Some(f) = adjudicate_anchor(&key, &facet, entity, anchor, *state, &now) {
1570            findings.push(f);
1571        }
1572    }
1573    // `cap == 0` disables the cap (adjudicate every candidate), and a full
1574    // measurement treats any configured cap as unlimited — its rotation state
1575    // is neither consulted nor advanced (the scheduler is bypassed, so the
1576    // sampled loop's window sequence is untouched by a full run). Otherwise a
1577    // cap-sized rotation window selects this run's adjudicated set (D1/D2).
1578    let window: Option<BTreeSet<String>> = if full || cap == 0 {
1579        None
1580    } else {
1581        let mut keys: Vec<String> = candidates
1582            .iter()
1583            .map(|(e, a, _)| candidate_key(e, a))
1584            .collect();
1585        keys.sort();
1586        keys.dedup();
1587        next_rotation_batch(
1588            &cache_root,
1589            &binding_id,
1590            ROTATION_ANCHOR_ADJUDICATION,
1591            keys,
1592            cap as usize,
1593        )
1594        .map(|b| b.files.into_iter().collect())
1595    };
1596    findings.extend(adjudicate_candidates(
1597        &key,
1598        &facet,
1599        &candidates,
1600        window.as_ref(),
1601        &now,
1602    ));
1603
1604    // 2. Sample in-scope source artifacts for coverage. When a full walk is due
1605    //    (D3) or explicitly requested (`Forced`), enumerate the WHOLE source of
1606    //    every enumerable facet — guaranteeing complete coverage this run;
1607    //    otherwise sample a bounded rotating window (D2). Non-enumerable facets
1608    //    are refused (scheduled: the typed refusal rides on `full_resync`;
1609    //    explicit: the whole run refused before observing), never silently
1610    //    claimed as covered.
1611    let sample_files: Vec<String> = if full_resync.is_full_walk() {
1612        // Collected above where the walk decision was settled — only facets
1613        // the decision actually announces as walked contribute.
1614        let mut all = full_walk_files;
1615        all.sort();
1616        all.dedup();
1617        all
1618    } else {
1619        next_batch(engine, resolved, workspace_root, &cache_root, sample_batch)
1620            .map(|b| b.files)
1621            .unwrap_or_default()
1622    };
1623    // Filtered by BINDING, not merely by mem (consistency-sweep 03/01,
1624    // criterion 7). The report's coverage lookup was scoped first and this one
1625    // was missed, which is the worse of the two: this decides whether an
1626    // `Uncovered` finding is RECORDED and whether a prior one stays open, so a
1627    // mem filter here let another binding's anchor mark a file covered in the
1628    // durable store. An anchor with no recorded binding still counts, by the
1629    // same pre-provenance fallback the population uses.
1630    let this_binding = binding_hash_of(binding, resolved);
1631    // An anchor whose ENTITY is gone covers nothing (03/02, criterion 5),
1632    // guarded on the reconciliation having been possible at all so an
1633    // unreconcilable mem keeps its coverage rather than reading as wholly
1634    // uncovered.
1635    let entity_end_reconciled = engine
1636        .entity_set_is_reconcilable(&resolved.destination_mem)
1637        .is_ok();
1638    let covered_now = |artifact: &str| {
1639        engine
1640            .anchors_referencing_artifact(artifact)
1641            .iter()
1642            .any(|(eid, a)| {
1643                eid.mem() == resolved.destination_mem.as_str()
1644                    && a.binding
1645                        .as_deref()
1646                        .map(|b| b == this_binding.as_str())
1647                        .unwrap_or(true)
1648                    && (!entity_end_reconciled || !engine.entity_is_absent(eid))
1649            })
1650    };
1651    // The durable authored-exclusion ledger (B4) gates the RECORDING, not
1652    // only the report's decoration: an artifact mined and deliberately
1653    // excluded with a rationale is not an uncovered finding. Until
1654    // 2026-08-28 only the report body consulted the ledger, so the verdict
1655    // line and the findings store kept counting exclusions as uncovered
1656    // (three of them on plugin/graph) while the rationales rendered right
1657    // beside the count.
1658    let excluded: BTreeSet<String> =
1659        crate::ingest::advance::read_advance_store(workspace_root, &mem, &name)
1660            .ok()
1661            .flatten()
1662            .map(|state| state.exclusions.keys().cloned().collect())
1663            .unwrap_or_default();
1664    for file in &sample_files {
1665        if !covered_now(file) && !excluded.contains(file) {
1666            findings.push(Finding {
1667                key: key.clone(),
1668                facet: facet.clone(),
1669                target: FindingTarget::Artifact {
1670                    artifact: file.clone(),
1671                },
1672                class: FindingClass::Uncovered,
1673                detail: "source artifact in scope has no anchor in the destination mem".to_string(),
1674                created_at: now.clone(),
1675            });
1676        }
1677    }
1678
1679    // 3. Head-durable merge (the store keys on hash(D) alone): fold the prior
1680    //    open batch into this pass's findings — re-observed targets take this
1681    //    pass's outcome; unobserved-but-still-open ones carry forward with
1682    //    their original observed head; departed/covered/vanished subjects
1683    //    close. Sync briefs thus keep presenting an open finding across
1684    //    source-head movement until a pass observes it clean.
1685    let mut store = read_findings_store(workspace_root, &mem, &name)
1686        .map_err(FindingsError::Store)?
1687        .unwrap_or_else(|| FindingsStore {
1688            binding: binding_id.clone(),
1689            ..Default::default()
1690        });
1691    let mut s_d: BTreeSet<String> = BTreeSet::new();
1692    for source in &resolved.sources {
1693        if let ResolvedSource::Primary(p) = source
1694            && medium_capabilities(p.medium_type).enumerable
1695        {
1696            s_d.extend(enumerate_source_artifacts(
1697                engine,
1698                p,
1699                &resolved.deny_paths,
1700                workspace_root,
1701            ));
1702        }
1703    }
1704    let obs = PassObservation {
1705        anchors_observed,
1706        anchors_existing,
1707        files_observed: sample_files.into_iter().collect(),
1708        s_d,
1709    };
1710    let prior = store.current(&key).to_vec();
1711    let findings = merge_with_prior(findings, &prior, &obs, covered_now);
1712
1713    let backlog = findings
1714        .iter()
1715        .filter(|f| f.class == FindingClass::QueuedForAdjudication)
1716        .count();
1717
1718    // Record under the current key (prior-hash batches retained, segregated —
1719    // A3), persist to the durable state tier (A1).
1720    let recorded = findings.len();
1721    store.record(key.clone(), now, findings);
1722    let superseded = store.superseded(&key).len();
1723    write_findings_store(workspace_root, &mem, &name, &store).map_err(FindingsError::Store)?;
1724
1725    Ok(VerifyOutcome {
1726        binding: binding_id,
1727        key,
1728        recorded,
1729        superseded,
1730        backlog,
1731        full_resync,
1732        facet_heads,
1733        hash_backfill,
1734    })
1735}
1736
1737/// The medium type's wire string (`codebase` / `web` / …) — the serde form the
1738/// capability matrix and reports use.
1739fn medium_type_wire(t: crate::pipeline::MediumType) -> String {
1740    serde_json::to_value(t)
1741        .ok()
1742        .and_then(|v| v.as_str().map(str::to_string))
1743        .unwrap_or_default()
1744}
1745
1746#[cfg(test)]
1747mod tests {
1748    use super::*;
1749    use crate::anchor::{Anchor, AnchorGrain, AnchorHashStability, AnchorProvenanceClass};
1750
1751    fn key(hash: &str, head: &str) -> FindingKey {
1752        FindingKey {
1753            binding_hash: hash.to_string(),
1754            source_head: head.to_string(),
1755        }
1756    }
1757
1758    fn anchor(class: AnchorProvenanceClass) -> Anchor {
1759        Anchor {
1760            artifact: "src/lib.rs".to_string(),
1761            grain: AnchorGrain::File,
1762            class,
1763            at_version: None,
1764            hash: if class.is_hash_bearing() {
1765                Some("h1".to_string())
1766            } else {
1767                None
1768            },
1769            hash_stability: AnchorHashStability::Stable,
1770            derived_from: Vec::new(),
1771            binding: None,
1772            source: None,
1773            span_unvalidated: false,
1774            hash_source: None,
1775        }
1776    }
1777
1778    /// The store round-trips through serde and survives a write/read cycle on
1779    /// disk — the durability A1 rests on.
1780    #[test]
1781    fn store_round_trips_on_disk_and_delete_is_idempotent() {
1782        let tmp = tempfile::tempdir().unwrap();
1783        let root = tmp.path();
1784        assert!(
1785            read_findings_store(root, "engine", "graph")
1786                .unwrap()
1787                .is_none()
1788        );
1789
1790        let mut store = FindingsStore {
1791            binding: "engine/graph".to_string(),
1792            ..Default::default()
1793        };
1794        let k = key("hashA", "head1");
1795        store.record(
1796            k.clone(),
1797            "1".to_string(),
1798            vec![Finding {
1799                key: k.clone(),
1800                facet: "src".to_string(),
1801                target: FindingTarget::Artifact {
1802                    artifact: "src/a.rs".to_string(),
1803                },
1804                class: FindingClass::Uncovered,
1805                detail: "d".to_string(),
1806                created_at: "1".to_string(),
1807            }],
1808        );
1809        write_findings_store(root, "engine", "graph", &store).unwrap();
1810        assert!(findings_store_path(root, "engine", "graph").exists());
1811
1812        // The store subtree self-ignores: per-checkout engine state must
1813        // not surface as untracked noise in a tracked workspace.
1814        let ignore = root
1815            .join(WORKSPACE_STORE_DIR)
1816            .join(STATE_DIR)
1817            .join(FINDINGS_DIR)
1818            .join(".gitignore");
1819        assert_eq!(std::fs::read_to_string(&ignore).unwrap(), "*\n");
1820
1821        // Fresh read from disk (a later process) sees the findings (A1).
1822        let back = read_findings_store(root, "engine", "graph")
1823            .unwrap()
1824            .unwrap();
1825        assert_eq!(back, store);
1826        assert_eq!(back.current(&k).len(), 1);
1827
1828        delete_findings_store(root, "engine", "graph").unwrap();
1829        assert!(
1830            read_findings_store(root, "engine", "graph")
1831                .unwrap()
1832                .is_none()
1833        );
1834        // Idempotent.
1835        delete_findings_store(root, "engine", "graph").unwrap();
1836    }
1837
1838    /// A3 — a changed `hash(D)` segregates the prior batch: findings under the
1839    /// old hash are never `current` under the new key, only `superseded`.
1840    #[test]
1841    fn changed_binding_hash_supersedes_prior_findings() {
1842        let mut store = FindingsStore::default();
1843        let old = key("hashOLD", "head1");
1844        let new = key("hashNEW", "head1");
1845        let f_old = Finding {
1846            key: old.clone(),
1847            facet: "src".to_string(),
1848            target: FindingTarget::Artifact {
1849                artifact: "src/old.rs".to_string(),
1850            },
1851            class: FindingClass::Uncovered,
1852            detail: "old".to_string(),
1853            created_at: "1".to_string(),
1854        };
1855        store.record(old.clone(), "1".to_string(), vec![f_old.clone()]);
1856
1857        // Recording under the new key must not touch the old batch.
1858        store.record(new.clone(), "2".to_string(), Vec::new());
1859        assert!(store.current(&new).is_empty(), "new key has its own view");
1860        let superseded = store.superseded(&new);
1861        assert_eq!(superseded.len(), 1, "old batch is segregated as superseded");
1862        assert_eq!(superseded[0], &f_old);
1863        // The old findings are never presented as current under the new key.
1864        assert!(!store.current(&new).contains(&f_old));
1865    }
1866
1867    /// The impl-version bump's documented invalidation-by-construction: a
1868    /// finding recorded under the `hash(D)` a prior engine generation
1869    /// computed (`PREPARATION_IMPL_VERSION` 0, for a binding declaring no
1870    /// preparation at all) is not current under the live hash — segregated
1871    /// as superseded, never presented — because the impl version is hashed
1872    /// into every binding's identity. The old key still reads its own batch,
1873    /// so nothing is deleted, only retired from the current view.
1874    #[test]
1875    fn impl_version_bump_invalidates_findings_by_construction() {
1876        use crate::binding::{
1877            PREPARATION_IMPL_VERSION, ScaffoldParams, hash_binding, hash_binding_at_impl_version,
1878            scaffold_binding,
1879        };
1880        let binding = scaffold_binding(ScaffoldParams {
1881            destination_mem: "plugin",
1882            source_name: "source-tree",
1883            pointer: "../public",
1884            medium_type: crate::pipeline::MediumType::Codebase,
1885            intent: None,
1886            additional_deny_paths: Vec::new(),
1887        })
1888        .binding;
1889        assert!(binding.sources[0].preparation.is_none());
1890        // The live constant is whatever the latest landed implementation set
1891        // it to; the pin is that the version-0 hash (the pre-registry
1892        // generation) is not the live one.
1893        let _ = PREPARATION_IMPL_VERSION;
1894        let old = key(&hash_binding_at_impl_version(&binding, 0), "head1");
1895        let live = key(&hash_binding(&binding), "head1");
1896        assert_ne!(old.binding_hash, live.binding_hash);
1897
1898        let mut store = FindingsStore::default();
1899        let f_old = Finding {
1900            key: old.clone(),
1901            facet: "source-tree".to_string(),
1902            target: FindingTarget::Artifact {
1903                artifact: "src/old.rs".to_string(),
1904            },
1905            class: FindingClass::Uncovered,
1906            detail: "recorded before the bump".to_string(),
1907            created_at: "1".to_string(),
1908        };
1909        store.record(old.clone(), "1".to_string(), vec![f_old.clone()]);
1910
1911        assert!(
1912            store.current(&live).is_empty(),
1913            "a finding keyed on the pre-bump hash is invalid under the live hash"
1914        );
1915        assert_eq!(store.superseded(&live), vec![&f_old]);
1916        assert_eq!(
1917            store.current(&old),
1918            &[f_old.clone()][..],
1919            "nothing is deleted"
1920        );
1921    }
1922
1923    /// Criterion — findings survive head movement: the store keys on `hash(D)`
1924    /// alone, so a finding recorded at head1 stays `current` when read at
1925    /// head2 (the sync brief's read is head-agnostic), still carrying the head
1926    /// it was observed at as metadata. REFUSAL half: recording the hash's next
1927    /// batch (verify's post-merge write) replaces it — a finding absent from
1928    /// that batch (resolved) never re-presents, at any head.
1929    #[test]
1930    fn moved_source_head_keeps_findings_current_until_superseded() {
1931        let mut store = FindingsStore::default();
1932        let before = key("hashA", "head1");
1933        let after = key("hashA", "head2");
1934        let f = Finding {
1935            key: before.clone(),
1936            facet: "src".to_string(),
1937            target: FindingTarget::Anchor {
1938                entity: "engine--e".to_string(),
1939                artifact: "src/x.rs".to_string(),
1940            },
1941            class: FindingClass::UnresolvableAnchor,
1942            detail: "gone".to_string(),
1943            created_at: "1".to_string(),
1944        };
1945        store.record(before.clone(), "1".to_string(), vec![f.clone()]);
1946
1947        // The head moved; the finding is still presented, with its observed
1948        // head intact, and it is not "superseded".
1949        assert_eq!(store.current(&after), std::slice::from_ref(&f));
1950        assert_eq!(store.current(&after)[0].key.source_head, "head1");
1951        assert!(store.superseded(&after).is_empty());
1952
1953        // A verify at head2 records the hash's next batch WITHOUT the finding
1954        // (its target observed clean) → resolved, never re-presented.
1955        store.record(after.clone(), "2".to_string(), Vec::new());
1956        assert!(store.current(&after).is_empty());
1957        assert!(store.current(&before).is_empty(), "at the old head too");
1958        assert_eq!(store.batches.len(), 1, "one batch per hash(D)");
1959    }
1960
1961    /// Migration/compat — a store written by the pre-re-key engine (batches
1962    /// keyed `(hash(D), source_head)`; the exact on-disk shape live dogfood
1963    /// workspaces carry) loads without loss: the other-hash batch stays
1964    /// segregated as superseded, the current-hash batch presents at ANY head,
1965    /// and a legacy same-hash pair collapses to its latest-recorded batch —
1966    /// never resurrecting the older (superseded-at-write-time) one. The next
1967    /// `record` folds the same-hash siblings into one batch.
1968    #[test]
1969    fn legacy_per_head_store_loads_and_presents_head_agnostically() {
1970        let tmp = tempfile::tempdir().unwrap();
1971        let root = tmp.path();
1972        let path = findings_store_path(root, "engine", "graph");
1973        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1974        // Trimmed replica of the live on-disk format: `{binding, batches:[{key:
1975        // {binding_hash, source_head}, recorded_at, findings:[{key, facet,
1976        // target:{kind,...}, class, detail, created_at}]}]}` — one batch under
1977        // an old hash, two batches under the current hash at different heads.
1978        std::fs::write(
1979            &path,
1980            r#"{
1981              "binding": "engine/graph",
1982              "batches": [
1983                {
1984                  "key": { "binding_hash": "hashOLD", "source_head": "src=aaa" },
1985                  "recorded_at": "100",
1986                  "findings": [
1987                    {
1988                      "key": { "binding_hash": "hashOLD", "source_head": "src=aaa" },
1989                      "facet": "src",
1990                      "target": { "kind": "artifact", "artifact": "src/old.rs" },
1991                      "class": "uncovered",
1992                      "detail": "old declaration",
1993                      "created_at": "100"
1994                    }
1995                  ]
1996                },
1997                {
1998                  "key": { "binding_hash": "hashCUR", "source_head": "src=bbb" },
1999                  "recorded_at": "200",
2000                  "findings": [
2001                    {
2002                      "key": { "binding_hash": "hashCUR", "source_head": "src=bbb" },
2003                      "facet": "src",
2004                      "target": { "kind": "artifact", "artifact": "src/resolved-at-ccc.rs" },
2005                      "class": "uncovered",
2006                      "detail": "was open at bbb, absent from the ccc batch",
2007                      "created_at": "200"
2008                    }
2009                  ]
2010                },
2011                {
2012                  "key": { "binding_hash": "hashCUR", "source_head": "src=ccc" },
2013                  "recorded_at": "300",
2014                  "findings": [
2015                    {
2016                      "key": { "binding_hash": "hashCUR", "source_head": "src=ccc" },
2017                      "facet": "src",
2018                      "target": { "kind": "anchor", "entity": "engine--e", "artifact": "src/x.rs" },
2019                      "class": "unresolvable-anchor",
2020                      "detail": "gone",
2021                      "created_at": "300"
2022                    }
2023                  ]
2024                }
2025              ]
2026            }"#,
2027        )
2028        .unwrap();
2029
2030        let mut store = read_findings_store(root, "engine", "graph")
2031            .unwrap()
2032            .expect("the legacy on-disk format loads as-is");
2033        assert_eq!(store.binding, "engine/graph");
2034        assert_eq!(store.batches.len(), 3, "loaded without loss");
2035
2036        // Head-agnostic current view: reading at a NEWLY moved head (ddd —
2037        // recorded nowhere) presents the latest current-hash batch.
2038        let now = key("hashCUR", "src=ddd");
2039        let current = store.current(&now);
2040        assert_eq!(current.len(), 1);
2041        assert_eq!(current[0].detail, "gone");
2042        assert_eq!(
2043            current[0].key.source_head, "src=ccc",
2044            "the finding keeps the head it was observed at"
2045        );
2046        // The pre-re-key superseded batches (old hash + the older same-hash
2047        // head) stay segregated — never mixed into the current view.
2048        let superseded = store.superseded(&now);
2049        assert_eq!(superseded.len(), 2);
2050        assert!(
2051            !current.iter().any(|f| f.detail.contains("was open at bbb")),
2052            "the older same-hash batch was superseded at write time and is not resurrected"
2053        );
2054
2055        // The next record under the current hash collapses the legacy
2056        // same-hash pair into one batch; the old-hash batch is untouched.
2057        store.record(now.clone(), "400".to_string(), Vec::new());
2058        assert_eq!(store.batches.len(), 2, "hashCUR collapsed, hashOLD kept");
2059        assert_eq!(store.superseded(&now).len(), 1);
2060    }
2061
2062    /// The head-durable merge: an unobserved-but-still-open prior finding
2063    /// carries forward (original observed head intact); a prior finding whose
2064    /// artifact left `S(D)`, gained coverage, or whose anchor vanished closes;
2065    /// a re-observed target takes this pass's outcome (clean → closed).
2066    #[test]
2067    fn merge_carries_unobserved_open_findings_and_closes_departed() {
2068        let k_old = key("h", "head1");
2069        let mk_artifact = |artifact: &str, detail: &str| Finding {
2070            key: k_old.clone(),
2071            facet: "src".to_string(),
2072            target: FindingTarget::Artifact {
2073                artifact: artifact.to_string(),
2074            },
2075            class: FindingClass::Uncovered,
2076            detail: detail.to_string(),
2077            created_at: "1".to_string(),
2078        };
2079        let anchor_finding = Finding {
2080            key: k_old.clone(),
2081            facet: "src".to_string(),
2082            target: FindingTarget::Anchor {
2083                entity: "engine--gone".to_string(),
2084                artifact: "src/gone.rs".to_string(),
2085            },
2086            class: FindingClass::UnresolvableAnchor,
2087            detail: "anchor since removed from the mem".to_string(),
2088            created_at: "1".to_string(),
2089        };
2090        let prior = vec![
2091            mk_artifact("src/unsampled.rs", "still open, not in this window"),
2092            mk_artifact("src/departed.rs", "left S(D)"),
2093            mk_artifact("src/now-covered.rs", "gained an anchor since"),
2094            mk_artifact("src/observed-clean.rs", "re-sampled and now covered"),
2095            anchor_finding,
2096        ];
2097        let obs = PassObservation {
2098            anchors_observed: BTreeSet::new(),
2099            anchors_existing: BTreeSet::new(), // the anchor vanished
2100            files_observed: ["src/observed-clean.rs".to_string()].into(),
2101            s_d: [
2102                "src/unsampled.rs".to_string(),
2103                "src/now-covered.rs".to_string(),
2104                "src/observed-clean.rs".to_string(),
2105            ]
2106            .into(),
2107        };
2108        let merged = merge_with_prior(Vec::new(), &prior, &obs, |artifact| {
2109            artifact == "src/now-covered.rs" || artifact == "src/observed-clean.rs"
2110        });
2111        assert_eq!(merged.len(), 1, "only the still-open unsampled one carries");
2112        assert_eq!(
2113            merged[0].target,
2114            FindingTarget::Artifact {
2115                artifact: "src/unsampled.rs".to_string()
2116            }
2117        );
2118        assert_eq!(
2119            merged[0].key.source_head, "head1",
2120            "a carried finding keeps the head it was observed at"
2121        );
2122    }
2123
2124    /// Supersession honesty: a fresh `queued-for-adjudication` entry is a
2125    /// scheduling deferral, not an observation — it never downgrades a prior
2126    /// substantive `drifted` verdict for the same target. A fresh substantive
2127    /// outcome (or a clean observation) still supersedes normally.
2128    #[test]
2129    fn merge_deferral_never_downgrades_prior_adjudication() {
2130        let k_old = key("h", "head1");
2131        let k_new = key("h", "head2");
2132        let target = FindingTarget::Anchor {
2133            entity: "engine--e".to_string(),
2134            artifact: "src/x.rs".to_string(),
2135        };
2136        let prior_drifted = Finding {
2137            key: k_old.clone(),
2138            facet: "src".to_string(),
2139            target: target.clone(),
2140            class: FindingClass::Drifted,
2141            detail: "adjudicated drifted at head1".to_string(),
2142            created_at: "1".to_string(),
2143        };
2144        let fresh_queued = Finding {
2145            key: k_new.clone(),
2146            facet: "src".to_string(),
2147            target: target.clone(),
2148            class: FindingClass::QueuedForAdjudication,
2149            detail: "deferred by the cap this run".to_string(),
2150            created_at: "2".to_string(),
2151        };
2152        let obs = PassObservation {
2153            anchors_observed: [target_key(&target)].into(),
2154            anchors_existing: [target_key(&target)].into(),
2155            files_observed: BTreeSet::new(),
2156            s_d: BTreeSet::new(),
2157        };
2158        let merged = merge_with_prior(
2159            vec![fresh_queued],
2160            std::slice::from_ref(&prior_drifted),
2161            &obs,
2162            |_| true,
2163        );
2164        assert_eq!(merged.len(), 1);
2165        assert_eq!(
2166            merged[0].class,
2167            FindingClass::Drifted,
2168            "the prior verdict stands over a deferral"
2169        );
2170        assert_eq!(merged[0].key.source_head, "head1");
2171    }
2172
2173    /// A2 — hash-drift adjudication is excluded for `informed-by` (and every
2174    /// non-hash-bearing class): a drifted/recheck state yields NO finding.
2175    #[test]
2176    fn informed_by_anchor_never_drifts() {
2177        let k = key("h", "s");
2178        for class in [
2179            AnchorProvenanceClass::InformedBy,
2180            AnchorProvenanceClass::Authored,
2181        ] {
2182            let a = anchor(class);
2183            assert!(
2184                adjudicate_anchor(&k, "f", "engine--e", &a, AnchorState::Drifted, "1").is_none(),
2185                "{class:?} must not produce a drift finding"
2186            );
2187            assert!(
2188                adjudicate_anchor(&k, "f", "engine--e", &a, AnchorState::Recheck, "1").is_none(),
2189                "{class:?} must not produce a queued finding"
2190            );
2191        }
2192    }
2193
2194    /// A2 — hash-bearing classes DO produce drift/recheck findings, and every
2195    /// class produces an existence (`unresolvable-anchor`) finding when orphaned.
2196    #[test]
2197    fn hash_bearing_drifts_and_orphan_is_class_independent() {
2198        let k = key("h", "s");
2199        let anchored = anchor(AnchorProvenanceClass::Anchored);
2200        let drifted =
2201            adjudicate_anchor(&k, "f", "engine--e", &anchored, AnchorState::Drifted, "1").unwrap();
2202        assert_eq!(drifted.class, FindingClass::Drifted);
2203        assert_eq!(drifted.key, k, "the finding carries its recording key (A2)");
2204
2205        let queued =
2206            adjudicate_anchor(&k, "f", "engine--e", &anchored, AnchorState::Recheck, "1").unwrap();
2207        assert_eq!(queued.class, FindingClass::QueuedForAdjudication);
2208
2209        // Orphaned is existence, not hash-drift — reported for informed-by too.
2210        let informed = anchor(AnchorProvenanceClass::InformedBy);
2211        let orphan =
2212            adjudicate_anchor(&k, "f", "engine--e", &informed, AnchorState::Orphaned, "1").unwrap();
2213        assert_eq!(orphan.class, FindingClass::UnresolvableAnchor);
2214
2215        // Resolves yields nothing.
2216        assert!(
2217            adjudicate_anchor(&k, "f", "engine--e", &anchored, AnchorState::Resolves, "1")
2218                .is_none()
2219        );
2220    }
2221
2222    /// The finding class vocabulary round-trips through its wire form.
2223    #[test]
2224    fn finding_class_wire_round_trips() {
2225        for w in FindingClass::WIRE_VALUES {
2226            let c = FindingClass::from_wire(w).expect("known wire value");
2227            assert_eq!(c.as_wire(), *w);
2228        }
2229        assert!(FindingClass::from_wire("nonsense").is_none());
2230    }
2231
2232    /// A malformed binding id refuses before touching the store tier.
2233    #[test]
2234    fn malformed_binding_id_refuses() {
2235        assert!(matches!(
2236            split_binding_id("../escape"),
2237            Err(FindingsError::MalformedId(_))
2238        ));
2239        assert!(matches!(
2240            split_binding_id("no-slash"),
2241            Err(FindingsError::MalformedId(_))
2242        ));
2243        assert_eq!(
2244            split_binding_id("engine/graph").unwrap(),
2245            ("engine".to_string(), "graph".to_string())
2246        );
2247    }
2248
2249    // ---- A1/A5 end-to-end: verify writes durable findings, no entity write --
2250
2251    use crate::anchor::AnchorSidecar;
2252    use crate::binding::{
2253        BINDING_VERSION, BuildMode, BuildOperation, CoverageSemantics, DEFAULT_ADJUDICATION_CAP,
2254        DEFAULT_FULL_RESYNC_EVERY, Operations, VerifyOperation,
2255    };
2256    use crate::ingest::resolve::resolve_binding_run;
2257    use crate::pipeline::{IngestTrigger, MediumType, PatternEntry, PatternMode};
2258    use crate::pipeline_store::{load_pipeline_configs, write_binding};
2259    use crate::workspace::{
2260        Mount, MountCapability, MountLifecycle, MountStorage, Workspace, WorkspaceSettings,
2261    };
2262    use crate::workspace_store::WorkspaceStoreAdapter;
2263
2264    /// A full verify pass over a folder mem: it adjudicates the mem's anchors
2265    /// against the live source (orphaned → unresolvable-anchor; present
2266    /// hash-bearing whose recorded hash mismatches the observed prepared form
2267    /// → deterministic `drifted`; informed-by → no finding, A2) and flags an
2268    /// uncovered source file, then persists the findings to the durable state
2269    /// tier. A **fresh** read from disk (a later process) sees them (A1). The
2270    /// pass runs on a shared `&Engine` — structurally read-only on the mem (A5).
2271    #[test]
2272    fn verify_persists_findings_readable_fresh() {
2273        let tmp = tempfile::tempdir().unwrap();
2274        let root = tmp.path();
2275        let mem_dir = root.join("mem");
2276        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
2277        std::fs::write(
2278            mem_dir.join(".memstead").join("config.json"),
2279            r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
2280        )
2281        .unwrap();
2282
2283        // Workspace state so `from_workspace_root` sets `workspace_root` (which
2284        // the anchor observation and cursor need) and mounts the `engine` mem.
2285        std::fs::create_dir_all(root.join(".memstead")).unwrap();
2286        std::fs::write(
2287            root.join(".memstead").join("workspace.toml"),
2288            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2289        )
2290        .unwrap();
2291        let mount = Mount {
2292            mem: "engine".to_string(),
2293            schema: Some("default@1.0.0".parse().unwrap()),
2294            storage: MountStorage::Folder {
2295                path: mem_dir.clone(),
2296            },
2297            capability: MountCapability::Write,
2298            lifecycle: MountLifecycle::Eager,
2299            cross_linkable: false,
2300            migration_target: None,
2301        };
2302        crate::FileWorkspaceStore::new()
2303            .save_state(
2304                root,
2305                &Workspace {
2306                    mounts: vec![mount],
2307                    settings: WorkspaceSettings::default(),
2308                },
2309            )
2310            .unwrap();
2311
2312        // A git work tree at the workspace root so the codebase medium's `git`
2313        // change strategy resolves; source files: one anchored+present, one
2314        // uncovered.
2315        let out = std::process::Command::new("git")
2316            .args(["init", "-q"])
2317            .current_dir(root)
2318            .output()
2319            .unwrap();
2320        assert!(out.status.success());
2321        std::fs::create_dir_all(root.join("src")).unwrap();
2322        std::fs::write(root.join("src").join("present.rs"), "fn a() {}\n").unwrap();
2323        std::fs::write(root.join("src").join("uncovered.rs"), "fn b() {}\n").unwrap();
2324
2325        // Seed the engine-owned anchors sidecar directly (test fixture — the
2326        // production write path is the mutation surface, not this verify code).
2327        let mk = |artifact: &str, class: AnchorProvenanceClass| Anchor {
2328            artifact: artifact.to_string(),
2329            grain: AnchorGrain::File,
2330            class,
2331            at_version: None,
2332            hash: class.is_hash_bearing().then(|| "recorded".to_string()),
2333            hash_stability: AnchorHashStability::Stable,
2334            derived_from: Vec::new(),
2335            binding: None,
2336            source: None,
2337            span_unvalidated: false,
2338            hash_source: None,
2339        };
2340        // The entity the sidecar is keyed to. Written, because it exists:
2341        // a row whose entity does not is DANGLING (consistency-sweep 03/02)
2342        // and leaves the population before any figure counts it.
2343        std::fs::write(
2344            mem_dir.join("e.md"),
2345            "---\ntype: decision\n---\n\n# E\n\n## Decision\n\nBody.\n",
2346        )
2347        .unwrap();
2348        let mut sidecar = AnchorSidecar::default();
2349        sidecar.set(
2350            "engine--e",
2351            vec![
2352                mk("src/present.rs", AnchorProvenanceClass::Anchored), // recorded hash mismatches prepared form → drifted
2353                mk("src/gone.rs", AnchorProvenanceClass::Anchored), // absent → unresolvable-anchor
2354                mk("src/present.rs", AnchorProvenanceClass::InformedBy), // present, non-hash → no finding (A2)
2355            ],
2356        );
2357        std::fs::write(
2358            mem_dir.join(crate::anchor::ANCHOR_SIDECAR_PATH),
2359            sidecar.to_bytes(),
2360        )
2361        .unwrap();
2362
2363        // Binding engine/graph over a codebase facet (medium root = workspace).
2364        write_binding(
2365            root,
2366            "engine",
2367            "graph",
2368            &Binding {
2369                version: BINDING_VERSION,
2370                intent: None,
2371                sources: vec![crate::pipeline::Source {
2372                    name: "graph".to_string(),
2373                    medium_type: MediumType::Codebase,
2374                    pointer: String::new(),
2375                    change_detection: Some("git".to_string()),
2376                    scope: vec![PatternEntry {
2377                        path: "src/**/*.rs".to_string(),
2378                        mode: PatternMode::Allow,
2379                    }],
2380                    engagement: None,
2381                    preparation: None,
2382                }],
2383                reference_mems: Vec::new(),
2384                destination_mem: "engine".to_string(),
2385                deny_paths: Vec::new(),
2386                coverage_semantics: None,
2387                rules: None,
2388                prune: None,
2389                operations: Operations {
2390                    build: Some(BuildOperation {
2391                        mode: BuildMode::Discovery,
2392                        trigger: IngestTrigger::Loop,
2393                        batch_size: 20,
2394                        post_actions: None,
2395                    }),
2396                    sync: None,
2397                    verify: Some(VerifyOperation {
2398                        trigger: IngestTrigger::Manual,
2399                        batch_size: 20,
2400                        adjudication_cap: DEFAULT_ADJUDICATION_CAP,
2401                        full_resync_every: DEFAULT_FULL_RESYNC_EVERY,
2402                    }),
2403                },
2404            },
2405        )
2406        .unwrap();
2407
2408        let engine = Engine::from_workspace_root(root).unwrap();
2409
2410        let configs = load_pipeline_configs(root).unwrap();
2411        let binding = &configs.bindings[0].config;
2412        let resolved = resolve_binding_run("engine/graph", binding).unwrap();
2413
2414        // `&engine` — shared borrow, structurally cannot mutate the mem (A5).
2415        let outcome = verify_binding(&engine, root, binding, &resolved).unwrap();
2416        assert!(
2417            outcome.recorded >= 3,
2418            "orphan + drifted + uncovered at least"
2419        );
2420        assert_eq!(outcome.superseded, 0, "no prior key yet");
2421        assert_eq!(
2422            outcome.backlog, 0,
2423            "the mismatching hash adjudicated deterministically — nothing queued"
2424        );
2425        assert!(
2426            outcome.hash_backfill.is_empty(),
2427            "every hash-bearing anchor already carries a recorded hash — nothing to backfill"
2428        );
2429
2430        // Fresh read from disk — a later process / sync-brief render (A1).
2431        let store = read_findings_store(root, "engine", "graph")
2432            .unwrap()
2433            .unwrap();
2434        let current = store.current(&outcome.key);
2435        assert_eq!(current.len(), outcome.recorded);
2436
2437        let has = |c: FindingClass, art: &str| {
2438            current.iter().any(|f| {
2439                f.class == c
2440                    && match &f.target {
2441                        FindingTarget::Anchor { artifact, .. } => artifact == art,
2442                        FindingTarget::Artifact { artifact } => artifact == art,
2443                    }
2444            })
2445        };
2446        assert!(has(FindingClass::UnresolvableAnchor, "src/gone.rs"));
2447        assert!(
2448            has(FindingClass::Drifted, "src/present.rs"),
2449            "recorded-hash mismatch on a stable medium adjudicates drifted deterministically"
2450        );
2451        assert!(has(FindingClass::Uncovered, "src/uncovered.rs"));
2452        // A2: the informed-by anchor on the present file produced no finding —
2453        // the one drifted finding above belongs to the anchored (hash-bearing)
2454        // anchor, and nothing queued.
2455        assert!(
2456            !current
2457                .iter()
2458                .any(|f| f.class == FindingClass::QueuedForAdjudication
2459                    || f.class == FindingClass::Wrong),
2460            "deterministic adjudication leaves nothing queued"
2461        );
2462        // The covered file is not flagged uncovered.
2463        assert!(!has(FindingClass::Uncovered, "src/present.rs"));
2464    }
2465
2466    /// Criterion, end-to-end — **findings survive head movement**: a finding
2467    /// recorded at head H keeps presenting through the sync brief's read
2468    /// (`current_findings` / `render_sync_brief_for`) after the source
2469    /// advances to H′, until a verify observes its subject clean — and once
2470    /// resolved it never re-presents, at any head.
2471    #[test]
2472    fn finding_recorded_at_old_head_presents_in_brief_at_new_head() {
2473        use crate::ingest::render::render_sync_brief_for;
2474
2475        let tmp = tempfile::tempdir().unwrap();
2476        let root = tmp.path();
2477        let mem_dir = root.join("mem");
2478        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
2479        std::fs::write(
2480            mem_dir.join(".memstead").join("config.json"),
2481            r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
2482        )
2483        .unwrap();
2484        std::fs::create_dir_all(root.join(".memstead")).unwrap();
2485        std::fs::write(
2486            root.join(".memstead").join("workspace.toml"),
2487            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2488        )
2489        .unwrap();
2490        let mount = Mount {
2491            mem: "engine".to_string(),
2492            schema: Some("default@1.0.0".parse().unwrap()),
2493            storage: MountStorage::Folder {
2494                path: mem_dir.clone(),
2495            },
2496            capability: MountCapability::Write,
2497            lifecycle: MountLifecycle::Eager,
2498            cross_linkable: false,
2499            migration_target: None,
2500        };
2501        crate::FileWorkspaceStore::new()
2502            .save_state(
2503                root,
2504                &Workspace {
2505                    mounts: vec![mount],
2506                    settings: WorkspaceSettings::default(),
2507                },
2508            )
2509            .unwrap();
2510
2511        // Git source tree at head A: src/present.rs committed.
2512        let git = |args: &[&str]| {
2513            let out = std::process::Command::new("git")
2514                .args(args)
2515                .current_dir(root)
2516                .env("GIT_AUTHOR_NAME", "t")
2517                .env("GIT_AUTHOR_EMAIL", "t@t")
2518                .env("GIT_COMMITTER_NAME", "t")
2519                .env("GIT_COMMITTER_EMAIL", "t@t")
2520                .output()
2521                .unwrap();
2522            assert!(
2523                out.status.success(),
2524                "git {args:?}: {}",
2525                String::from_utf8_lossy(&out.stderr)
2526            );
2527        };
2528        git(&["init", "-q"]);
2529        std::fs::create_dir_all(root.join("src")).unwrap();
2530        std::fs::write(root.join("src").join("present.rs"), "fn a() {}\n").unwrap();
2531        git(&["add", "-A"]);
2532        git(&["commit", "-qm", "head-a"]);
2533
2534        // Anchors: `informed-by` on the present file (clean, non-hash — no
2535        // finding) and on the ABSENT src/gone.rs (orphaned → the finding).
2536        let mk = |artifact: &str| Anchor {
2537            artifact: artifact.to_string(),
2538            grain: AnchorGrain::File,
2539            class: AnchorProvenanceClass::InformedBy,
2540            at_version: None,
2541            hash: None,
2542            hash_stability: AnchorHashStability::Stable,
2543            derived_from: Vec::new(),
2544            binding: None,
2545            source: None,
2546            span_unvalidated: false,
2547            hash_source: None,
2548        };
2549        // The entity the sidecar is keyed to. Written, because it exists:
2550        // a row whose entity does not is DANGLING (consistency-sweep 03/02)
2551        // and leaves the population before any figure counts it.
2552        std::fs::write(
2553            mem_dir.join("e.md"),
2554            "---\ntype: decision\n---\n\n# E\n\n## Decision\n\nBody.\n",
2555        )
2556        .unwrap();
2557        let mut sidecar = AnchorSidecar::default();
2558        sidecar.set("engine--e", vec![mk("src/present.rs"), mk("src/gone.rs")]);
2559        std::fs::write(
2560            mem_dir.join(crate::anchor::ANCHOR_SIDECAR_PATH),
2561            sidecar.to_bytes(),
2562        )
2563        .unwrap();
2564
2565        write_binding(
2566            root,
2567            "engine",
2568            "graph",
2569            &Binding {
2570                version: BINDING_VERSION,
2571                intent: None,
2572                sources: vec![crate::pipeline::Source {
2573                    name: "graph".to_string(),
2574                    medium_type: MediumType::Codebase,
2575                    pointer: String::new(),
2576                    change_detection: Some("git".to_string()),
2577                    scope: vec![PatternEntry {
2578                        path: "src/**/*.rs".to_string(),
2579                        mode: PatternMode::Allow,
2580                    }],
2581                    engagement: None,
2582                    preparation: None,
2583                }],
2584                reference_mems: Vec::new(),
2585                destination_mem: "engine".to_string(),
2586                deny_paths: Vec::new(),
2587                coverage_semantics: None,
2588                rules: None,
2589                prune: None,
2590                operations: Operations {
2591                    build: None,
2592                    sync: Some(crate::binding::SyncOperation {
2593                        trigger: IngestTrigger::Manual,
2594                        batch_size: 20,
2595                    }),
2596                    verify: Some(VerifyOperation {
2597                        trigger: IngestTrigger::Manual,
2598                        batch_size: 20,
2599                        adjudication_cap: DEFAULT_ADJUDICATION_CAP,
2600                        full_resync_every: DEFAULT_FULL_RESYNC_EVERY,
2601                    }),
2602                },
2603            },
2604        )
2605        .unwrap();
2606
2607        // Verify at head A — records the orphaned-anchor finding.
2608        let configs = load_pipeline_configs(root).unwrap();
2609        let binding = &configs.bindings[0].config;
2610        let resolved = resolve_binding_run("engine/graph", binding).unwrap();
2611        let head_a_outcome = {
2612            let engine = Engine::from_workspace_root(root).unwrap();
2613            verify_binding(&engine, root, binding, &resolved).unwrap()
2614        };
2615        assert!(
2616            head_a_outcome.key.source_head.contains("graph="),
2617            "the run observed a facet head"
2618        );
2619
2620        // The source moves to head B.
2621        std::fs::write(root.join("src").join("present.rs"), "fn a() {} // more\n").unwrap();
2622        git(&["add", "-A"]);
2623        git(&["commit", "-qm", "head-b"]);
2624
2625        // A fresh process at head B: the finding recorded at head A is still
2626        // presented — by the brief's read AND in the rendered sync brief.
2627        {
2628            let engine = Engine::from_workspace_root(root).unwrap();
2629            let (key_b, findings) = current_findings(&engine, root, binding, &resolved).unwrap();
2630            assert_ne!(
2631                key_b.source_head, head_a_outcome.key.source_head,
2632                "the head really moved"
2633            );
2634            assert_eq!(findings.len(), 1);
2635            assert_eq!(findings[0].class, FindingClass::UnresolvableAnchor);
2636            assert_eq!(
2637                findings[0].key.source_head, head_a_outcome.key.source_head,
2638                "the finding still records the head it was observed at"
2639            );
2640
2641            let brief = render_sync_brief_for(&engine, root, "engine/graph").unwrap();
2642            assert!(brief.contains("## Open findings to repair"));
2643            assert!(brief.contains("src/gone.rs"));
2644        }
2645
2646        // The repair lands: src/gone.rs exists again (head C). A verify
2647        // observes the anchor clean → the finding closes…
2648        std::fs::write(root.join("src").join("gone.rs"), "fn g() {}\n").unwrap();
2649        git(&["add", "-A"]);
2650        git(&["commit", "-qm", "head-c"]);
2651        {
2652            let engine = Engine::from_workspace_root(root).unwrap();
2653            verify_binding(&engine, root, binding, &resolved).unwrap();
2654        }
2655        // …and never re-presents (REFUSAL: resolved findings stay resolved).
2656        {
2657            let engine = Engine::from_workspace_root(root).unwrap();
2658            let (_key, findings) = current_findings(&engine, root, binding, &resolved).unwrap();
2659            assert!(
2660                findings
2661                    .iter()
2662                    .all(|f| f.class != FindingClass::UnresolvableAnchor),
2663                "the resolved orphan finding must not re-present: {findings:?}"
2664            );
2665        }
2666    }
2667
2668    /// Prepared-hash backfill + deterministic drift, end-to-end over real git
2669    /// heads and fresh engines:
2670    ///
2671    /// 1. a hash-less `anchored`/`derived` anchor on a resolvable artifact is
2672    ///    backfilled by the first verify (once — a re-verify observes an empty
2673    ///    worklist and the recorded hash is never overwritten);
2674    /// 2. after a source change, a subsequent verify adjudicates `drifted`
2675    ///    deterministically — no LLM sampling, no queued deferral;
2676    /// 3. the tier-3 recheck queue for such anchors drains: post-backfill
2677    ///    clean passes queue nothing, instead of re-queueing forever.
2678    ///
2679    /// REFUSAL half: `authored` / `informed-by` anchors never gain hashes and
2680    /// never adjudicate `drifted`; an `unstable` hash-stability medium
2681    /// resolves `recheck` (queued), never `drifted`.
2682    #[test]
2683    fn hashless_anchor_backfills_once_then_drift_adjudicates_deterministically() {
2684        let tmp = tempfile::tempdir().unwrap();
2685        let root = tmp.path();
2686        let mem_dir = root.join("mem");
2687        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
2688        std::fs::write(
2689            mem_dir.join(".memstead").join("config.json"),
2690            r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
2691        )
2692        .unwrap();
2693        std::fs::create_dir_all(root.join(".memstead")).unwrap();
2694        std::fs::write(
2695            root.join(".memstead").join("workspace.toml"),
2696            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2697        )
2698        .unwrap();
2699        let mount = Mount {
2700            mem: "engine".to_string(),
2701            schema: Some("default@1.0.0".parse().unwrap()),
2702            storage: MountStorage::Folder {
2703                path: mem_dir.clone(),
2704            },
2705            capability: MountCapability::Write,
2706            lifecycle: MountLifecycle::Eager,
2707            cross_linkable: false,
2708            migration_target: None,
2709        };
2710        crate::FileWorkspaceStore::new()
2711            .save_state(
2712                root,
2713                &Workspace {
2714                    mounts: vec![mount],
2715                    settings: WorkspaceSettings::default(),
2716                },
2717            )
2718            .unwrap();
2719
2720        // Git source tree at head A: two committed source files.
2721        let git = |args: &[&str]| {
2722            let out = std::process::Command::new("git")
2723                .args(args)
2724                .current_dir(root)
2725                .env("GIT_AUTHOR_NAME", "t")
2726                .env("GIT_AUTHOR_EMAIL", "t@t")
2727                .env("GIT_COMMITTER_NAME", "t")
2728                .env("GIT_COMMITTER_EMAIL", "t@t")
2729                .output()
2730                .unwrap();
2731            assert!(
2732                out.status.success(),
2733                "git {args:?}: {}",
2734                String::from_utf8_lossy(&out.stderr)
2735            );
2736        };
2737        git(&["init", "-q"]);
2738        std::fs::create_dir_all(root.join("src")).unwrap();
2739        std::fs::write(root.join("src").join("present.rs"), "fn a() {}\n").unwrap();
2740        std::fs::write(root.join("src").join("other.rs"), "fn o() {}\n").unwrap();
2741        git(&["add", "-A"]);
2742        git(&["commit", "-qm", "head-a"]);
2743
2744        // Anchors, all HASH-LESS: `anchored` (stable) + `derived` (stable) on
2745        // present.rs, `anchored` but UNSTABLE on other.rs, and the two
2746        // non-hash classes that must never gain a hash.
2747        let mk = |artifact: &str, class: AnchorProvenanceClass, stab: AnchorHashStability| Anchor {
2748            artifact: artifact.to_string(),
2749            grain: AnchorGrain::File,
2750            class,
2751            at_version: None,
2752            hash: None,
2753            hash_stability: stab,
2754            derived_from: if class == AnchorProvenanceClass::Derived {
2755                vec!["src/present.rs".to_string()]
2756            } else {
2757                Vec::new()
2758            },
2759            binding: None,
2760            source: None,
2761            span_unvalidated: false,
2762            hash_source: None,
2763        };
2764        use AnchorHashStability::{Stable, Unstable};
2765        // The entity the sidecar is keyed to. Written, because it exists:
2766        // a row whose entity does not is DANGLING (consistency-sweep 03/02)
2767        // and leaves the population before any figure counts it.
2768        std::fs::write(
2769            mem_dir.join("e.md"),
2770            "---\ntype: decision\n---\n\n# E\n\n## Decision\n\nBody.\n",
2771        )
2772        .unwrap();
2773        let mut sidecar = AnchorSidecar::default();
2774        sidecar.set(
2775            "engine--e",
2776            vec![
2777                mk("src/present.rs", AnchorProvenanceClass::Anchored, Stable),
2778                mk("src/present.rs", AnchorProvenanceClass::Derived, Stable),
2779                mk("src/other.rs", AnchorProvenanceClass::Anchored, Unstable),
2780                mk("src/present.rs", AnchorProvenanceClass::Authored, Stable),
2781                mk("src/present.rs", AnchorProvenanceClass::InformedBy, Stable),
2782            ],
2783        );
2784        std::fs::write(
2785            mem_dir.join(crate::anchor::ANCHOR_SIDECAR_PATH),
2786            sidecar.to_bytes(),
2787        )
2788        .unwrap();
2789
2790        write_binding(
2791            root,
2792            "engine",
2793            "graph",
2794            &Binding {
2795                version: BINDING_VERSION,
2796                intent: None,
2797                sources: vec![crate::pipeline::Source {
2798                    name: "graph".to_string(),
2799                    medium_type: MediumType::Codebase,
2800                    pointer: String::new(),
2801                    change_detection: Some("git".to_string()),
2802                    scope: vec![PatternEntry {
2803                        path: "src/**/*.rs".to_string(),
2804                        mode: PatternMode::Allow,
2805                    }],
2806                    engagement: None,
2807                    preparation: None,
2808                }],
2809                reference_mems: Vec::new(),
2810                destination_mem: "engine".to_string(),
2811                deny_paths: Vec::new(),
2812                coverage_semantics: None,
2813                rules: None,
2814                prune: None,
2815                operations: Operations {
2816                    build: None,
2817                    sync: None,
2818                    verify: Some(VerifyOperation {
2819                        trigger: IngestTrigger::Manual,
2820                        batch_size: 20,
2821                        adjudication_cap: DEFAULT_ADJUDICATION_CAP,
2822                        full_resync_every: DEFAULT_FULL_RESYNC_EVERY,
2823                    }),
2824                },
2825            },
2826        )
2827        .unwrap();
2828
2829        let configs = load_pipeline_configs(root).unwrap();
2830        let binding = &configs.bindings[0].config;
2831        let resolved = resolve_binding_run("engine/graph", binding).unwrap();
2832
2833        // --- Pass 1: first observation backfills, once. ---
2834        {
2835            let mut engine = Engine::from_workspace_root(root).unwrap();
2836            let outcome = verify_binding(&engine, root, binding, &resolved).unwrap();
2837            // Every hash-less hash-bearing anchor is on the worklist —
2838            // including the unstable one; the non-hash classes are not.
2839            let mut backfilled: Vec<(&str, &str)> = outcome
2840                .hash_backfill
2841                .iter()
2842                .map(|b| (b.entity.as_str(), b.artifact.as_str()))
2843                .collect();
2844            backfilled.sort();
2845            backfilled.dedup();
2846            assert_eq!(
2847                backfilled,
2848                vec![
2849                    ("engine--e", "src/other.rs"),
2850                    ("engine--e", "src/present.rs"),
2851                ],
2852                "hash-bearing anchors backfill; authored/informed-by never appear"
2853            );
2854            // Backfill candidates are clean-by-construction this pass —
2855            // nothing queued, nothing drifted (the recheck queue drains).
2856            assert_eq!(
2857                outcome.backlog, 0,
2858                "no recheck queue for backfilled anchors"
2859            );
2860            let store = read_findings_store(root, "engine", "graph")
2861                .unwrap()
2862                .unwrap();
2863            assert!(
2864                store
2865                    .current(&outcome.key)
2866                    .iter()
2867                    .all(|f| !matches!(f.target, FindingTarget::Anchor { .. })),
2868                "no anchor finding on the backfill pass: {:?}",
2869                store.current(&outcome.key)
2870            );
2871
2872            // The sanctioned post-run write records the hashes.
2873            let written =
2874                record_anchor_hash_backfill(&mut engine, "engine", &outcome, None).unwrap();
2875            assert_eq!(
2876                written, 3,
2877                "anchored + derived + unstable-anchored gain hashes"
2878            );
2879        }
2880
2881        // The sidecar now carries the observed prepared-form hashes — and the
2882        // non-hash classes still carry none (class semantics preserved).
2883        let expected_present = crate::anchor::prepared_content_hash(
2884            &std::fs::read(root.join("src").join("present.rs")).unwrap(),
2885        );
2886        {
2887            let sc = AnchorSidecar::from_bytes(
2888                &std::fs::read(mem_dir.join(crate::anchor::ANCHOR_SIDECAR_PATH)).unwrap(),
2889            )
2890            .unwrap();
2891            for a in sc.get("engine--e") {
2892                if a.class.is_hash_bearing() {
2893                    assert!(a.hash.is_some(), "hash-bearing anchor backfilled: {a:?}");
2894                } else {
2895                    assert!(a.hash.is_none(), "non-hash class never gains a hash: {a:?}");
2896                }
2897                if a.artifact == "src/present.rs" && a.class.is_hash_bearing() {
2898                    assert_eq!(a.hash.as_deref(), Some(expected_present.as_str()));
2899                }
2900            }
2901        }
2902
2903        // --- Pass 2 (fresh engine): idempotent — nothing to backfill, clean. ---
2904        {
2905            let mut engine = Engine::from_workspace_root(root).unwrap();
2906            let outcome = verify_binding(&engine, root, binding, &resolved).unwrap();
2907            assert!(
2908                outcome.hash_backfill.is_empty(),
2909                "backfill happens once — a re-verify observes an empty worklist"
2910            );
2911            assert_eq!(outcome.backlog, 0, "steady state: nothing re-queues");
2912            let store = read_findings_store(root, "engine", "graph")
2913                .unwrap()
2914                .unwrap();
2915            assert!(
2916                store
2917                    .current(&outcome.key)
2918                    .iter()
2919                    .all(|f| !matches!(f.target, FindingTarget::Anchor { .. })),
2920                "recorded hashes match the source — no anchor finding"
2921            );
2922            let written =
2923                record_anchor_hash_backfill(&mut engine, "engine", &outcome, None).unwrap();
2924            assert_eq!(written, 0, "no write, no commit on the idempotent pass");
2925        }
2926
2927        // --- Source change: both anchored artifacts move (head B). ---
2928        std::fs::write(
2929            root.join("src").join("present.rs"),
2930            "fn a() { /* changed */ }\n",
2931        )
2932        .unwrap();
2933        std::fs::write(
2934            root.join("src").join("other.rs"),
2935            "fn o() { /* changed */ }\n",
2936        )
2937        .unwrap();
2938        git(&["add", "-A"]);
2939        git(&["commit", "-qm", "head-b"]);
2940
2941        // --- Pass 3: deterministic adjudication — stable drifts, unstable
2942        //     rechecks, non-hash classes stay silent. ---
2943        {
2944            let engine = Engine::from_workspace_root(root).unwrap();
2945            let outcome = verify_binding(&engine, root, binding, &resolved).unwrap();
2946            assert!(
2947                outcome.hash_backfill.is_empty(),
2948                "recorded hashes are never overwritten by observation"
2949            );
2950            let store = read_findings_store(root, "engine", "graph")
2951                .unwrap()
2952                .unwrap();
2953            let current = store.current(&outcome.key);
2954            let drifted: Vec<&Finding> = current
2955                .iter()
2956                .filter(|f| f.class == FindingClass::Drifted)
2957                .collect();
2958            // The stable `anchored` + `derived` anchors on present.rs drift —
2959            // deterministically, from the hash comparison alone.
2960            assert_eq!(
2961                drifted.len(),
2962                2,
2963                "stable-medium mismatch → drifted: {current:?}"
2964            );
2965            assert!(drifted.iter().all(|f| matches!(
2966                &f.target,
2967                FindingTarget::Anchor { artifact, .. } if artifact == "src/present.rs"
2968            )));
2969            // REFUSAL: the unstable anchor on other.rs resolves recheck →
2970            // queued, never drifted.
2971            assert!(
2972                current
2973                    .iter()
2974                    .any(|f| f.class == FindingClass::QueuedForAdjudication
2975                        && matches!(
2976                            &f.target,
2977                            FindingTarget::Anchor { artifact, .. } if artifact == "src/other.rs"
2978                        )),
2979                "unstable medium resolves recheck (queued), not drifted: {current:?}"
2980            );
2981            assert!(
2982                !current.iter().any(|f| f.class == FindingClass::Drifted
2983                    && matches!(
2984                        &f.target,
2985                        FindingTarget::Anchor { artifact, .. } if artifact == "src/other.rs"
2986                    )),
2987                "an unstable hash break must never assert drift"
2988            );
2989        }
2990    }
2991
2992    /// The engine's backfill writer enforces the class guard at the write
2993    /// seam: an `authored` / `informed-by` anchor never gains a hash even if
2994    /// a (buggy or malicious) caller hands one in, and a recorded hash is
2995    /// never overwritten.
2996    #[test]
2997    fn backfill_writer_refuses_non_hash_classes_and_never_overwrites() {
2998        let tmp = tempfile::tempdir().unwrap();
2999        let root = tmp.path();
3000        let mem_dir = root.join("mem");
3001        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
3002        std::fs::write(
3003            mem_dir.join(".memstead").join("config.json"),
3004            r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
3005        )
3006        .unwrap();
3007        std::fs::create_dir_all(root.join(".memstead")).unwrap();
3008        std::fs::write(
3009            root.join(".memstead").join("workspace.toml"),
3010            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
3011        )
3012        .unwrap();
3013        crate::FileWorkspaceStore::new()
3014            .save_state(
3015                root,
3016                &Workspace {
3017                    mounts: vec![Mount {
3018                        mem: "engine".to_string(),
3019                        schema: Some("default@1.0.0".parse().unwrap()),
3020                        storage: MountStorage::Folder {
3021                            path: mem_dir.clone(),
3022                        },
3023                        capability: MountCapability::Write,
3024                        lifecycle: MountLifecycle::Eager,
3025                        cross_linkable: false,
3026                        migration_target: None,
3027                    }],
3028                    settings: WorkspaceSettings::default(),
3029                },
3030            )
3031            .unwrap();
3032
3033        let anchor = |class: AnchorProvenanceClass, hash: Option<&str>| Anchor {
3034            artifact: "src/a.rs".to_string(),
3035            grain: AnchorGrain::File,
3036            class,
3037            at_version: None,
3038            hash: hash.map(str::to_string),
3039            hash_stability: AnchorHashStability::Stable,
3040            derived_from: Vec::new(),
3041            binding: None,
3042            source: None,
3043            span_unvalidated: false,
3044            hash_source: None,
3045        };
3046        // The entity the sidecar is keyed to. Written, because it exists:
3047        // a row whose entity does not is DANGLING (consistency-sweep 03/02)
3048        // and leaves the population before any figure counts it.
3049        std::fs::write(
3050            mem_dir.join("e.md"),
3051            "---\ntype: decision\n---\n\n# E\n\n## Decision\n\nBody.\n",
3052        )
3053        .unwrap();
3054        let mut sidecar = AnchorSidecar::default();
3055        sidecar.set(
3056            "engine--e",
3057            vec![
3058                anchor(AnchorProvenanceClass::Authored, None),
3059                anchor(AnchorProvenanceClass::InformedBy, None),
3060                anchor(AnchorProvenanceClass::Anchored, Some("recorded")),
3061            ],
3062        );
3063        std::fs::write(
3064            mem_dir.join(crate::anchor::ANCHOR_SIDECAR_PATH),
3065            sidecar.to_bytes(),
3066        )
3067        .unwrap();
3068
3069        let mut engine = Engine::from_workspace_root(root).unwrap();
3070        let written = engine
3071            .record_anchor_observed_hashes(
3072                "engine",
3073                &[crate::anchor::ObservedArtifactHash {
3074                    entity: "engine--e".to_string(),
3075                    artifact: "src/a.rs".to_string(),
3076                    hash: "observed".to_string(),
3077                }],
3078                None,
3079            )
3080            .unwrap();
3081        assert_eq!(
3082            written, 0,
3083            "non-hash classes refuse the hash; a recorded hash is never overwritten"
3084        );
3085        let sc = AnchorSidecar::from_bytes(
3086            &std::fs::read(mem_dir.join(crate::anchor::ANCHOR_SIDECAR_PATH)).unwrap(),
3087        )
3088        .unwrap();
3089        for a in sc.get("engine--e") {
3090            match a.class {
3091                AnchorProvenanceClass::Anchored => {
3092                    assert_eq!(a.hash.as_deref(), Some("recorded"), "baseline stands")
3093                }
3094                _ => assert!(a.hash.is_none(), "non-hash class stays hash-less: {a:?}"),
3095            }
3096        }
3097    }
3098
3099    /// The completed-run `#verified` writer (backlog 2026-07-11): a verify
3100    /// pass surfaces its observed facet heads on the outcome (the per-facet
3101    /// decomposition of `key.source_head`), and [`record_verified_baseline`]
3102    /// records them as `<binding>/<facet>#verified` through the engine's
3103    /// sync-state writer — durable on disk, visible to the same config read
3104    /// `report`/`status` consume. A failed pass returns
3105    /// `Err` before any caller reaches the writer, so the token never
3106    /// advances on an aborted run.
3107    /// A vanished source directory must refuse verify with the typed
3108    /// `SourceUnreachable` error instead of degrading to an empty
3109    /// enumeration: pre-fix, the missing tree produced an empty stat map
3110    /// whose aggregate (the digest of nothing) completed the run and let
3111    /// the caller overwrite a genuine `#verified` baseline with fake
3112    /// state. The engine mem itself stays loadable — only the binding's
3113    /// source is gone.
3114    #[test]
3115    fn verify_refuses_unreachable_source_with_typed_error() {
3116        let tmp = tempfile::tempdir().unwrap();
3117        let root = tmp.path();
3118        let mem_dir = root.join("mem");
3119        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
3120        std::fs::write(
3121            mem_dir.join(".memstead").join("config.json"),
3122            r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
3123        )
3124        .unwrap();
3125        std::fs::create_dir_all(root.join(".memstead")).unwrap();
3126        std::fs::write(
3127            root.join(".memstead").join("workspace.toml"),
3128            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
3129        )
3130        .unwrap();
3131        let mount = Mount {
3132            mem: "engine".to_string(),
3133            schema: Some("default@1.0.0".parse().unwrap()),
3134            storage: MountStorage::Folder {
3135                path: mem_dir.clone(),
3136            },
3137            capability: MountCapability::Write,
3138            lifecycle: MountLifecycle::Eager,
3139            cross_linkable: false,
3140            migration_target: None,
3141        };
3142        crate::FileWorkspaceStore::new()
3143            .save_state(
3144                root,
3145                &Workspace {
3146                    mounts: vec![mount],
3147                    settings: WorkspaceSettings::default(),
3148                },
3149            )
3150            .unwrap();
3151
3152        // The medium points at a subdirectory that does NOT exist — the
3153        // vanished-source case (`git` declared, so pre-fix the strategy
3154        // layer silently degraded instead of refusing).
3155        write_binding(
3156            root,
3157            "engine",
3158            "gone",
3159            &Binding {
3160                version: BINDING_VERSION,
3161                intent: None,
3162                sources: vec![crate::pipeline::Source {
3163                    name: "gone".to_string(),
3164                    medium_type: MediumType::Codebase,
3165                    pointer: "vanished-src".to_string(),
3166                    change_detection: Some("git".to_string()),
3167                    scope: vec![PatternEntry {
3168                        path: "**/*.rs".to_string(),
3169                        mode: PatternMode::Allow,
3170                    }],
3171                    engagement: None,
3172                    preparation: None,
3173                }],
3174                reference_mems: Vec::new(),
3175                destination_mem: "engine".to_string(),
3176                deny_paths: Vec::new(),
3177                coverage_semantics: None,
3178                rules: None,
3179                prune: None,
3180                operations: Operations {
3181                    build: None,
3182                    sync: None,
3183                    verify: Some(VerifyOperation {
3184                        trigger: IngestTrigger::Manual,
3185                        batch_size: 20,
3186                        adjudication_cap: DEFAULT_ADJUDICATION_CAP,
3187                        full_resync_every: DEFAULT_FULL_RESYNC_EVERY,
3188                    }),
3189                },
3190            },
3191        )
3192        .unwrap();
3193
3194        let engine = Engine::from_workspace_root(root).unwrap();
3195        let configs = load_pipeline_configs(root).unwrap();
3196        let binding = &configs.bindings[0].config;
3197        let resolved = resolve_binding_run("engine/gone", binding).unwrap();
3198
3199        match verify_binding(&engine, root, binding, &resolved) {
3200            Err(FindingsError::SourceUnreachable { source_name, path }) => {
3201                assert_eq!(source_name, "gone");
3202                assert!(
3203                    path.ends_with("vanished-src"),
3204                    "refusal must name the resolved missing path, got `{path}`",
3205                );
3206            }
3207            other => panic!("expected SourceUnreachable refusal, got {other:?}"),
3208        }
3209
3210        // Nothing was observed → no `#verified` token exists (the caller
3211        // never reaches its baseline write on an Err).
3212        assert!(
3213            !engine
3214                .mem_config_for("engine")
3215                .unwrap()
3216                .sync_state
3217                .keys()
3218                .any(|k| k.ends_with("#verified")),
3219            "a refused verify must not leave any #verified token",
3220        );
3221    }
3222
3223    #[test]
3224    fn completed_verify_records_the_verified_baseline() {
3225        let tmp = tempfile::tempdir().unwrap();
3226        let root = tmp.path();
3227        let mem_dir = root.join("mem");
3228        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
3229        std::fs::write(
3230            mem_dir.join(".memstead").join("config.json"),
3231            r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
3232        )
3233        .unwrap();
3234        std::fs::create_dir_all(root.join(".memstead")).unwrap();
3235        std::fs::write(
3236            root.join(".memstead").join("workspace.toml"),
3237            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
3238        )
3239        .unwrap();
3240        let mount = Mount {
3241            mem: "engine".to_string(),
3242            schema: Some("default@1.0.0".parse().unwrap()),
3243            storage: MountStorage::Folder {
3244                path: mem_dir.clone(),
3245            },
3246            capability: MountCapability::Write,
3247            lifecycle: MountLifecycle::Eager,
3248            cross_linkable: false,
3249            migration_target: None,
3250        };
3251        crate::FileWorkspaceStore::new()
3252            .save_state(
3253                root,
3254                &Workspace {
3255                    mounts: vec![mount],
3256                    settings: WorkspaceSettings::default(),
3257                },
3258            )
3259            .unwrap();
3260        let out = std::process::Command::new("git")
3261            .args(["init", "-q"])
3262            .current_dir(root)
3263            .output()
3264            .unwrap();
3265        assert!(out.status.success());
3266
3267        write_binding(
3268            root,
3269            "engine",
3270            "graph",
3271            &Binding {
3272                version: BINDING_VERSION,
3273                intent: None,
3274                sources: vec![crate::pipeline::Source {
3275                    name: "graph".to_string(),
3276                    medium_type: MediumType::Codebase,
3277                    pointer: String::new(),
3278                    change_detection: Some("git".to_string()),
3279                    scope: vec![PatternEntry {
3280                        path: "src/**/*.rs".to_string(),
3281                        mode: PatternMode::Allow,
3282                    }],
3283                    engagement: None,
3284                    preparation: None,
3285                }],
3286                reference_mems: Vec::new(),
3287                destination_mem: "engine".to_string(),
3288                deny_paths: Vec::new(),
3289                coverage_semantics: None,
3290                rules: None,
3291                prune: None,
3292                operations: Operations {
3293                    build: Some(BuildOperation {
3294                        mode: BuildMode::Discovery,
3295                        trigger: IngestTrigger::Loop,
3296                        batch_size: 20,
3297                        post_actions: None,
3298                    }),
3299                    sync: None,
3300                    verify: Some(VerifyOperation {
3301                        trigger: IngestTrigger::Manual,
3302                        batch_size: 20,
3303                        adjudication_cap: DEFAULT_ADJUDICATION_CAP,
3304                        full_resync_every: DEFAULT_FULL_RESYNC_EVERY,
3305                    }),
3306                },
3307            },
3308        )
3309        .unwrap();
3310
3311        let mut engine = Engine::from_workspace_root(root).unwrap();
3312        // A recorded `#synced` baseline is this facet's current head (the git
3313        // work tree has no commits, so the cursor contributes no newer token).
3314        engine
3315            .set_mem_sync_state("engine", "engine/graph/graph#synced", "deadbeef", None)
3316            .unwrap();
3317
3318        let configs = load_pipeline_configs(root).unwrap();
3319        let binding = &configs.bindings[0].config;
3320        let resolved = resolve_binding_run("engine/graph", binding).unwrap();
3321
3322        let outcome = verify_binding(&engine, root, binding, &resolved).unwrap();
3323        // The outcome decomposes its own key: joined facet heads == source_head.
3324        assert_eq!(
3325            outcome.facet_heads.get("graph").map(String::as_str),
3326            Some("deadbeef")
3327        );
3328        assert_eq!(outcome.key.source_head, "graph=deadbeef");
3329        assert_eq!(
3330            join_facet_heads(&outcome.facet_heads),
3331            outcome.key.source_head
3332        );
3333
3334        // No `#verified` token exists before the writer runs.
3335        assert!(
3336            !engine
3337                .mem_config_for("engine")
3338                .unwrap()
3339                .sync_state
3340                .contains_key("engine/graph/graph#verified")
3341        );
3342
3343        let written = record_verified_baseline(&mut engine, "engine", &outcome, None).unwrap();
3344        assert_eq!(written, vec!["engine/graph/graph#verified".to_string()]);
3345
3346        // Visible to the engine's config read (the app's sync_state source)…
3347        assert_eq!(
3348            engine
3349                .mem_config_for("engine")
3350                .unwrap()
3351                .sync_state
3352                .get("engine/graph/graph#verified")
3353                .map(String::as_str),
3354            Some("deadbeef")
3355        );
3356        // …and durable on disk (what a fresh CLI process reads).
3357        let disk: serde_json::Value = serde_json::from_slice(
3358            &std::fs::read(mem_dir.join(".memstead").join("config.json")).unwrap(),
3359        )
3360        .unwrap();
3361        assert_eq!(
3362            disk["syncState"]["engine/graph/graph#verified"],
3363            serde_json::json!("deadbeef")
3364        );
3365    }
3366
3367    // ---- D1: per-run adjudication cap -----------------------------------
3368
3369    /// D1 — the per-run cap queues the remainder. A rotation window covering
3370    /// only a subset of drift candidates adjudicates the in-window ones and
3371    /// QUEUES every out-of-window candidate as `queued-for-adjudication` (the
3372    /// tier-3 backlog). Uncapped (`window = None`) adjudicates every candidate.
3373    #[test]
3374    fn adjudication_cap_queues_the_remainder() {
3375        let k = key("h", "s");
3376        let mk = |art: &str| {
3377            let mut a = anchor(AnchorProvenanceClass::Anchored);
3378            a.artifact = art.to_string();
3379            a
3380        };
3381        let candidates = vec![
3382            (
3383                "engine--a".to_string(),
3384                mk("src/a.rs"),
3385                AnchorState::Drifted,
3386            ),
3387            (
3388                "engine--b".to_string(),
3389                mk("src/b.rs"),
3390                AnchorState::Drifted,
3391            ),
3392            (
3393                "engine--c".to_string(),
3394                mk("src/c.rs"),
3395                AnchorState::Drifted,
3396            ),
3397        ];
3398        // A cap-1 window selects only src/a.rs.
3399        let window: BTreeSet<String> = [candidate_key("engine--a", &mk("src/a.rs"))]
3400            .into_iter()
3401            .collect();
3402        let out = adjudicate_candidates(&k, "f", &candidates, Some(&window), "1");
3403        let drifted = out
3404            .iter()
3405            .filter(|f| f.class == FindingClass::Drifted)
3406            .count();
3407        let queued = out
3408            .iter()
3409            .filter(|f| f.class == FindingClass::QueuedForAdjudication)
3410            .count();
3411        assert_eq!(drifted, 1, "only the in-window candidate is adjudicated");
3412        assert_eq!(queued, 2, "the remainder is queued as the tier-3 backlog");
3413        // A queued remainder finding carries the queued detail, not a drift claim.
3414        assert!(
3415            out.iter()
3416                .any(|f| f.class == FindingClass::QueuedForAdjudication
3417                    && f.detail.contains("cap reached")),
3418            "capped remainder states it was deferred by the cap"
3419        );
3420
3421        // Uncapped: every candidate adjudicated, none queued.
3422        let uncapped = adjudicate_candidates(&k, "f", &candidates, None, "1");
3423        assert_eq!(
3424            uncapped
3425                .iter()
3426                .filter(|f| f.class == FindingClass::Drifted)
3427                .count(),
3428            3,
3429            "uncapped adjudicates every candidate"
3430        );
3431        assert_eq!(
3432            uncapped
3433                .iter()
3434                .filter(|f| f.class == FindingClass::QueuedForAdjudication)
3435                .count(),
3436            0
3437        );
3438    }
3439
3440    // ---- D3: full_resync scheduling + non-enumerable refusal ------------
3441
3442    /// D3 — `schedule_full_resync`: disabled at cadence 0; not-due off-cadence
3443    /// (with a countdown); due on-cadence for an enumerable facet (walked, no
3444    /// refusal).
3445    #[test]
3446    fn full_resync_schedule_disabled_notdue_due() {
3447        let codebase = FacetEnumerability {
3448            facet: "src".to_string(),
3449            medium_type: "codebase".to_string(),
3450            enumerable: true,
3451        };
3452        assert_eq!(
3453            schedule_full_resync(0, 5, std::slice::from_ref(&codebase)),
3454            FullResyncDecision::Disabled
3455        );
3456        match schedule_full_resync(3, 2, std::slice::from_ref(&codebase)) {
3457            FullResyncDecision::NotDue { runs_until_due, .. } => assert_eq!(runs_until_due, 1),
3458            other => panic!("expected NotDue, got {other:?}"),
3459        }
3460        match schedule_full_resync(3, 3, std::slice::from_ref(&codebase)) {
3461            FullResyncDecision::Due {
3462                walked_facets,
3463                refused,
3464                ..
3465            } => {
3466                assert_eq!(walked_facets, vec!["src".to_string()]);
3467                assert!(refused.is_empty(), "enumerable facet is not refused");
3468            }
3469            other => panic!("expected Due, got {other:?}"),
3470        }
3471    }
3472
3473    /// D3 REFUSAL — a scheduled full walk over a NON-enumerable medium refuses
3474    /// with a typed signal: it never claims coverage and is never a silent skip.
3475    #[test]
3476    fn full_resync_refuses_non_enumerable_medium() {
3477        let web = FacetEnumerability {
3478            facet: "manual".to_string(),
3479            medium_type: "web".to_string(),
3480            enumerable: false,
3481        };
3482        let d = schedule_full_resync(1, 1, &[web]);
3483        assert!(
3484            d.is_full_walk(),
3485            "a due sweep is a full walk even when refused"
3486        );
3487        match d {
3488            FullResyncDecision::Due {
3489                walked_facets,
3490                refused,
3491                ..
3492            } => {
3493                assert!(walked_facets.is_empty(), "nothing enumerable to walk");
3494                assert_eq!(refused.len(), 1, "the non-enumerable facet is refused");
3495                assert_eq!(refused[0].facet, "manual");
3496                assert_eq!(refused[0].medium_type, "web");
3497                assert!(
3498                    refused[0].reason.contains("non-enumerable"),
3499                    "the refusal is typed and states why"
3500                );
3501            }
3502            other => panic!("expected Due with a refusal, got {other:?}"),
3503        }
3504    }
3505
3506    /// D3 — a scheduled full walk fires the WHOLE-source enumeration this run:
3507    /// with `full_resync_every = 1` (due every run) and a sample `batch_size` of
3508    /// 1, all three uncovered source files are flagged, not just one — the full
3509    /// walk overrides the bounded rotating sample for an enumerable medium.
3510    #[test]
3511    fn full_resync_full_walk_covers_whole_source() {
3512        let tmp = tempfile::tempdir().unwrap();
3513        let root = tmp.path();
3514        let mem_dir = root.join("mem");
3515        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
3516        std::fs::write(
3517            mem_dir.join(".memstead").join("config.json"),
3518            r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
3519        )
3520        .unwrap();
3521        std::fs::create_dir_all(root.join(".memstead")).unwrap();
3522        std::fs::write(
3523            root.join(".memstead").join("workspace.toml"),
3524            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
3525        )
3526        .unwrap();
3527        let mount = Mount {
3528            mem: "engine".to_string(),
3529            schema: Some("default@1.0.0".parse().unwrap()),
3530            storage: MountStorage::Folder {
3531                path: mem_dir.clone(),
3532            },
3533            capability: MountCapability::Write,
3534            lifecycle: MountLifecycle::Eager,
3535            cross_linkable: false,
3536            migration_target: None,
3537        };
3538        crate::FileWorkspaceStore::new()
3539            .save_state(
3540                root,
3541                &Workspace {
3542                    mounts: vec![mount],
3543                    settings: WorkspaceSettings::default(),
3544                },
3545            )
3546            .unwrap();
3547        let out = std::process::Command::new("git")
3548            .args(["init", "-q"])
3549            .current_dir(root)
3550            .output()
3551            .unwrap();
3552        assert!(out.status.success());
3553        std::fs::create_dir_all(root.join("src")).unwrap();
3554        for f in ["a.rs", "b.rs", "c.rs"] {
3555            std::fs::write(root.join("src").join(f), "fn x() {}\n").unwrap();
3556        }
3557
3558        write_binding(
3559            root,
3560            "engine",
3561            "graph",
3562            &Binding {
3563                version: BINDING_VERSION,
3564                intent: None,
3565                sources: vec![crate::pipeline::Source {
3566                    name: "graph".to_string(),
3567                    medium_type: MediumType::Codebase,
3568                    pointer: String::new(),
3569                    change_detection: Some("git".to_string()),
3570                    scope: vec![PatternEntry {
3571                        path: "src/**/*.rs".to_string(),
3572                        mode: PatternMode::Allow,
3573                    }],
3574                    engagement: None,
3575                    preparation: None,
3576                }],
3577                reference_mems: Vec::new(),
3578                destination_mem: "engine".to_string(),
3579                deny_paths: Vec::new(),
3580                coverage_semantics: None,
3581                rules: None,
3582                prune: None,
3583                operations: Operations {
3584                    build: Some(BuildOperation {
3585                        mode: BuildMode::Discovery,
3586                        trigger: IngestTrigger::Loop,
3587                        batch_size: 20,
3588                        post_actions: None,
3589                    }),
3590                    sync: None,
3591                    verify: Some(VerifyOperation {
3592                        trigger: IngestTrigger::Manual,
3593                        batch_size: 1, // a tiny rotating sample …
3594                        adjudication_cap: DEFAULT_ADJUDICATION_CAP,
3595                        full_resync_every: 1, // … but a full walk fires EVERY run
3596                    }),
3597                },
3598            },
3599        )
3600        .unwrap();
3601
3602        let engine = Engine::from_workspace_root(root).unwrap();
3603        let configs = load_pipeline_configs(root).unwrap();
3604        let binding = &configs.bindings[0].config;
3605        let resolved = resolve_binding_run("engine/graph", binding).unwrap();
3606
3607        let outcome = verify_binding(&engine, root, binding, &resolved).unwrap();
3608        // The full walk is due on run 1 and covers the enumerable facet.
3609        match &outcome.full_resync {
3610            FullResyncDecision::Due {
3611                walked_facets,
3612                refused,
3613                run_count,
3614                ..
3615            } => {
3616                assert_eq!(*run_count, 1);
3617                assert_eq!(walked_facets, &vec!["graph".to_string()]);
3618                assert!(refused.is_empty());
3619            }
3620            other => panic!("expected a due full walk, got {other:?}"),
3621        }
3622        // All three uncovered files flagged despite the batch_size-1 sample.
3623        let store = read_findings_store(root, "engine", "graph")
3624            .unwrap()
3625            .unwrap();
3626        let uncovered = store
3627            .current(&outcome.key)
3628            .iter()
3629            .filter(|f| f.class == FindingClass::Uncovered)
3630            .count();
3631        assert_eq!(
3632            uncovered, 3,
3633            "the scheduled full walk covers the whole source, not a batch of one"
3634        );
3635    }
3636
3637    /// A SCHEDULED full walk consults partiality the way `--full` does: a facet
3638    /// whose enumeration is known-incomplete (here: a scope pattern still in
3639    /// the retired workspace-relative dialect) is demoted into the typed
3640    /// refusal list instead of being walked and announced as full. Without the
3641    /// demotion one report carries both "full-enumeration walk fired" and
3642    /// "`S(D)` is partial, no percentage".
3643    #[test]
3644    fn scheduled_full_walk_demotes_partial_facet_to_refusal() {
3645        let tmp = tempfile::tempdir().unwrap();
3646        let root = tmp.path();
3647        let mem_dir = root.join("mem");
3648        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
3649        std::fs::write(
3650            mem_dir.join(".memstead").join("config.json"),
3651            r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
3652        )
3653        .unwrap();
3654        std::fs::create_dir_all(root.join(".memstead")).unwrap();
3655        std::fs::write(
3656            root.join(".memstead").join("workspace.toml"),
3657            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
3658        )
3659        .unwrap();
3660        let mount = Mount {
3661            mem: "engine".to_string(),
3662            schema: Some("default@1.0.0".parse().unwrap()),
3663            storage: MountStorage::Folder {
3664                path: mem_dir.clone(),
3665            },
3666            capability: MountCapability::Write,
3667            lifecycle: MountLifecycle::Eager,
3668            cross_linkable: false,
3669            migration_target: None,
3670        };
3671        crate::FileWorkspaceStore::new()
3672            .save_state(
3673                root,
3674                &Workspace {
3675                    mounts: vec![mount],
3676                    settings: WorkspaceSettings::default(),
3677                },
3678            )
3679            .unwrap();
3680        let out = std::process::Command::new("git")
3681            .args(["init", "-q"])
3682            .current_dir(root)
3683            .output()
3684            .unwrap();
3685        assert!(out.status.success());
3686        std::fs::create_dir_all(root.join("src")).unwrap();
3687        std::fs::write(root.join("src").join("a.rs"), "fn x() {}\n").unwrap();
3688
3689        write_binding(
3690            root,
3691            "engine",
3692            "graph",
3693            &Binding {
3694                version: BINDING_VERSION,
3695                intent: None,
3696                sources: vec![crate::pipeline::Source {
3697                    name: "graph".to_string(),
3698                    medium_type: MediumType::Codebase,
3699                    pointer: "src".to_string(),
3700                    change_detection: Some("git".to_string()),
3701                    // A MIXED scope: the prefix-free pattern still enumerates,
3702                    // so the facet is non-empty and looks like a population —
3703                    // while the retired-dialect pattern's share is absent.
3704                    scope: vec![
3705                        PatternEntry {
3706                            path: "**/*.rs".to_string(),
3707                            mode: PatternMode::Allow,
3708                        },
3709                        PatternEntry {
3710                            path: "src/nested.rs".to_string(),
3711                            mode: PatternMode::Allow,
3712                        },
3713                    ],
3714                    engagement: None,
3715                    preparation: None,
3716                }],
3717                reference_mems: Vec::new(),
3718                destination_mem: "engine".to_string(),
3719                deny_paths: Vec::new(),
3720                coverage_semantics: None,
3721                rules: None,
3722                prune: None,
3723                operations: Operations {
3724                    build: Some(BuildOperation {
3725                        mode: BuildMode::Discovery,
3726                        trigger: IngestTrigger::Loop,
3727                        batch_size: 20,
3728                        post_actions: None,
3729                    }),
3730                    sync: None,
3731                    verify: Some(VerifyOperation {
3732                        trigger: IngestTrigger::Manual,
3733                        batch_size: 1,
3734                        adjudication_cap: DEFAULT_ADJUDICATION_CAP,
3735                        full_resync_every: 1, // a full walk fires EVERY run …
3736                    }),
3737                },
3738            },
3739        )
3740        .unwrap();
3741
3742        let engine = Engine::from_workspace_root(root).unwrap();
3743        let configs = load_pipeline_configs(root).unwrap();
3744        let binding = &configs.bindings[0].config;
3745        let resolved = resolve_binding_run("engine/graph", binding).unwrap();
3746
3747        let outcome = verify_binding(&engine, root, binding, &resolved).unwrap();
3748        match &outcome.full_resync {
3749            FullResyncDecision::Due {
3750                walked_facets,
3751                refused,
3752                ..
3753            } => {
3754                assert!(
3755                    walked_facets.is_empty(),
3756                    "a partial facet must not be announced as walked-in-full: {walked_facets:?}"
3757                );
3758                assert_eq!(refused.len(), 1, "the partial facet is refused, typed");
3759                assert_eq!(refused[0].facet, "graph");
3760                assert!(
3761                    refused[0].reason.contains("incomplete"),
3762                    "the refusal names the partiality: {}",
3763                    refused[0].reason
3764                );
3765            }
3766            other => panic!("expected a due full walk decision, got {other:?}"),
3767        }
3768    }
3769
3770    // ---- explicit full measurement (`verify_binding_full`) ----------------
3771
3772    /// An explicit full measurement walks the whole `S(D)` and treats the
3773    /// adjudication cap as unlimited — every drift candidate adjudicates and
3774    /// every uncovered artifact is flagged in ONE run, with nothing deferred
3775    /// to a cap or a rotating sample, and the decision reports `Forced`.
3776    /// REFUSAL half (byte-compat): a no-flag run over the same binding keeps
3777    /// today's capped/sampled behavior exactly — cap-1 adjudicates one
3778    /// candidate and queues the remainder with the cap-reached detail, and
3779    /// the batch-1 sample flags at most one uncovered file.
3780    #[test]
3781    fn full_verify_uncaps_adjudication_and_walks_whole_source() {
3782        let tmp = tempfile::tempdir().unwrap();
3783        let root = tmp.path();
3784        let mem_dir = root.join("mem");
3785        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
3786        std::fs::write(
3787            mem_dir.join(".memstead").join("config.json"),
3788            r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
3789        )
3790        .unwrap();
3791        std::fs::create_dir_all(root.join(".memstead")).unwrap();
3792        std::fs::write(
3793            root.join(".memstead").join("workspace.toml"),
3794            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
3795        )
3796        .unwrap();
3797        crate::FileWorkspaceStore::new()
3798            .save_state(
3799                root,
3800                &Workspace {
3801                    mounts: vec![Mount {
3802                        mem: "engine".to_string(),
3803                        schema: Some("default@1.0.0".parse().unwrap()),
3804                        storage: MountStorage::Folder {
3805                            path: mem_dir.clone(),
3806                        },
3807                        capability: MountCapability::Write,
3808                        lifecycle: MountLifecycle::Eager,
3809                        cross_linkable: false,
3810                        migration_target: None,
3811                    }],
3812                    settings: WorkspaceSettings::default(),
3813                },
3814            )
3815            .unwrap();
3816        let out = std::process::Command::new("git")
3817            .args(["init", "-q"])
3818            .current_dir(root)
3819            .output()
3820            .unwrap();
3821        assert!(out.status.success());
3822        std::fs::create_dir_all(root.join("src")).unwrap();
3823        // Three anchored (drift-candidate) files + three uncovered files.
3824        for f in ["a.rs", "b.rs", "c.rs", "d.rs", "e.rs", "f.rs"] {
3825            std::fs::write(root.join("src").join(f), "fn x() {}\n").unwrap();
3826        }
3827        let mk = |art: &str| Anchor {
3828            artifact: art.to_string(),
3829            grain: AnchorGrain::File,
3830            class: AnchorProvenanceClass::Anchored,
3831            at_version: None,
3832            hash: Some("stale-recorded-hash".to_string()), // mismatches → drift candidate
3833            hash_stability: AnchorHashStability::Stable,
3834            derived_from: Vec::new(),
3835            binding: None,
3836            source: None,
3837            span_unvalidated: false,
3838            hash_source: None,
3839        };
3840        // The entity the sidecar is keyed to. Written, because it exists:
3841        // a row whose entity does not is DANGLING (consistency-sweep 03/02)
3842        // and leaves the population before any figure counts it.
3843        std::fs::write(
3844            mem_dir.join("e.md"),
3845            "---\ntype: decision\n---\n\n# E\n\n## Decision\n\nBody.\n",
3846        )
3847        .unwrap();
3848        let mut sidecar = AnchorSidecar::default();
3849        sidecar.set(
3850            "engine--e",
3851            vec![mk("src/a.rs"), mk("src/b.rs"), mk("src/c.rs")],
3852        );
3853        std::fs::write(
3854            mem_dir.join(crate::anchor::ANCHOR_SIDECAR_PATH),
3855            sidecar.to_bytes(),
3856        )
3857        .unwrap();
3858
3859        write_binding(
3860            root,
3861            "engine",
3862            "graph",
3863            &Binding {
3864                version: BINDING_VERSION,
3865                intent: None,
3866                sources: vec![crate::pipeline::Source {
3867                    name: "graph".to_string(),
3868                    medium_type: MediumType::Codebase,
3869                    pointer: String::new(),
3870                    change_detection: Some("git".to_string()),
3871                    scope: vec![PatternEntry {
3872                        path: "src/**/*.rs".to_string(),
3873                        mode: PatternMode::Allow,
3874                    }],
3875                    engagement: None,
3876                    preparation: None,
3877                }],
3878                reference_mems: Vec::new(),
3879                destination_mem: "engine".to_string(),
3880                deny_paths: Vec::new(),
3881                coverage_semantics: None,
3882                rules: None,
3883                prune: None,
3884                operations: Operations {
3885                    build: None,
3886                    sync: None,
3887                    verify: Some(VerifyOperation {
3888                        trigger: IngestTrigger::Manual,
3889                        batch_size: 1,        // tiny rotating sample …
3890                        adjudication_cap: 1,  // … and a tiny cap
3891                        full_resync_every: 0, // scheduled walks disabled
3892                    }),
3893                },
3894            },
3895        )
3896        .unwrap();
3897
3898        let engine = Engine::from_workspace_root(root).unwrap();
3899        let configs = load_pipeline_configs(root).unwrap();
3900        let binding = &configs.bindings[0].config;
3901        let resolved = resolve_binding_run("engine/graph", binding).unwrap();
3902
3903        // Byte-compat leg — the no-flag run keeps today's capped/sampled
3904        // economics: one candidate adjudicated, two queued by the cap, at
3905        // most one uncovered file from the batch-1 sample, no full walk.
3906        let sampled = verify_binding(&engine, root, binding, &resolved).unwrap();
3907        assert_eq!(sampled.full_resync, FullResyncDecision::Disabled);
3908        let store = read_findings_store(root, "engine", "graph")
3909            .unwrap()
3910            .unwrap();
3911        let current = store.current(&sampled.key);
3912        let count = |c: FindingClass| current.iter().filter(|f| f.class == c).count();
3913        assert_eq!(count(FindingClass::Drifted), 1, "cap-1 adjudicates one");
3914        assert_eq!(
3915            count(FindingClass::QueuedForAdjudication),
3916            2,
3917            "the remainder queues"
3918        );
3919        assert!(
3920            current
3921                .iter()
3922                .any(|f| f.class == FindingClass::QueuedForAdjudication
3923                    && f.detail.contains("cap reached")),
3924            "the sampled deferral states the cap"
3925        );
3926        assert!(
3927            count(FindingClass::Uncovered) <= 1,
3928            "batch-1 sample looks at one artifact"
3929        );
3930
3931        // Full measurement: everything adjudicates, everything is walked,
3932        // nothing deferred — no sampling/truncation residue anywhere.
3933        let full = verify_binding_full(&engine, root, binding, &resolved).unwrap();
3934        assert_eq!(
3935            full.full_resync,
3936            FullResyncDecision::Forced {
3937                walked_facets: vec!["graph".to_string()]
3938            }
3939        );
3940        assert_eq!(full.backlog, 0, "cap treated as unlimited — no backlog");
3941        let store = read_findings_store(root, "engine", "graph")
3942            .unwrap()
3943            .unwrap();
3944        let current = store.current(&full.key);
3945        let count = |c: FindingClass| current.iter().filter(|f| f.class == c).count();
3946        assert_eq!(
3947            count(FindingClass::Drifted),
3948            3,
3949            "every candidate adjudicated"
3950        );
3951        assert_eq!(count(FindingClass::QueuedForAdjudication), 0);
3952        assert_eq!(
3953            count(FindingClass::Uncovered),
3954            3,
3955            "the whole S(D) walked — every uncovered file flagged"
3956        );
3957        assert!(
3958            current.iter().all(|f| !f.detail.contains("cap reached")),
3959            "a full run's findings carry no cap-deferral caveat"
3960        );
3961    }
3962
3963    /// REFUSAL — an explicit full measurement over a non-enumerable medium
3964    /// refuses the whole run with the typed capability error (nothing
3965    /// observed, nothing recorded — never a fabricated-complete report),
3966    /// while the no-flag sampled verify over the same binding still runs.
3967    #[test]
3968    fn full_verify_refuses_non_enumerable_medium_typed() {
3969        let tmp = tempfile::tempdir().unwrap();
3970        let root = tmp.path();
3971        let mem_dir = root.join("mem");
3972        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
3973        std::fs::write(
3974            mem_dir.join(".memstead").join("config.json"),
3975            r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
3976        )
3977        .unwrap();
3978        std::fs::create_dir_all(root.join(".memstead")).unwrap();
3979        std::fs::write(
3980            root.join(".memstead").join("workspace.toml"),
3981            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
3982        )
3983        .unwrap();
3984        crate::FileWorkspaceStore::new()
3985            .save_state(
3986                root,
3987                &Workspace {
3988                    mounts: vec![Mount {
3989                        mem: "engine".to_string(),
3990                        schema: Some("default@1.0.0".parse().unwrap()),
3991                        storage: MountStorage::Folder {
3992                            path: mem_dir.clone(),
3993                        },
3994                        capability: MountCapability::Write,
3995                        lifecycle: MountLifecycle::Eager,
3996                        cross_linkable: false,
3997                        migration_target: None,
3998                    }],
3999                    settings: WorkspaceSettings::default(),
4000                },
4001            )
4002            .unwrap();
4003
4004        // A web medium — the capability matrix marks it non-enumerable.
4005        write_binding(
4006            root,
4007            "engine",
4008            "manual",
4009            &Binding {
4010                version: BINDING_VERSION,
4011                intent: None,
4012                sources: vec![crate::pipeline::Source {
4013                    name: "manual".to_string(),
4014                    medium_type: MediumType::Web,
4015                    pointer: "https://example.com/docs".to_string(),
4016                    change_detection: None,
4017                    scope: Vec::new(),
4018                    engagement: None,
4019                    preparation: None,
4020                }],
4021                reference_mems: Vec::new(),
4022                destination_mem: "engine".to_string(),
4023                deny_paths: Vec::new(),
4024                coverage_semantics: Some(CoverageSemantics::Curated),
4025                rules: None,
4026                prune: None,
4027                operations: Operations {
4028                    build: None,
4029                    sync: None,
4030                    verify: Some(VerifyOperation {
4031                        trigger: IngestTrigger::Manual,
4032                        batch_size: 20,
4033                        adjudication_cap: DEFAULT_ADJUDICATION_CAP,
4034                        full_resync_every: DEFAULT_FULL_RESYNC_EVERY,
4035                    }),
4036                },
4037            },
4038        )
4039        .unwrap();
4040
4041        let engine = Engine::from_workspace_root(root).unwrap();
4042        let configs = load_pipeline_configs(root).unwrap();
4043        let binding = &configs.bindings[0].config;
4044        let resolved = resolve_binding_run("engine/manual", binding).unwrap();
4045
4046        // Full: typed refusal naming the facet and medium type; nothing recorded.
4047        let err = verify_binding_full(&engine, root, binding, &resolved).unwrap_err();
4048        match &err {
4049            FindingsError::FullWalkNonEnumerable(refusal) => {
4050                assert_eq!(refusal.facet, "manual");
4051                assert_eq!(refusal.medium_type, "web");
4052                assert!(refusal.reason.contains("non-enumerable"));
4053            }
4054            other => panic!("expected FullWalkNonEnumerable, got {other:?}"),
4055        }
4056        assert!(
4057            read_findings_store(root, "engine", "manual")
4058                .unwrap()
4059                .is_none(),
4060            "a refused full run records nothing"
4061        );
4062
4063        // No-flag: the sampled verify over the same binding still runs.
4064        let sampled = verify_binding(&engine, root, binding, &resolved).unwrap();
4065        assert_eq!(sampled.binding, "engine/manual");
4066    }
4067
4068    fn sourceless_binding() -> crate::binding::Binding {
4069        crate::binding::Binding {
4070            version: crate::binding::BINDING_VERSION,
4071            intent: None,
4072            sources: Vec::new(),
4073            reference_mems: Vec::new(),
4074            destination_mem: "m".to_string(),
4075            deny_paths: Vec::new(),
4076            coverage_semantics: None,
4077            rules: None,
4078            prune: None,
4079            operations: crate::binding::Operations {
4080                build: None,
4081                sync: None,
4082                verify: None,
4083            },
4084        }
4085    }
4086
4087    fn uncovered(key: &FindingKey, artifact: &str) -> Finding {
4088        Finding {
4089            key: key.clone(),
4090            facet: "src".to_string(),
4091            target: FindingTarget::Artifact {
4092                artifact: artifact.to_string(),
4093            },
4094            class: FindingClass::Uncovered,
4095            detail: "source artifact in scope has no anchor in the destination mem".to_string(),
4096            created_at: "1".to_string(),
4097        }
4098    }
4099
4100    /// An exclusion `projection exclude` just accepted takes effect on the
4101    /// VERY NEXT brief read, with no verify pass in between: the stored batch
4102    /// still carries the uncovered finding, and `current_findings` drops it
4103    /// against the durable exclusion ledger. Non-uncovered findings and
4104    /// uncovered artifacts the ledger does not name are untouched.
4105    #[test]
4106    fn current_findings_drops_ledger_excluded_uncovered_without_a_verify() {
4107        let ws = tempfile::tempdir().unwrap();
4108        let root = ws.path();
4109        let engine = crate::engine::Engine::from_mounts(Vec::new()).unwrap();
4110        let binding = sourceless_binding();
4111        let resolved = resolve_binding_run("m/s", &binding).unwrap();
4112
4113        let key = FindingKey {
4114            binding_hash: crate::binding::hash_binding(&binding),
4115            source_head: String::new(),
4116        };
4117        let mut store = FindingsStore {
4118            binding: "m/s".to_string(),
4119            ..Default::default()
4120        };
4121        store.record(
4122            key.clone(),
4123            "1".to_string(),
4124            vec![uncovered(&key, "docs/a.md"), uncovered(&key, "docs/b.md")],
4125        );
4126        write_findings_store(root, "m", "s", &store).unwrap();
4127
4128        // Before the exclusion: both present.
4129        let (_, before) = current_findings(&engine, root, &binding, &resolved).unwrap();
4130        assert_eq!(before.len(), 2);
4131
4132        // The exclusion lands in the durable ledger (as `projection exclude`
4133        // records it) — no verify rewrites the batch.
4134        let state = crate::ingest::advance::AdvanceState {
4135            binding: "m/s".to_string(),
4136            exclusions: [("docs/a.md".to_string(), "generated; no entity".to_string())]
4137                .into_iter()
4138                .collect(),
4139            ..Default::default()
4140        };
4141        crate::ingest::advance::write_advance_store(root, "m", "s", &state).unwrap();
4142
4143        let (_, after) = current_findings(&engine, root, &binding, &resolved).unwrap();
4144        assert_eq!(after.len(), 1);
4145        assert!(matches!(
4146            &after[0].target,
4147            FindingTarget::Artifact { artifact } if artifact == "docs/b.md"
4148        ));
4149    }
4150
4151    /// Findings recorded under a prior `hash(D)` are superseded and never
4152    /// surface through `current_findings` — the brief renders the current
4153    /// batch alone.
4154    #[test]
4155    fn current_findings_never_serves_superseded_batches() {
4156        let ws = tempfile::tempdir().unwrap();
4157        let root = ws.path();
4158        let engine = crate::engine::Engine::from_mounts(Vec::new()).unwrap();
4159        let binding = sourceless_binding();
4160        let resolved = resolve_binding_run("m/s", &binding).unwrap();
4161
4162        let old_key = key("a-prior-binding-hash", "head0");
4163        let cur_key = FindingKey {
4164            binding_hash: crate::binding::hash_binding(&binding),
4165            source_head: String::new(),
4166        };
4167        let mut store = FindingsStore {
4168            binding: "m/s".to_string(),
4169            ..Default::default()
4170        };
4171        store.record(
4172            old_key.clone(),
4173            "1".to_string(),
4174            vec![uncovered(&old_key, "docs/stale.md")],
4175        );
4176        store.record(
4177            cur_key.clone(),
4178            "2".to_string(),
4179            vec![uncovered(&cur_key, "docs/live.md")],
4180        );
4181        write_findings_store(root, "m", "s", &store).unwrap();
4182
4183        let (_, current) = current_findings(&engine, root, &binding, &resolved).unwrap();
4184        assert_eq!(current.len(), 1);
4185        assert!(matches!(
4186            &current[0].target,
4187            FindingTarget::Artifact { artifact } if artifact == "docs/live.md"
4188        ));
4189    }
4190}