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 is still unaccounted (`accounted_now`: no covering anchor
1111///   and no ledger exclusion); departure from `S(D)`, gained coverage, or a
1112///   recorded exclusion closes it — so an exclusion supersedes a standing
1113///   `uncovered` finding in the store itself, not only in the presentation
1114///   filter, and the verdict count cannot contradict the coverage section.
1115///
1116/// Carried findings keep their original [`Finding::key`] (the head they were
1117/// observed at). The carry rules are the growth bound: nothing is carried
1118/// whose subject left the source or re-adjudicated clean, so the open set
1119/// cannot grow without bound — and a closed/superseded finding is never
1120/// resurrected (it is simply absent from the recorded batch).
1121fn merge_with_prior(
1122    mut fresh: Vec<Finding>,
1123    prior: &[Finding],
1124    obs: &PassObservation,
1125    accounted_now: impl Fn(&str) -> bool,
1126) -> Vec<Finding> {
1127    let fresh_idx: BTreeMap<String, usize> = fresh
1128        .iter()
1129        .enumerate()
1130        .map(|(i, f)| (target_key(&f.target), i))
1131        .collect();
1132    let mut carried: Vec<Finding> = Vec::new();
1133    for f in prior {
1134        let tkey = target_key(&f.target);
1135        let observed = match &f.target {
1136            FindingTarget::Anchor { .. } => obs.anchors_observed.contains(&tkey),
1137            FindingTarget::Artifact { artifact } => obs.files_observed.contains(artifact),
1138        };
1139        if observed {
1140            // Deferral must not supersede a substantive prior verdict.
1141            if matches!(f.class, FindingClass::Drifted | FindingClass::Wrong)
1142                && let Some(&i) = fresh_idx.get(&tkey)
1143                && fresh[i].class == FindingClass::QueuedForAdjudication
1144            {
1145                fresh[i] = f.clone();
1146            }
1147            continue;
1148        }
1149        if fresh_idx.contains_key(&tkey) {
1150            continue; // a fresh outcome exists for this target anyway
1151        }
1152        let still_open = match &f.target {
1153            FindingTarget::Anchor { .. } => obs.anchors_existing.contains(&tkey),
1154            FindingTarget::Artifact { artifact } => {
1155                obs.s_d.contains(artifact) && !accounted_now(artifact)
1156            }
1157        };
1158        if still_open {
1159            carried.push(f.clone());
1160        }
1161    }
1162    fresh.extend(carried);
1163    fresh
1164}
1165
1166/// The thin `projection verify` write path (group A). Measures a binding's
1167/// fidelity and records durable findings under the current `(hash(D),
1168/// source_head)` key; **read-only on the destination mem** — the `&Engine`
1169/// (shared, not `&mut`) makes a mem mutation structurally impossible (A5).
1170///
1171/// It does two things a real verify does, enough to populate and exercise the
1172/// store (A1/A2): it adjudicates the destination mem's anchors against their
1173/// live source observation (via [`adjudicate_anchor`], honouring the A2
1174/// hash-drift exclusion), and it samples in-scope source artifacts through the
1175/// retained [`next_batch`] rotation (A4 — the rotation's sole surviving
1176/// consumer, used only to schedule which artifacts a pass looks at) to surface
1177/// uncovered ones. The full tier-1 fidelity report and the sync brief are
1178/// group B/C — this path deliberately renders neither.
1179pub fn verify_binding(
1180    engine: &Engine,
1181    workspace_root: &Path,
1182    binding: &Binding,
1183    resolved: &ResolvedIngest,
1184) -> Result<VerifyOutcome, FindingsError> {
1185    run_verify(engine, workspace_root, binding, resolved, false)
1186}
1187
1188/// [`verify_binding`]'s **full-measurement** mode (the CLI's `--full`):
1189/// enumerate the whole `S(D)` (the sampling scheduler is bypassed — the
1190/// rotation state is neither consulted nor advanced), treat the per-run
1191/// adjudication cap as unlimited, and observe every anchor — so the recorded
1192/// findings, and the tier-1 report computed over them, carry no
1193/// sampling/truncation caveat: coverage and accuracy are computed, not
1194/// sampled. The prepared-hash backfill worklist rides the outcome exactly as
1195/// on a sampled pass.
1196///
1197/// REFUSAL: a facet whose medium the capability matrix marks non-enumerable
1198/// refuses the **whole** run with the typed
1199/// [`FindingsError::FullWalkNonEnumerable`] — an explicit full measurement
1200/// promises complete figures, so a partial walk is never silently substituted
1201/// and a fabricated-complete report is never rendered. The sampled path
1202/// ([`verify_binding`]) is untouched by this mode's existence.
1203pub fn verify_binding_full(
1204    engine: &Engine,
1205    workspace_root: &Path,
1206    binding: &Binding,
1207    resolved: &ResolvedIngest,
1208) -> Result<VerifyOutcome, FindingsError> {
1209    run_verify(engine, workspace_root, binding, resolved, true)
1210}
1211
1212/// The shared verify pass behind [`verify_binding`] (`full = false`, the
1213/// capped/sampled loop economics) and [`verify_binding_full`] (`full = true`,
1214/// the uncapped whole-`S(D)` measurement).
1215fn run_verify(
1216    engine: &Engine,
1217    workspace_root: &Path,
1218    binding: &Binding,
1219    resolved: &ResolvedIngest,
1220    full: bool,
1221) -> Result<VerifyOutcome, FindingsError> {
1222    let binding_id = resolved.name.clone();
1223    let (mem, name) = split_binding_id(&binding_id)?;
1224
1225    // Full measurement requires every primary facet to be enumerable — refuse
1226    // the whole run typed before observing anything (never a fake-complete
1227    // report over a partially-walkable source).
1228    if full {
1229        for source in &resolved.sources {
1230            if let ResolvedSource::Primary(p) = source {
1231                let medium_type = medium_type_wire(p.medium_type);
1232                if !medium_capabilities(p.medium_type).enumerable {
1233                    return Err(FindingsError::FullWalkNonEnumerable(FullResyncRefusal {
1234                        facet: p.name.clone(),
1235                        medium_type: medium_type.clone(),
1236                        reason: format!(
1237                            "medium type '{medium_type}' is non-enumerable — a full-enumeration \
1238                             walk cannot cover it; the full measurement refuses rather than \
1239                             render a report with fabricated completeness"
1240                        ),
1241                    }));
1242                }
1243            }
1244        }
1245
1246        // The matrix claiming enumerability is not evidence that a walk
1247        // happened. When a medium is declared enumerable but its walk yields
1248        // nothing, `--full` used to sail through the gate above and return
1249        // clean over a zero-artifact measurement — coverage 0/0, every anchor
1250        // unobserved, verdict green. That is the exact shape a full
1251        // measurement exists to make impossible, so refuse it.
1252        //
1253        // This guard survives the enumerator being fixed: it is the standing
1254        // check that a future medium cannot be added to the matrix as
1255        // enumerable without an enumeration arm and still report green.
1256        // Checked PER FACET. A binding-level union hides the mixed case: one
1257        // facet that walks makes the union non-empty, so `--full` returned
1258        // clean while a sibling enumerable facet was never walked at all —
1259        // complete coverage claimed over a scope nobody looked at. Each
1260        // enumerable facet must produce something of its own.
1261        for source in &resolved.sources {
1262            if let ResolvedSource::Primary(p) = source
1263                && medium_capabilities(p.medium_type).enumerable
1264            {
1265                let walked = super::cursor::enumerate_source_artifacts_reported(
1266                    engine,
1267                    p,
1268                    &resolved.deny_paths,
1269                    workspace_root,
1270                );
1271                let medium_type = medium_type_wire(p.medium_type);
1272                // A PARTIAL walk is the case the empty-check above cannot
1273                // see: some patterns resolved, so the facet is non-empty and
1274                // the gate waved it through, and `--full` then reported
1275                // complete coverage over a denominator missing whatever the
1276                // skipped patterns would have contributed. A full measurement
1277                // promises complete figures; a known-incomplete enumeration
1278                // cannot deliver one.
1279                if let Some(why) = walked.partiality_reason() {
1280                    return Err(FindingsError::FullWalkNonEnumerable(FullResyncRefusal {
1281                        facet: p.name.clone(),
1282                        medium_type: medium_type.clone(),
1283                        reason: format!(
1284                            "this facet's enumeration is incomplete — {why} — so a full \
1285                             measurement would claim complete coverage over a denominator \
1286                             that is not the population. Fix those patterns first"
1287                        ),
1288                    }));
1289                }
1290                if walked.files.is_empty() {
1291                    // The remedy text has to name the real cause. "Check that
1292                    // its scope patterns actually select something" is wrong
1293                    // advice when the patterns DO select artifacts and merely
1294                    // speak the retired workspace-relative dialect.
1295                    let remedy = if walked.legacy_dialect.is_empty() {
1296                        "Check that its scope patterns actually select something".to_string()
1297                    } else {
1298                        format!(
1299                            "its scope pattern(s) are still written against the workspace root \
1300                             rather than the source pointer ({}), so they select nothing under \
1301                             the pointer join — rewrite them relative to the pointer",
1302                            walked
1303                                .legacy_dialect
1304                                .iter()
1305                                .map(|n| n.pattern.as_str())
1306                                .collect::<Vec<_>>()
1307                                .join(", ")
1308                        )
1309                    };
1310                    return Err(FindingsError::FullWalkNonEnumerable(FullResyncRefusal {
1311                        facet: p.name.clone(),
1312                        medium_type: medium_type.clone(),
1313                        reason: format!(
1314                            "medium type '{medium_type}' claims to be enumerable, but this \
1315                             facet's enumeration yielded no artifacts — a full measurement over \
1316                             an empty walk would report complete coverage of nothing. {remedy}"
1317                        ),
1318                    }));
1319                }
1320            }
1321        }
1322    }
1323
1324    // Refuse a vanished or unmounted path-based source before observing
1325    // anything: a missing tree would otherwise degrade to an empty
1326    // enumeration whose head token (the digest of nothing) masquerades as
1327    // a real observation — and the caller's completed-run baseline write
1328    // would clobber a genuine `#verified` token with it.
1329    for source in &resolved.sources {
1330        if let ResolvedSource::Primary(p) = source
1331            && matches!(
1332                p.medium_type,
1333                crate::pipeline::MediumType::Codebase
1334                    | crate::pipeline::MediumType::Filesystem
1335                    | crate::pipeline::MediumType::Git
1336            )
1337        {
1338            let base = super::resolve::source_base_path(p, workspace_root);
1339            // Unreachable is not only "absent". A directory that exists but
1340            // cannot be entered (permissions, a broken mount) enumerates
1341            // nothing, and the pass then reports every anchor unresolvable —
1342            // drift, in the verdict, blamed on a mem that did not move. The
1343            // read attempt is the test: existence alone let that through.
1344            // These mediums (codebase / filesystem / git) are all
1345            // directory-shaped — their scope globs enumerate under a tree —
1346            // so reachable means it IS a readable directory. A regular file
1347            // where the pointer promises a tree enumerates nothing and used
1348            // to slip through to be reported as drift, though the refusal
1349            // text already promised "present but not enumerable".
1350            let reachable = base.is_dir() && std::fs::read_dir(&base).is_ok();
1351            if !reachable {
1352                return Err(FindingsError::SourceUnreachable {
1353                    source_name: p.name.clone(),
1354                    path: base.display().to_string(),
1355                });
1356            }
1357        }
1358    }
1359
1360    // The same refusal for a graph source, which needs it just as badly and
1361    // for a worse reason. A graph source's "tree" is a mounted mem; if that
1362    // mem is absent from the workspace, every entity anchor into it misses
1363    // the store and observes as ABSENT — a definite `orphaned`, not an
1364    // honest "unobserved". The pass would then report drift, tell the reader
1365    // to repoint or unset anchors that are perfectly fine, and — because
1366    // `orphaned` is the one state that satisfies prune's all-orphaned gate —
1367    // let prune propose deleting the destination entities. An unmounted mem
1368    // must never be indistinguishable from a deleted one.
1369    for source in &resolved.sources {
1370        if let ResolvedSource::Primary(p) = source
1371            && p.medium_type == crate::pipeline::MediumType::Graph
1372            && !engine.mem_names().iter().any(|m| *m == p.pointer)
1373        {
1374            return Err(FindingsError::SourceUnreachable {
1375                source_name: p.name.clone(),
1376                path: format!("mem `{}` (not mounted in this workspace)", p.pointer),
1377            });
1378        }
1379    }
1380
1381    // The facet-head map is the key's per-facet decomposition: computed once,
1382    // joined into `key.source_head`, and returned on the outcome so a
1383    // completed run's baseline write records exactly what this run observed.
1384    let facet_heads = current_facet_heads(engine, workspace_root, resolved);
1385    let key = FindingKey {
1386        binding_hash: binding_hash_of(binding, resolved),
1387        source_head: join_facet_heads(&facet_heads),
1388    };
1389    let now = now_seconds();
1390    let facet = source_facet_label(resolved);
1391    let cache_root = workspace_root.join(".memstead.cache").join("ingest");
1392
1393    // Tier-3 operations knobs (group D): the per-run adjudication cap (D1), the
1394    // scheduled full-walk cadence (D3), and the sample window size. All come off
1395    // the `verify` block, defaulting to the dogfood-tuned engine defaults when it
1396    // is absent (verify has no mutating operation to gate — an absent block is
1397    // defaults, never a refusal).
1398    let verify_op = binding.operations.verify.as_ref();
1399    let cap = verify_op.map_or(DEFAULT_ADJUDICATION_CAP, |v| v.adjudication_cap);
1400    let full_resync_every = verify_op.map_or(DEFAULT_FULL_RESYNC_EVERY, |v| v.full_resync_every);
1401    let sample_batch = verify_op
1402        .map_or(resolved.batch_size, |v| v.batch_size)
1403        .max(1) as usize;
1404
1405    // Level-trigger clock + full-resync schedule (D3) — the counter ticks every
1406    // run (even a non-enumerable one) so the schedule can refuse on time. An
1407    // explicit full measurement ticks the same clock (it is a verify run) but
1408    // its walk decision is `Forced`, not schedule-derived: the every-facet-
1409    // enumerable gate above already held, so no per-facet refusal list exists.
1410    let run_count = bump_verify_runs(&cache_root, &binding_id);
1411    let facet_enum: Vec<FacetEnumerability> = resolved
1412        .sources
1413        .iter()
1414        .filter_map(|s| match s {
1415            ResolvedSource::Primary(p) => Some(FacetEnumerability {
1416                facet: p.name.clone(),
1417                medium_type: medium_type_wire(p.medium_type),
1418                enumerable: medium_capabilities(p.medium_type).enumerable,
1419            }),
1420            ResolvedSource::Reference { .. } => None,
1421        })
1422        .collect();
1423    let full_resync = if full {
1424        FullResyncDecision::Forced {
1425            walked_facets: facet_enum.iter().map(|f| f.facet.clone()).collect(),
1426        }
1427    } else {
1428        schedule_full_resync(full_resync_every, run_count, &facet_enum)
1429    };
1430    // A SCHEDULED due walk consults partiality the way `--full` does: the
1431    // scheduler branches on enumerability alone (it is pure and has no
1432    // filesystem), so a facet whose enumeration is known-incomplete — a
1433    // malformed or retired-dialect scope pattern — would be walked and
1434    // announced as full over a denominator that is not the population. Demote
1435    // such a facet into the typed refusal list instead, exactly where the
1436    // non-enumerable ones already land. The enumeration performed here is the
1437    // walk itself — its files feed the coverage pass below, so nothing is
1438    // enumerated twice. (`Forced` needs no demotion: the explicit-full gate
1439    // already refused the whole run on any partial facet.)
1440    let mut full_walk_files: Vec<String> = Vec::new();
1441    let full_resync = match full_resync {
1442        FullResyncDecision::Due {
1443            run_count,
1444            every,
1445            walked_facets,
1446            mut refused,
1447        } => {
1448            let mut kept: Vec<String> = Vec::new();
1449            for source in &resolved.sources {
1450                if let ResolvedSource::Primary(p) = source
1451                    && walked_facets.iter().any(|f| f == &p.name)
1452                {
1453                    let walked = super::cursor::enumerate_source_artifacts_reported(
1454                        engine,
1455                        p,
1456                        &resolved.deny_paths,
1457                        workspace_root,
1458                    );
1459                    if let Some(why) = walked.partiality_reason() {
1460                        refused.push(FullResyncRefusal {
1461                            facet: p.name.clone(),
1462                            medium_type: medium_type_wire(p.medium_type),
1463                            reason: format!(
1464                                "this facet's enumeration is incomplete — {why} — so the \
1465                                 scheduled full walk refuses it rather than announce complete \
1466                                 coverage over a denominator that is not the population"
1467                            ),
1468                        });
1469                    } else {
1470                        kept.push(p.name.clone());
1471                        full_walk_files.extend(walked.files);
1472                    }
1473                }
1474            }
1475            FullResyncDecision::Due {
1476                run_count,
1477                every,
1478                walked_facets: kept,
1479                refused,
1480            }
1481        }
1482        FullResyncDecision::Forced { walked_facets } => {
1483            for source in &resolved.sources {
1484                if let ResolvedSource::Primary(p) = source
1485                    && medium_capabilities(p.medium_type).enumerable
1486                {
1487                    full_walk_files.extend(enumerate_source_artifacts(
1488                        engine,
1489                        p,
1490                        &resolved.deny_paths,
1491                        workspace_root,
1492                    ));
1493                }
1494            }
1495            FullResyncDecision::Forced { walked_facets }
1496        }
1497        other => other,
1498    };
1499
1500    let mut findings: Vec<Finding> = Vec::new();
1501
1502    // 1. Adjudicate the destination mem's anchors against the live source, under
1503    //    the per-run cap (D1) with a rotating window (D2). Existence failures
1504    //    (orphaned) are cheap and always reported; hash-drift candidates are
1505    //    bounded — the cap-sized rotation window is adjudicated, the remainder
1506    //    queued, and successive runs rotate the window so the whole anchor set is
1507    //    covered over a full rotation.
1508    let mut existence: Vec<(String, Anchor, AnchorState)> = Vec::new();
1509    let mut candidates: Vec<(String, Anchor, AnchorState)> = Vec::new();
1510    // First-observation backfill worklist: a hash-less hash-bearing anchor
1511    // whose artifact resolved and yielded a prepared-content hash is not a
1512    // drift candidate (there is no recorded hash to compare — recorded ==
1513    // observed by construction once the backfill lands); it resolves clean
1514    // this pass and the observed hash rides the outcome for the caller's
1515    // [`record_anchor_hash_backfill`] write. From the next pass on the
1516    // anchor adjudicates deterministically — the recheck queue drains
1517    // instead of re-queueing forever.
1518    let mut hash_backfill: Vec<ObservedArtifactHash> = Vec::new();
1519    let mut backfill_seen: BTreeSet<(String, String)> = BTreeSet::new();
1520    // Observation bookkeeping for the head-durable merge: which anchor
1521    // targets exist, and which of them this pass actually resolved.
1522    let mut anchors_existing: BTreeSet<String> = BTreeSet::new();
1523    let mut anchors_observed: BTreeSet<String> = BTreeSet::new();
1524    // Scoped to this binding's population (consistency-sweep 03/01). An
1525    // excluded anchor must never raise a finding against a binding that did
1526    // not write it or has disclaimed the file; the report names the exclusions.
1527    let population = crate::ingest::anchor_population::population_for(
1528        engine,
1529        resolved,
1530        Some(binding_hash_of(binding, resolved).as_str()),
1531    );
1532    for (eid, resolved_anchor) in population.included {
1533        let tkey = target_key(&FindingTarget::Anchor {
1534            entity: eid.as_ref().to_string(),
1535            artifact: resolved_anchor.anchor.artifact.clone(),
1536        });
1537        anchors_existing.insert(tkey.clone());
1538        let Some(state) = resolved_anchor.state else {
1539            continue;
1540        };
1541        anchors_observed.insert(tkey);
1542        let observed_hash = resolved_anchor.observed_hash;
1543        let anchor = resolved_anchor.anchor;
1544        match state {
1545            AnchorState::Resolves => {}
1546            AnchorState::Orphaned => existence.push((eid.as_ref().to_string(), anchor, state)),
1547            AnchorState::Drifted | AnchorState::Recheck => {
1548                // Only hash-bearing anchors are hash-drift candidates (A2); a
1549                // non-hash-bearing class yields no adjudication.
1550                if !anchor.class.is_hash_bearing() {
1551                    continue;
1552                }
1553                if anchor.hash.is_none()
1554                    && let Some(hash) = observed_hash
1555                {
1556                    // First observation of a hash-less anchor on a resolvable
1557                    // artifact: backfill, not adjudication.
1558                    if backfill_seen.insert((eid.as_ref().to_string(), anchor.artifact.clone())) {
1559                        hash_backfill.push(ObservedArtifactHash {
1560                            entity: eid.as_ref().to_string(),
1561                            artifact: anchor.artifact.clone(),
1562                            hash,
1563                        });
1564                    }
1565                    continue;
1566                }
1567                candidates.push((eid.as_ref().to_string(), anchor, state));
1568            }
1569        }
1570    }
1571    for (entity, anchor, state) in &existence {
1572        if let Some(f) = adjudicate_anchor(&key, &facet, entity, anchor, *state, &now) {
1573            findings.push(f);
1574        }
1575    }
1576    // `cap == 0` disables the cap (adjudicate every candidate), and a full
1577    // measurement treats any configured cap as unlimited — its rotation state
1578    // is neither consulted nor advanced (the scheduler is bypassed, so the
1579    // sampled loop's window sequence is untouched by a full run). Otherwise a
1580    // cap-sized rotation window selects this run's adjudicated set (D1/D2).
1581    let window: Option<BTreeSet<String>> = if full || cap == 0 {
1582        None
1583    } else {
1584        let mut keys: Vec<String> = candidates
1585            .iter()
1586            .map(|(e, a, _)| candidate_key(e, a))
1587            .collect();
1588        keys.sort();
1589        keys.dedup();
1590        next_rotation_batch(
1591            &cache_root,
1592            &binding_id,
1593            ROTATION_ANCHOR_ADJUDICATION,
1594            keys,
1595            cap as usize,
1596        )
1597        .map(|b| b.files.into_iter().collect())
1598    };
1599    findings.extend(adjudicate_candidates(
1600        &key,
1601        &facet,
1602        &candidates,
1603        window.as_ref(),
1604        &now,
1605    ));
1606
1607    // 2. Sample in-scope source artifacts for coverage. When a full walk is due
1608    //    (D3) or explicitly requested (`Forced`), enumerate the WHOLE source of
1609    //    every enumerable facet — guaranteeing complete coverage this run;
1610    //    otherwise sample a bounded rotating window (D2). Non-enumerable facets
1611    //    are refused (scheduled: the typed refusal rides on `full_resync`;
1612    //    explicit: the whole run refused before observing), never silently
1613    //    claimed as covered.
1614    let sample_files: Vec<String> = if full_resync.is_full_walk() {
1615        // Collected above where the walk decision was settled — only facets
1616        // the decision actually announces as walked contribute.
1617        let mut all = full_walk_files;
1618        all.sort();
1619        all.dedup();
1620        all
1621    } else {
1622        next_batch(engine, resolved, workspace_root, &cache_root, sample_batch)
1623            .map(|b| b.files)
1624            .unwrap_or_default()
1625    };
1626    // Filtered by BINDING, not merely by mem (consistency-sweep 03/01,
1627    // criterion 7). The report's coverage lookup was scoped first and this one
1628    // was missed, which is the worse of the two: this decides whether an
1629    // `Uncovered` finding is RECORDED and whether a prior one stays open, so a
1630    // mem filter here let another binding's anchor mark a file covered in the
1631    // durable store. An anchor with no recorded binding still counts, by the
1632    // same pre-provenance fallback the population uses.
1633    let this_binding = binding_hash_of(binding, resolved);
1634    // An anchor whose ENTITY is gone covers nothing (03/02, criterion 5),
1635    // guarded on the reconciliation having been possible at all so an
1636    // unreconcilable mem keeps its coverage rather than reading as wholly
1637    // uncovered.
1638    let entity_end_reconciled = engine
1639        .entity_set_is_reconcilable(&resolved.destination_mem)
1640        .is_ok();
1641    let covered_now = |artifact: &str| {
1642        engine
1643            .anchors_referencing_artifact(artifact)
1644            .iter()
1645            .any(|(eid, a)| {
1646                eid.mem() == resolved.destination_mem.as_str()
1647                    && a.binding
1648                        .as_deref()
1649                        .map(|b| b == this_binding.as_str())
1650                        .unwrap_or(true)
1651                    && (!entity_end_reconciled || !engine.entity_is_absent(eid))
1652            })
1653    };
1654    // The durable authored-exclusion ledger (B4) gates the RECORDING, not
1655    // only the report's decoration: an artifact mined and deliberately
1656    // excluded with a rationale is not an uncovered finding. Until
1657    // 2026-08-28 only the report body consulted the ledger, so the verdict
1658    // line and the findings store kept counting exclusions as uncovered
1659    // (three of them on plugin/graph) while the rationales rendered right
1660    // beside the count.
1661    let excluded: BTreeSet<String> =
1662        crate::ingest::advance::read_advance_store(workspace_root, &mem, &name)
1663            .ok()
1664            .flatten()
1665            .map(|state| state.exclusions.keys().cloned().collect())
1666            .unwrap_or_default();
1667    for file in &sample_files {
1668        if !covered_now(file) && !excluded.contains(file) {
1669            findings.push(Finding {
1670                key: key.clone(),
1671                facet: facet.clone(),
1672                target: FindingTarget::Artifact {
1673                    artifact: file.clone(),
1674                },
1675                class: FindingClass::Uncovered,
1676                detail: "source artifact in scope has no anchor in the destination mem".to_string(),
1677                created_at: now.clone(),
1678            });
1679        }
1680    }
1681
1682    // 3. Head-durable merge (the store keys on hash(D) alone): fold the prior
1683    //    open batch into this pass's findings — re-observed targets take this
1684    //    pass's outcome; unobserved-but-still-open ones carry forward with
1685    //    their original observed head; departed/covered/vanished subjects
1686    //    close. Sync briefs thus keep presenting an open finding across
1687    //    source-head movement until a pass observes it clean.
1688    let mut store = read_findings_store(workspace_root, &mem, &name)
1689        .map_err(FindingsError::Store)?
1690        .unwrap_or_else(|| FindingsStore {
1691            binding: binding_id.clone(),
1692            ..Default::default()
1693        });
1694    let mut s_d: BTreeSet<String> = BTreeSet::new();
1695    for source in &resolved.sources {
1696        if let ResolvedSource::Primary(p) = source
1697            && medium_capabilities(p.medium_type).enumerable
1698        {
1699            s_d.extend(enumerate_source_artifacts(
1700                engine,
1701                p,
1702                &resolved.deny_paths,
1703                workspace_root,
1704            ));
1705        }
1706    }
1707    let obs = PassObservation {
1708        anchors_observed,
1709        anchors_existing,
1710        files_observed: sample_files.into_iter().collect(),
1711        s_d,
1712    };
1713    let prior = store.current(&key).to_vec();
1714    // The merge's accounting closure folds the exclusion ledger in: an
1715    // artifact that gained an authored exclusion since its `uncovered`
1716    // finding was recorded is accounted for, so the stale finding closes in
1717    // the store instead of being carried forward and merely hidden by the
1718    // presentation filter.
1719    let findings = merge_with_prior(findings, &prior, &obs, |artifact: &str| {
1720        covered_now(artifact) || excluded.contains(artifact)
1721    });
1722
1723    let backlog = findings
1724        .iter()
1725        .filter(|f| f.class == FindingClass::QueuedForAdjudication)
1726        .count();
1727
1728    // Record under the current key (prior-hash batches retained, segregated —
1729    // A3), persist to the durable state tier (A1).
1730    let recorded = findings.len();
1731    store.record(key.clone(), now, findings);
1732    let superseded = store.superseded(&key).len();
1733    write_findings_store(workspace_root, &mem, &name, &store).map_err(FindingsError::Store)?;
1734
1735    Ok(VerifyOutcome {
1736        binding: binding_id,
1737        key,
1738        recorded,
1739        superseded,
1740        backlog,
1741        full_resync,
1742        facet_heads,
1743        hash_backfill,
1744    })
1745}
1746
1747/// The medium type's wire string (`codebase` / `web` / …) — the serde form the
1748/// capability matrix and reports use.
1749fn medium_type_wire(t: crate::pipeline::MediumType) -> String {
1750    serde_json::to_value(t)
1751        .ok()
1752        .and_then(|v| v.as_str().map(str::to_string))
1753        .unwrap_or_default()
1754}
1755
1756#[cfg(test)]
1757mod tests {
1758    use super::*;
1759    use crate::anchor::{Anchor, AnchorGrain, AnchorHashStability, AnchorProvenanceClass};
1760
1761    fn key(hash: &str, head: &str) -> FindingKey {
1762        FindingKey {
1763            binding_hash: hash.to_string(),
1764            source_head: head.to_string(),
1765        }
1766    }
1767
1768    fn anchor(class: AnchorProvenanceClass) -> Anchor {
1769        Anchor {
1770            artifact: "src/lib.rs".to_string(),
1771            grain: AnchorGrain::File,
1772            class,
1773            at_version: None,
1774            hash: if class.is_hash_bearing() {
1775                Some("h1".to_string())
1776            } else {
1777                None
1778            },
1779            hash_stability: AnchorHashStability::Stable,
1780            derived_from: Vec::new(),
1781            binding: None,
1782            source: None,
1783            span_unvalidated: false,
1784            hash_source: None,
1785            last_observed: None,
1786        }
1787    }
1788
1789    /// The store round-trips through serde and survives a write/read cycle on
1790    /// disk — the durability A1 rests on.
1791    #[test]
1792    fn store_round_trips_on_disk_and_delete_is_idempotent() {
1793        let tmp = tempfile::tempdir().unwrap();
1794        let root = tmp.path();
1795        assert!(
1796            read_findings_store(root, "engine", "graph")
1797                .unwrap()
1798                .is_none()
1799        );
1800
1801        let mut store = FindingsStore {
1802            binding: "engine/graph".to_string(),
1803            ..Default::default()
1804        };
1805        let k = key("hashA", "head1");
1806        store.record(
1807            k.clone(),
1808            "1".to_string(),
1809            vec![Finding {
1810                key: k.clone(),
1811                facet: "src".to_string(),
1812                target: FindingTarget::Artifact {
1813                    artifact: "src/a.rs".to_string(),
1814                },
1815                class: FindingClass::Uncovered,
1816                detail: "d".to_string(),
1817                created_at: "1".to_string(),
1818            }],
1819        );
1820        write_findings_store(root, "engine", "graph", &store).unwrap();
1821        assert!(findings_store_path(root, "engine", "graph").exists());
1822
1823        // The store subtree self-ignores: per-checkout engine state must
1824        // not surface as untracked noise in a tracked workspace.
1825        let ignore = root
1826            .join(WORKSPACE_STORE_DIR)
1827            .join(STATE_DIR)
1828            .join(FINDINGS_DIR)
1829            .join(".gitignore");
1830        assert_eq!(std::fs::read_to_string(&ignore).unwrap(), "*\n");
1831
1832        // Fresh read from disk (a later process) sees the findings (A1).
1833        let back = read_findings_store(root, "engine", "graph")
1834            .unwrap()
1835            .unwrap();
1836        assert_eq!(back, store);
1837        assert_eq!(back.current(&k).len(), 1);
1838
1839        delete_findings_store(root, "engine", "graph").unwrap();
1840        assert!(
1841            read_findings_store(root, "engine", "graph")
1842                .unwrap()
1843                .is_none()
1844        );
1845        // Idempotent.
1846        delete_findings_store(root, "engine", "graph").unwrap();
1847    }
1848
1849    /// A3 — a changed `hash(D)` segregates the prior batch: findings under the
1850    /// old hash are never `current` under the new key, only `superseded`.
1851    #[test]
1852    fn changed_binding_hash_supersedes_prior_findings() {
1853        let mut store = FindingsStore::default();
1854        let old = key("hashOLD", "head1");
1855        let new = key("hashNEW", "head1");
1856        let f_old = Finding {
1857            key: old.clone(),
1858            facet: "src".to_string(),
1859            target: FindingTarget::Artifact {
1860                artifact: "src/old.rs".to_string(),
1861            },
1862            class: FindingClass::Uncovered,
1863            detail: "old".to_string(),
1864            created_at: "1".to_string(),
1865        };
1866        store.record(old.clone(), "1".to_string(), vec![f_old.clone()]);
1867
1868        // Recording under the new key must not touch the old batch.
1869        store.record(new.clone(), "2".to_string(), Vec::new());
1870        assert!(store.current(&new).is_empty(), "new key has its own view");
1871        let superseded = store.superseded(&new);
1872        assert_eq!(superseded.len(), 1, "old batch is segregated as superseded");
1873        assert_eq!(superseded[0], &f_old);
1874        // The old findings are never presented as current under the new key.
1875        assert!(!store.current(&new).contains(&f_old));
1876    }
1877
1878    /// The impl-version bump's documented invalidation-by-construction: a
1879    /// finding recorded under the `hash(D)` a prior engine generation
1880    /// computed (`PREPARATION_IMPL_VERSION` 0, for a binding declaring no
1881    /// preparation at all) is not current under the live hash — segregated
1882    /// as superseded, never presented — because the impl version is hashed
1883    /// into every binding's identity. The old key still reads its own batch,
1884    /// so nothing is deleted, only retired from the current view.
1885    #[test]
1886    fn impl_version_bump_invalidates_findings_by_construction() {
1887        use crate::binding::{
1888            PREPARATION_IMPL_VERSION, ScaffoldParams, hash_binding, hash_binding_at_impl_version,
1889            scaffold_binding,
1890        };
1891        let binding = scaffold_binding(ScaffoldParams {
1892            destination_mem: "plugin",
1893            source_name: "source-tree",
1894            pointer: "../public",
1895            medium_type: crate::pipeline::MediumType::Codebase,
1896            intent: None,
1897            additional_deny_paths: Vec::new(),
1898        })
1899        .binding;
1900        assert!(binding.sources[0].preparation.is_none());
1901        // The live constant is whatever the latest landed implementation set
1902        // it to; the pin is that the version-0 hash (the pre-registry
1903        // generation) is not the live one.
1904        let _ = PREPARATION_IMPL_VERSION;
1905        let old = key(&hash_binding_at_impl_version(&binding, 0), "head1");
1906        let live = key(&hash_binding(&binding), "head1");
1907        assert_ne!(old.binding_hash, live.binding_hash);
1908
1909        let mut store = FindingsStore::default();
1910        let f_old = Finding {
1911            key: old.clone(),
1912            facet: "source-tree".to_string(),
1913            target: FindingTarget::Artifact {
1914                artifact: "src/old.rs".to_string(),
1915            },
1916            class: FindingClass::Uncovered,
1917            detail: "recorded before the bump".to_string(),
1918            created_at: "1".to_string(),
1919        };
1920        store.record(old.clone(), "1".to_string(), vec![f_old.clone()]);
1921
1922        assert!(
1923            store.current(&live).is_empty(),
1924            "a finding keyed on the pre-bump hash is invalid under the live hash"
1925        );
1926        assert_eq!(store.superseded(&live), vec![&f_old]);
1927        assert_eq!(
1928            store.current(&old),
1929            &[f_old.clone()][..],
1930            "nothing is deleted"
1931        );
1932    }
1933
1934    /// Criterion — findings survive head movement: the store keys on `hash(D)`
1935    /// alone, so a finding recorded at head1 stays `current` when read at
1936    /// head2 (the sync brief's read is head-agnostic), still carrying the head
1937    /// it was observed at as metadata. REFUSAL half: recording the hash's next
1938    /// batch (verify's post-merge write) replaces it — a finding absent from
1939    /// that batch (resolved) never re-presents, at any head.
1940    #[test]
1941    fn moved_source_head_keeps_findings_current_until_superseded() {
1942        let mut store = FindingsStore::default();
1943        let before = key("hashA", "head1");
1944        let after = key("hashA", "head2");
1945        let f = Finding {
1946            key: before.clone(),
1947            facet: "src".to_string(),
1948            target: FindingTarget::Anchor {
1949                entity: "engine--e".to_string(),
1950                artifact: "src/x.rs".to_string(),
1951            },
1952            class: FindingClass::UnresolvableAnchor,
1953            detail: "gone".to_string(),
1954            created_at: "1".to_string(),
1955        };
1956        store.record(before.clone(), "1".to_string(), vec![f.clone()]);
1957
1958        // The head moved; the finding is still presented, with its observed
1959        // head intact, and it is not "superseded".
1960        assert_eq!(store.current(&after), std::slice::from_ref(&f));
1961        assert_eq!(store.current(&after)[0].key.source_head, "head1");
1962        assert!(store.superseded(&after).is_empty());
1963
1964        // A verify at head2 records the hash's next batch WITHOUT the finding
1965        // (its target observed clean) → resolved, never re-presented.
1966        store.record(after.clone(), "2".to_string(), Vec::new());
1967        assert!(store.current(&after).is_empty());
1968        assert!(store.current(&before).is_empty(), "at the old head too");
1969        assert_eq!(store.batches.len(), 1, "one batch per hash(D)");
1970    }
1971
1972    /// Migration/compat — a store written by the pre-re-key engine (batches
1973    /// keyed `(hash(D), source_head)`; the exact on-disk shape live dogfood
1974    /// workspaces carry) loads without loss: the other-hash batch stays
1975    /// segregated as superseded, the current-hash batch presents at ANY head,
1976    /// and a legacy same-hash pair collapses to its latest-recorded batch —
1977    /// never resurrecting the older (superseded-at-write-time) one. The next
1978    /// `record` folds the same-hash siblings into one batch.
1979    #[test]
1980    fn legacy_per_head_store_loads_and_presents_head_agnostically() {
1981        let tmp = tempfile::tempdir().unwrap();
1982        let root = tmp.path();
1983        let path = findings_store_path(root, "engine", "graph");
1984        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1985        // Trimmed replica of the live on-disk format: `{binding, batches:[{key:
1986        // {binding_hash, source_head}, recorded_at, findings:[{key, facet,
1987        // target:{kind,...}, class, detail, created_at}]}]}` — one batch under
1988        // an old hash, two batches under the current hash at different heads.
1989        std::fs::write(
1990            &path,
1991            r#"{
1992              "binding": "engine/graph",
1993              "batches": [
1994                {
1995                  "key": { "binding_hash": "hashOLD", "source_head": "src=aaa" },
1996                  "recorded_at": "100",
1997                  "findings": [
1998                    {
1999                      "key": { "binding_hash": "hashOLD", "source_head": "src=aaa" },
2000                      "facet": "src",
2001                      "target": { "kind": "artifact", "artifact": "src/old.rs" },
2002                      "class": "uncovered",
2003                      "detail": "old declaration",
2004                      "created_at": "100"
2005                    }
2006                  ]
2007                },
2008                {
2009                  "key": { "binding_hash": "hashCUR", "source_head": "src=bbb" },
2010                  "recorded_at": "200",
2011                  "findings": [
2012                    {
2013                      "key": { "binding_hash": "hashCUR", "source_head": "src=bbb" },
2014                      "facet": "src",
2015                      "target": { "kind": "artifact", "artifact": "src/resolved-at-ccc.rs" },
2016                      "class": "uncovered",
2017                      "detail": "was open at bbb, absent from the ccc batch",
2018                      "created_at": "200"
2019                    }
2020                  ]
2021                },
2022                {
2023                  "key": { "binding_hash": "hashCUR", "source_head": "src=ccc" },
2024                  "recorded_at": "300",
2025                  "findings": [
2026                    {
2027                      "key": { "binding_hash": "hashCUR", "source_head": "src=ccc" },
2028                      "facet": "src",
2029                      "target": { "kind": "anchor", "entity": "engine--e", "artifact": "src/x.rs" },
2030                      "class": "unresolvable-anchor",
2031                      "detail": "gone",
2032                      "created_at": "300"
2033                    }
2034                  ]
2035                }
2036              ]
2037            }"#,
2038        )
2039        .unwrap();
2040
2041        let mut store = read_findings_store(root, "engine", "graph")
2042            .unwrap()
2043            .expect("the legacy on-disk format loads as-is");
2044        assert_eq!(store.binding, "engine/graph");
2045        assert_eq!(store.batches.len(), 3, "loaded without loss");
2046
2047        // Head-agnostic current view: reading at a NEWLY moved head (ddd —
2048        // recorded nowhere) presents the latest current-hash batch.
2049        let now = key("hashCUR", "src=ddd");
2050        let current = store.current(&now);
2051        assert_eq!(current.len(), 1);
2052        assert_eq!(current[0].detail, "gone");
2053        assert_eq!(
2054            current[0].key.source_head, "src=ccc",
2055            "the finding keeps the head it was observed at"
2056        );
2057        // The pre-re-key superseded batches (old hash + the older same-hash
2058        // head) stay segregated — never mixed into the current view.
2059        let superseded = store.superseded(&now);
2060        assert_eq!(superseded.len(), 2);
2061        assert!(
2062            !current.iter().any(|f| f.detail.contains("was open at bbb")),
2063            "the older same-hash batch was superseded at write time and is not resurrected"
2064        );
2065
2066        // The next record under the current hash collapses the legacy
2067        // same-hash pair into one batch; the old-hash batch is untouched.
2068        store.record(now.clone(), "400".to_string(), Vec::new());
2069        assert_eq!(store.batches.len(), 2, "hashCUR collapsed, hashOLD kept");
2070        assert_eq!(store.superseded(&now).len(), 1);
2071    }
2072
2073    /// An authored exclusion supersedes a standing `uncovered` finding in
2074    /// the STORE, not only in the presentation filter: the merge's
2075    /// accounting closure folds the exclusion ledger in, so an unsampled
2076    /// artifact that gained an exclusion since its finding was recorded
2077    /// closes instead of carrying forward — the verdict count can no longer
2078    /// contradict the coverage section's "0 unaccounted".
2079    #[test]
2080    fn merge_closes_uncovered_findings_for_ledger_excluded_artifacts() {
2081        let k_old = key("h", "head1");
2082        let uncovered = |artifact: &str| Finding {
2083            key: k_old.clone(),
2084            facet: "src".to_string(),
2085            target: FindingTarget::Artifact {
2086                artifact: artifact.to_string(),
2087            },
2088            class: FindingClass::Uncovered,
2089            detail: "no anchor".to_string(),
2090            created_at: "1".to_string(),
2091        };
2092        let prior = vec![uncovered("src/excluded.rs"), uncovered("src/open.rs")];
2093        let obs = PassObservation {
2094            anchors_observed: BTreeSet::new(),
2095            anchors_existing: BTreeSet::new(),
2096            files_observed: BTreeSet::new(), // neither sampled this pass
2097            s_d: ["src/excluded.rs".to_string(), "src/open.rs".to_string()].into(),
2098        };
2099        let excluded: BTreeSet<String> = ["src/excluded.rs".to_string()].into();
2100        let merged = merge_with_prior(Vec::new(), &prior, &obs, |artifact: &str| {
2101            excluded.contains(artifact)
2102        });
2103        assert_eq!(
2104            merged.len(),
2105            1,
2106            "the excluded finding closes, the open one carries: {merged:?}"
2107        );
2108        assert_eq!(
2109            merged[0].target,
2110            FindingTarget::Artifact {
2111                artifact: "src/open.rs".to_string()
2112            }
2113        );
2114    }
2115
2116    /// The head-durable merge: an unobserved-but-still-open prior finding
2117    /// carries forward (original observed head intact); a prior finding whose
2118    /// artifact left `S(D)`, gained coverage, or whose anchor vanished closes;
2119    /// a re-observed target takes this pass's outcome (clean → closed).
2120    #[test]
2121    fn merge_carries_unobserved_open_findings_and_closes_departed() {
2122        let k_old = key("h", "head1");
2123        let mk_artifact = |artifact: &str, detail: &str| Finding {
2124            key: k_old.clone(),
2125            facet: "src".to_string(),
2126            target: FindingTarget::Artifact {
2127                artifact: artifact.to_string(),
2128            },
2129            class: FindingClass::Uncovered,
2130            detail: detail.to_string(),
2131            created_at: "1".to_string(),
2132        };
2133        let anchor_finding = Finding {
2134            key: k_old.clone(),
2135            facet: "src".to_string(),
2136            target: FindingTarget::Anchor {
2137                entity: "engine--gone".to_string(),
2138                artifact: "src/gone.rs".to_string(),
2139            },
2140            class: FindingClass::UnresolvableAnchor,
2141            detail: "anchor since removed from the mem".to_string(),
2142            created_at: "1".to_string(),
2143        };
2144        let prior = vec![
2145            mk_artifact("src/unsampled.rs", "still open, not in this window"),
2146            mk_artifact("src/departed.rs", "left S(D)"),
2147            mk_artifact("src/now-covered.rs", "gained an anchor since"),
2148            mk_artifact("src/observed-clean.rs", "re-sampled and now covered"),
2149            anchor_finding,
2150        ];
2151        let obs = PassObservation {
2152            anchors_observed: BTreeSet::new(),
2153            anchors_existing: BTreeSet::new(), // the anchor vanished
2154            files_observed: ["src/observed-clean.rs".to_string()].into(),
2155            s_d: [
2156                "src/unsampled.rs".to_string(),
2157                "src/now-covered.rs".to_string(),
2158                "src/observed-clean.rs".to_string(),
2159            ]
2160            .into(),
2161        };
2162        let merged = merge_with_prior(Vec::new(), &prior, &obs, |artifact| {
2163            artifact == "src/now-covered.rs" || artifact == "src/observed-clean.rs"
2164        });
2165        assert_eq!(merged.len(), 1, "only the still-open unsampled one carries");
2166        assert_eq!(
2167            merged[0].target,
2168            FindingTarget::Artifact {
2169                artifact: "src/unsampled.rs".to_string()
2170            }
2171        );
2172        assert_eq!(
2173            merged[0].key.source_head, "head1",
2174            "a carried finding keeps the head it was observed at"
2175        );
2176    }
2177
2178    /// Supersession honesty: a fresh `queued-for-adjudication` entry is a
2179    /// scheduling deferral, not an observation — it never downgrades a prior
2180    /// substantive `drifted` verdict for the same target. A fresh substantive
2181    /// outcome (or a clean observation) still supersedes normally.
2182    #[test]
2183    fn merge_deferral_never_downgrades_prior_adjudication() {
2184        let k_old = key("h", "head1");
2185        let k_new = key("h", "head2");
2186        let target = FindingTarget::Anchor {
2187            entity: "engine--e".to_string(),
2188            artifact: "src/x.rs".to_string(),
2189        };
2190        let prior_drifted = Finding {
2191            key: k_old.clone(),
2192            facet: "src".to_string(),
2193            target: target.clone(),
2194            class: FindingClass::Drifted,
2195            detail: "adjudicated drifted at head1".to_string(),
2196            created_at: "1".to_string(),
2197        };
2198        let fresh_queued = Finding {
2199            key: k_new.clone(),
2200            facet: "src".to_string(),
2201            target: target.clone(),
2202            class: FindingClass::QueuedForAdjudication,
2203            detail: "deferred by the cap this run".to_string(),
2204            created_at: "2".to_string(),
2205        };
2206        let obs = PassObservation {
2207            anchors_observed: [target_key(&target)].into(),
2208            anchors_existing: [target_key(&target)].into(),
2209            files_observed: BTreeSet::new(),
2210            s_d: BTreeSet::new(),
2211        };
2212        let merged = merge_with_prior(
2213            vec![fresh_queued],
2214            std::slice::from_ref(&prior_drifted),
2215            &obs,
2216            |_| true,
2217        );
2218        assert_eq!(merged.len(), 1);
2219        assert_eq!(
2220            merged[0].class,
2221            FindingClass::Drifted,
2222            "the prior verdict stands over a deferral"
2223        );
2224        assert_eq!(merged[0].key.source_head, "head1");
2225    }
2226
2227    /// A2 — hash-drift adjudication is excluded for `informed-by` (and every
2228    /// non-hash-bearing class): a drifted/recheck state yields NO finding.
2229    #[test]
2230    fn informed_by_anchor_never_drifts() {
2231        let k = key("h", "s");
2232        for class in [
2233            AnchorProvenanceClass::InformedBy,
2234            AnchorProvenanceClass::Authored,
2235        ] {
2236            let a = anchor(class);
2237            assert!(
2238                adjudicate_anchor(&k, "f", "engine--e", &a, AnchorState::Drifted, "1").is_none(),
2239                "{class:?} must not produce a drift finding"
2240            );
2241            assert!(
2242                adjudicate_anchor(&k, "f", "engine--e", &a, AnchorState::Recheck, "1").is_none(),
2243                "{class:?} must not produce a queued finding"
2244            );
2245        }
2246    }
2247
2248    /// A2 — hash-bearing classes DO produce drift/recheck findings, and every
2249    /// class produces an existence (`unresolvable-anchor`) finding when orphaned.
2250    #[test]
2251    fn hash_bearing_drifts_and_orphan_is_class_independent() {
2252        let k = key("h", "s");
2253        let anchored = anchor(AnchorProvenanceClass::Anchored);
2254        let drifted =
2255            adjudicate_anchor(&k, "f", "engine--e", &anchored, AnchorState::Drifted, "1").unwrap();
2256        assert_eq!(drifted.class, FindingClass::Drifted);
2257        assert_eq!(drifted.key, k, "the finding carries its recording key (A2)");
2258
2259        let queued =
2260            adjudicate_anchor(&k, "f", "engine--e", &anchored, AnchorState::Recheck, "1").unwrap();
2261        assert_eq!(queued.class, FindingClass::QueuedForAdjudication);
2262
2263        // Orphaned is existence, not hash-drift — reported for informed-by too.
2264        let informed = anchor(AnchorProvenanceClass::InformedBy);
2265        let orphan =
2266            adjudicate_anchor(&k, "f", "engine--e", &informed, AnchorState::Orphaned, "1").unwrap();
2267        assert_eq!(orphan.class, FindingClass::UnresolvableAnchor);
2268
2269        // Resolves yields nothing.
2270        assert!(
2271            adjudicate_anchor(&k, "f", "engine--e", &anchored, AnchorState::Resolves, "1")
2272                .is_none()
2273        );
2274    }
2275
2276    /// The finding class vocabulary round-trips through its wire form.
2277    #[test]
2278    fn finding_class_wire_round_trips() {
2279        for w in FindingClass::WIRE_VALUES {
2280            let c = FindingClass::from_wire(w).expect("known wire value");
2281            assert_eq!(c.as_wire(), *w);
2282        }
2283        assert!(FindingClass::from_wire("nonsense").is_none());
2284    }
2285
2286    /// A malformed binding id refuses before touching the store tier.
2287    #[test]
2288    fn malformed_binding_id_refuses() {
2289        assert!(matches!(
2290            split_binding_id("../escape"),
2291            Err(FindingsError::MalformedId(_))
2292        ));
2293        assert!(matches!(
2294            split_binding_id("no-slash"),
2295            Err(FindingsError::MalformedId(_))
2296        ));
2297        assert_eq!(
2298            split_binding_id("engine/graph").unwrap(),
2299            ("engine".to_string(), "graph".to_string())
2300        );
2301    }
2302
2303    // ---- A1/A5 end-to-end: verify writes durable findings, no entity write --
2304
2305    use crate::anchor::AnchorSidecar;
2306    use crate::binding::{
2307        BINDING_VERSION, BuildMode, BuildOperation, CoverageSemantics, DEFAULT_ADJUDICATION_CAP,
2308        DEFAULT_FULL_RESYNC_EVERY, Operations, VerifyOperation,
2309    };
2310    use crate::ingest::resolve::resolve_binding_run;
2311    use crate::pipeline::{IngestTrigger, MediumType, PatternEntry, PatternMode};
2312    use crate::pipeline_store::{load_pipeline_configs, write_binding};
2313    use crate::workspace::{
2314        Mount, MountCapability, MountLifecycle, MountStorage, Workspace, WorkspaceSettings,
2315    };
2316    use crate::workspace_store::WorkspaceStoreAdapter;
2317
2318    /// A full verify pass over a folder mem: it adjudicates the mem's anchors
2319    /// against the live source (orphaned → unresolvable-anchor; present
2320    /// hash-bearing whose recorded hash mismatches the observed prepared form
2321    /// → deterministic `drifted`; informed-by → no finding, A2) and flags an
2322    /// uncovered source file, then persists the findings to the durable state
2323    /// tier. A **fresh** read from disk (a later process) sees them (A1). The
2324    /// pass runs on a shared `&Engine` — structurally read-only on the mem (A5).
2325    #[test]
2326    fn verify_persists_findings_readable_fresh() {
2327        let tmp = tempfile::tempdir().unwrap();
2328        let root = tmp.path();
2329        let mem_dir = root.join("mem");
2330        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
2331        std::fs::write(
2332            mem_dir.join(".memstead").join("config.json"),
2333            r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
2334        )
2335        .unwrap();
2336
2337        // Workspace state so `from_workspace_root` sets `workspace_root` (which
2338        // the anchor observation and cursor need) and mounts the `engine` mem.
2339        std::fs::create_dir_all(root.join(".memstead")).unwrap();
2340        std::fs::write(
2341            root.join(".memstead").join("workspace.toml"),
2342            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2343        )
2344        .unwrap();
2345        let mount = Mount {
2346            mem: "engine".to_string(),
2347            schema: Some("default@1.0.0".parse().unwrap()),
2348            storage: MountStorage::Folder {
2349                path: mem_dir.clone(),
2350            },
2351            capability: MountCapability::Write,
2352            lifecycle: MountLifecycle::Eager,
2353            cross_linkable: false,
2354            migration_target: None,
2355        };
2356        crate::FileWorkspaceStore::new()
2357            .save_state(
2358                root,
2359                &Workspace {
2360                    mounts: vec![mount],
2361                    settings: WorkspaceSettings::default(),
2362                },
2363            )
2364            .unwrap();
2365
2366        // A git work tree at the workspace root so the codebase medium's `git`
2367        // change strategy resolves; source files: one anchored+present, one
2368        // uncovered.
2369        let out = std::process::Command::new("git")
2370            .args(["init", "-q"])
2371            .current_dir(root)
2372            .output()
2373            .unwrap();
2374        assert!(out.status.success());
2375        std::fs::create_dir_all(root.join("src")).unwrap();
2376        std::fs::write(root.join("src").join("present.rs"), "fn a() {}\n").unwrap();
2377        std::fs::write(root.join("src").join("uncovered.rs"), "fn b() {}\n").unwrap();
2378
2379        // Seed the engine-owned anchors sidecar directly (test fixture — the
2380        // production write path is the mutation surface, not this verify code).
2381        let mk = |artifact: &str, class: AnchorProvenanceClass| Anchor {
2382            artifact: artifact.to_string(),
2383            grain: AnchorGrain::File,
2384            class,
2385            at_version: None,
2386            hash: class.is_hash_bearing().then(|| "recorded".to_string()),
2387            hash_stability: AnchorHashStability::Stable,
2388            derived_from: Vec::new(),
2389            binding: None,
2390            source: None,
2391            span_unvalidated: false,
2392            hash_source: None,
2393            last_observed: None,
2394        };
2395        // The entity the sidecar is keyed to. Written, because it exists:
2396        // a row whose entity does not is DANGLING (consistency-sweep 03/02)
2397        // and leaves the population before any figure counts it.
2398        std::fs::write(
2399            mem_dir.join("e.md"),
2400            "---\ntype: decision\n---\n\n# E\n\n## Decision\n\nBody.\n",
2401        )
2402        .unwrap();
2403        let mut sidecar = AnchorSidecar::default();
2404        sidecar.set(
2405            "engine--e",
2406            vec![
2407                mk("src/present.rs", AnchorProvenanceClass::Anchored), // recorded hash mismatches prepared form → drifted
2408                mk("src/gone.rs", AnchorProvenanceClass::Anchored), // absent → unresolvable-anchor
2409                mk("src/present.rs", AnchorProvenanceClass::InformedBy), // present, non-hash → no finding (A2)
2410            ],
2411        );
2412        std::fs::write(
2413            mem_dir.join(crate::anchor::ANCHOR_SIDECAR_PATH),
2414            sidecar.to_bytes(),
2415        )
2416        .unwrap();
2417
2418        // Binding engine/graph over a codebase facet (medium root = workspace).
2419        write_binding(
2420            root,
2421            "engine",
2422            "graph",
2423            &Binding {
2424                version: BINDING_VERSION,
2425                intent: None,
2426                sources: vec![crate::pipeline::Source {
2427                    name: "graph".to_string(),
2428                    medium_type: MediumType::Codebase,
2429                    pointer: String::new(),
2430                    change_detection: Some("git".to_string()),
2431                    scope: vec![PatternEntry {
2432                        path: "src/**/*.rs".to_string(),
2433                        mode: PatternMode::Allow,
2434                    }],
2435                    engagement: None,
2436                    preparation: None,
2437                }],
2438                reference_mems: Vec::new(),
2439                destination_mem: "engine".to_string(),
2440                deny_paths: Vec::new(),
2441                coverage_semantics: None,
2442                rules: None,
2443                prune: None,
2444                operations: Operations {
2445                    build: Some(BuildOperation {
2446                        mode: BuildMode::Discovery,
2447                        trigger: IngestTrigger::Loop,
2448                        batch_size: 20,
2449                        post_actions: None,
2450                    }),
2451                    sync: None,
2452                    verify: Some(VerifyOperation {
2453                        trigger: IngestTrigger::Manual,
2454                        batch_size: 20,
2455                        adjudication_cap: DEFAULT_ADJUDICATION_CAP,
2456                        full_resync_every: DEFAULT_FULL_RESYNC_EVERY,
2457                    }),
2458                },
2459            },
2460        )
2461        .unwrap();
2462
2463        let engine = Engine::from_workspace_root(root).unwrap();
2464
2465        let configs = load_pipeline_configs(root).unwrap();
2466        let binding = &configs.bindings[0].config;
2467        let resolved = resolve_binding_run("engine/graph", binding).unwrap();
2468
2469        // `&engine` — shared borrow, structurally cannot mutate the mem (A5).
2470        let outcome = verify_binding(&engine, root, binding, &resolved).unwrap();
2471        assert!(
2472            outcome.recorded >= 3,
2473            "orphan + drifted + uncovered at least"
2474        );
2475        assert_eq!(outcome.superseded, 0, "no prior key yet");
2476        assert_eq!(
2477            outcome.backlog, 0,
2478            "the mismatching hash adjudicated deterministically — nothing queued"
2479        );
2480        assert!(
2481            outcome.hash_backfill.is_empty(),
2482            "every hash-bearing anchor already carries a recorded hash — nothing to backfill"
2483        );
2484
2485        // Fresh read from disk — a later process / sync-brief render (A1).
2486        let store = read_findings_store(root, "engine", "graph")
2487            .unwrap()
2488            .unwrap();
2489        let current = store.current(&outcome.key);
2490        assert_eq!(current.len(), outcome.recorded);
2491
2492        let has = |c: FindingClass, art: &str| {
2493            current.iter().any(|f| {
2494                f.class == c
2495                    && match &f.target {
2496                        FindingTarget::Anchor { artifact, .. } => artifact == art,
2497                        FindingTarget::Artifact { artifact } => artifact == art,
2498                    }
2499            })
2500        };
2501        assert!(has(FindingClass::UnresolvableAnchor, "src/gone.rs"));
2502        assert!(
2503            has(FindingClass::Drifted, "src/present.rs"),
2504            "recorded-hash mismatch on a stable medium adjudicates drifted deterministically"
2505        );
2506        assert!(has(FindingClass::Uncovered, "src/uncovered.rs"));
2507        // A2: the informed-by anchor on the present file produced no finding —
2508        // the one drifted finding above belongs to the anchored (hash-bearing)
2509        // anchor, and nothing queued.
2510        assert!(
2511            !current
2512                .iter()
2513                .any(|f| f.class == FindingClass::QueuedForAdjudication
2514                    || f.class == FindingClass::Wrong),
2515            "deterministic adjudication leaves nothing queued"
2516        );
2517        // The covered file is not flagged uncovered.
2518        assert!(!has(FindingClass::Uncovered, "src/present.rs"));
2519    }
2520
2521    /// Criterion, end-to-end — **findings survive head movement**: a finding
2522    /// recorded at head H keeps presenting through the sync brief's read
2523    /// (`current_findings` / `render_sync_brief_for`) after the source
2524    /// advances to H′, until a verify observes its subject clean — and once
2525    /// resolved it never re-presents, at any head.
2526    #[test]
2527    fn finding_recorded_at_old_head_presents_in_brief_at_new_head() {
2528        use crate::ingest::render::render_sync_brief_for;
2529
2530        let tmp = tempfile::tempdir().unwrap();
2531        let root = tmp.path();
2532        let mem_dir = root.join("mem");
2533        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
2534        std::fs::write(
2535            mem_dir.join(".memstead").join("config.json"),
2536            r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
2537        )
2538        .unwrap();
2539        std::fs::create_dir_all(root.join(".memstead")).unwrap();
2540        std::fs::write(
2541            root.join(".memstead").join("workspace.toml"),
2542            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2543        )
2544        .unwrap();
2545        let mount = Mount {
2546            mem: "engine".to_string(),
2547            schema: Some("default@1.0.0".parse().unwrap()),
2548            storage: MountStorage::Folder {
2549                path: mem_dir.clone(),
2550            },
2551            capability: MountCapability::Write,
2552            lifecycle: MountLifecycle::Eager,
2553            cross_linkable: false,
2554            migration_target: None,
2555        };
2556        crate::FileWorkspaceStore::new()
2557            .save_state(
2558                root,
2559                &Workspace {
2560                    mounts: vec![mount],
2561                    settings: WorkspaceSettings::default(),
2562                },
2563            )
2564            .unwrap();
2565
2566        // Git source tree at head A: src/present.rs committed.
2567        let git = |args: &[&str]| {
2568            let out = std::process::Command::new("git")
2569                .args(args)
2570                .current_dir(root)
2571                .env("GIT_AUTHOR_NAME", "t")
2572                .env("GIT_AUTHOR_EMAIL", "t@t")
2573                .env("GIT_COMMITTER_NAME", "t")
2574                .env("GIT_COMMITTER_EMAIL", "t@t")
2575                .output()
2576                .unwrap();
2577            assert!(
2578                out.status.success(),
2579                "git {args:?}: {}",
2580                String::from_utf8_lossy(&out.stderr)
2581            );
2582        };
2583        git(&["init", "-q"]);
2584        std::fs::create_dir_all(root.join("src")).unwrap();
2585        std::fs::write(root.join("src").join("present.rs"), "fn a() {}\n").unwrap();
2586        git(&["add", "-A"]);
2587        git(&["commit", "-qm", "head-a"]);
2588
2589        // Anchors: `informed-by` on the present file (clean, non-hash — no
2590        // finding) and on the ABSENT src/gone.rs (orphaned → the finding).
2591        let mk = |artifact: &str| Anchor {
2592            artifact: artifact.to_string(),
2593            grain: AnchorGrain::File,
2594            class: AnchorProvenanceClass::InformedBy,
2595            at_version: None,
2596            hash: None,
2597            hash_stability: AnchorHashStability::Stable,
2598            derived_from: Vec::new(),
2599            binding: None,
2600            source: None,
2601            span_unvalidated: false,
2602            hash_source: None,
2603            last_observed: None,
2604        };
2605        // The entity the sidecar is keyed to. Written, because it exists:
2606        // a row whose entity does not is DANGLING (consistency-sweep 03/02)
2607        // and leaves the population before any figure counts it.
2608        std::fs::write(
2609            mem_dir.join("e.md"),
2610            "---\ntype: decision\n---\n\n# E\n\n## Decision\n\nBody.\n",
2611        )
2612        .unwrap();
2613        let mut sidecar = AnchorSidecar::default();
2614        sidecar.set("engine--e", vec![mk("src/present.rs"), mk("src/gone.rs")]);
2615        std::fs::write(
2616            mem_dir.join(crate::anchor::ANCHOR_SIDECAR_PATH),
2617            sidecar.to_bytes(),
2618        )
2619        .unwrap();
2620
2621        write_binding(
2622            root,
2623            "engine",
2624            "graph",
2625            &Binding {
2626                version: BINDING_VERSION,
2627                intent: None,
2628                sources: vec![crate::pipeline::Source {
2629                    name: "graph".to_string(),
2630                    medium_type: MediumType::Codebase,
2631                    pointer: String::new(),
2632                    change_detection: Some("git".to_string()),
2633                    scope: vec![PatternEntry {
2634                        path: "src/**/*.rs".to_string(),
2635                        mode: PatternMode::Allow,
2636                    }],
2637                    engagement: None,
2638                    preparation: None,
2639                }],
2640                reference_mems: Vec::new(),
2641                destination_mem: "engine".to_string(),
2642                deny_paths: Vec::new(),
2643                coverage_semantics: None,
2644                rules: None,
2645                prune: None,
2646                operations: Operations {
2647                    build: None,
2648                    sync: Some(crate::binding::SyncOperation {
2649                        trigger: IngestTrigger::Manual,
2650                        batch_size: 20,
2651                    }),
2652                    verify: Some(VerifyOperation {
2653                        trigger: IngestTrigger::Manual,
2654                        batch_size: 20,
2655                        adjudication_cap: DEFAULT_ADJUDICATION_CAP,
2656                        full_resync_every: DEFAULT_FULL_RESYNC_EVERY,
2657                    }),
2658                },
2659            },
2660        )
2661        .unwrap();
2662
2663        // Verify at head A — records the orphaned-anchor finding.
2664        let configs = load_pipeline_configs(root).unwrap();
2665        let binding = &configs.bindings[0].config;
2666        let resolved = resolve_binding_run("engine/graph", binding).unwrap();
2667        let head_a_outcome = {
2668            let engine = Engine::from_workspace_root(root).unwrap();
2669            verify_binding(&engine, root, binding, &resolved).unwrap()
2670        };
2671        assert!(
2672            head_a_outcome.key.source_head.contains("graph="),
2673            "the run observed a facet head"
2674        );
2675
2676        // The source moves to head B.
2677        std::fs::write(root.join("src").join("present.rs"), "fn a() {} // more\n").unwrap();
2678        git(&["add", "-A"]);
2679        git(&["commit", "-qm", "head-b"]);
2680
2681        // A fresh process at head B: the finding recorded at head A is still
2682        // presented — by the brief's read AND in the rendered sync brief.
2683        {
2684            let engine = Engine::from_workspace_root(root).unwrap();
2685            let (key_b, findings) = current_findings(&engine, root, binding, &resolved).unwrap();
2686            assert_ne!(
2687                key_b.source_head, head_a_outcome.key.source_head,
2688                "the head really moved"
2689            );
2690            assert_eq!(findings.len(), 1);
2691            assert_eq!(findings[0].class, FindingClass::UnresolvableAnchor);
2692            assert_eq!(
2693                findings[0].key.source_head, head_a_outcome.key.source_head,
2694                "the finding still records the head it was observed at"
2695            );
2696
2697            let brief = render_sync_brief_for(&engine, root, "engine/graph").unwrap();
2698            assert!(brief.contains("## Open findings to repair"));
2699            assert!(brief.contains("src/gone.rs"));
2700        }
2701
2702        // The repair lands: src/gone.rs exists again (head C). A verify
2703        // observes the anchor clean → the finding closes…
2704        std::fs::write(root.join("src").join("gone.rs"), "fn g() {}\n").unwrap();
2705        git(&["add", "-A"]);
2706        git(&["commit", "-qm", "head-c"]);
2707        {
2708            let engine = Engine::from_workspace_root(root).unwrap();
2709            verify_binding(&engine, root, binding, &resolved).unwrap();
2710        }
2711        // …and never re-presents (REFUSAL: resolved findings stay resolved).
2712        {
2713            let engine = Engine::from_workspace_root(root).unwrap();
2714            let (_key, findings) = current_findings(&engine, root, binding, &resolved).unwrap();
2715            assert!(
2716                findings
2717                    .iter()
2718                    .all(|f| f.class != FindingClass::UnresolvableAnchor),
2719                "the resolved orphan finding must not re-present: {findings:?}"
2720            );
2721        }
2722    }
2723
2724    /// Prepared-hash backfill + deterministic drift, end-to-end over real git
2725    /// heads and fresh engines:
2726    ///
2727    /// 1. a hash-less `anchored`/`derived` anchor on a resolvable artifact is
2728    ///    backfilled by the first verify (once — a re-verify observes an empty
2729    ///    worklist and the recorded hash is never overwritten);
2730    /// 2. after a source change, a subsequent verify adjudicates `drifted`
2731    ///    deterministically — no LLM sampling, no queued deferral;
2732    /// 3. the tier-3 recheck queue for such anchors drains: post-backfill
2733    ///    clean passes queue nothing, instead of re-queueing forever.
2734    ///
2735    /// The plain-TREE sibling of this lifecycle is
2736    /// [`plain_tree_anchor_backfills_then_adjudicates_deterministically`].
2737    ///
2738    /// REFUSAL half: `authored` / `informed-by` anchors never gain hashes and
2739    /// never adjudicate `drifted`; an `unstable` hash-stability medium
2740    /// resolves `recheck` (queued), never `drifted`.
2741    #[test]
2742    fn hashless_anchor_backfills_once_then_drift_adjudicates_deterministically() {
2743        let tmp = tempfile::tempdir().unwrap();
2744        let root = tmp.path();
2745        let mem_dir = root.join("mem");
2746        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
2747        std::fs::write(
2748            mem_dir.join(".memstead").join("config.json"),
2749            r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
2750        )
2751        .unwrap();
2752        std::fs::create_dir_all(root.join(".memstead")).unwrap();
2753        std::fs::write(
2754            root.join(".memstead").join("workspace.toml"),
2755            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2756        )
2757        .unwrap();
2758        let mount = Mount {
2759            mem: "engine".to_string(),
2760            schema: Some("default@1.0.0".parse().unwrap()),
2761            storage: MountStorage::Folder {
2762                path: mem_dir.clone(),
2763            },
2764            capability: MountCapability::Write,
2765            lifecycle: MountLifecycle::Eager,
2766            cross_linkable: false,
2767            migration_target: None,
2768        };
2769        crate::FileWorkspaceStore::new()
2770            .save_state(
2771                root,
2772                &Workspace {
2773                    mounts: vec![mount],
2774                    settings: WorkspaceSettings::default(),
2775                },
2776            )
2777            .unwrap();
2778
2779        // Git source tree at head A: two committed source files.
2780        let git = |args: &[&str]| {
2781            let out = std::process::Command::new("git")
2782                .args(args)
2783                .current_dir(root)
2784                .env("GIT_AUTHOR_NAME", "t")
2785                .env("GIT_AUTHOR_EMAIL", "t@t")
2786                .env("GIT_COMMITTER_NAME", "t")
2787                .env("GIT_COMMITTER_EMAIL", "t@t")
2788                .output()
2789                .unwrap();
2790            assert!(
2791                out.status.success(),
2792                "git {args:?}: {}",
2793                String::from_utf8_lossy(&out.stderr)
2794            );
2795        };
2796        git(&["init", "-q"]);
2797        std::fs::create_dir_all(root.join("src")).unwrap();
2798        std::fs::write(root.join("src").join("present.rs"), "fn a() {}\n").unwrap();
2799        std::fs::write(root.join("src").join("other.rs"), "fn o() {}\n").unwrap();
2800        git(&["add", "-A"]);
2801        git(&["commit", "-qm", "head-a"]);
2802
2803        // Anchors, all HASH-LESS: `anchored` (stable) + `derived` (stable) on
2804        // present.rs, `anchored` but UNSTABLE on other.rs, and the two
2805        // non-hash classes that must never gain a hash.
2806        let mk = |artifact: &str, class: AnchorProvenanceClass, stab: AnchorHashStability| Anchor {
2807            artifact: artifact.to_string(),
2808            grain: AnchorGrain::File,
2809            class,
2810            at_version: None,
2811            hash: None,
2812            hash_stability: stab,
2813            derived_from: if class == AnchorProvenanceClass::Derived {
2814                vec!["src/present.rs".to_string()]
2815            } else {
2816                Vec::new()
2817            },
2818            binding: None,
2819            source: None,
2820            span_unvalidated: false,
2821            hash_source: None,
2822            last_observed: None,
2823        };
2824        use AnchorHashStability::{Stable, Unstable};
2825        // The entity the sidecar is keyed to. Written, because it exists:
2826        // a row whose entity does not is DANGLING (consistency-sweep 03/02)
2827        // and leaves the population before any figure counts it.
2828        std::fs::write(
2829            mem_dir.join("e.md"),
2830            "---\ntype: decision\n---\n\n# E\n\n## Decision\n\nBody.\n",
2831        )
2832        .unwrap();
2833        let mut sidecar = AnchorSidecar::default();
2834        sidecar.set(
2835            "engine--e",
2836            vec![
2837                mk("src/present.rs", AnchorProvenanceClass::Anchored, Stable),
2838                mk("src/present.rs", AnchorProvenanceClass::Derived, Stable),
2839                mk("src/other.rs", AnchorProvenanceClass::Anchored, Unstable),
2840                mk("src/present.rs", AnchorProvenanceClass::Authored, Stable),
2841                mk("src/present.rs", AnchorProvenanceClass::InformedBy, Stable),
2842            ],
2843        );
2844        std::fs::write(
2845            mem_dir.join(crate::anchor::ANCHOR_SIDECAR_PATH),
2846            sidecar.to_bytes(),
2847        )
2848        .unwrap();
2849
2850        write_binding(
2851            root,
2852            "engine",
2853            "graph",
2854            &Binding {
2855                version: BINDING_VERSION,
2856                intent: None,
2857                sources: vec![crate::pipeline::Source {
2858                    name: "graph".to_string(),
2859                    medium_type: MediumType::Codebase,
2860                    pointer: String::new(),
2861                    change_detection: Some("git".to_string()),
2862                    scope: vec![PatternEntry {
2863                        path: "src/**/*.rs".to_string(),
2864                        mode: PatternMode::Allow,
2865                    }],
2866                    engagement: None,
2867                    preparation: None,
2868                }],
2869                reference_mems: Vec::new(),
2870                destination_mem: "engine".to_string(),
2871                deny_paths: Vec::new(),
2872                coverage_semantics: None,
2873                rules: None,
2874                prune: None,
2875                operations: Operations {
2876                    build: None,
2877                    sync: None,
2878                    verify: Some(VerifyOperation {
2879                        trigger: IngestTrigger::Manual,
2880                        batch_size: 20,
2881                        adjudication_cap: DEFAULT_ADJUDICATION_CAP,
2882                        full_resync_every: DEFAULT_FULL_RESYNC_EVERY,
2883                    }),
2884                },
2885            },
2886        )
2887        .unwrap();
2888
2889        let configs = load_pipeline_configs(root).unwrap();
2890        let binding = &configs.bindings[0].config;
2891        let resolved = resolve_binding_run("engine/graph", binding).unwrap();
2892
2893        // --- Pass 1: first observation backfills, once. ---
2894        {
2895            let mut engine = Engine::from_workspace_root(root).unwrap();
2896            let outcome = verify_binding(&engine, root, binding, &resolved).unwrap();
2897            // Every hash-less hash-bearing anchor is on the worklist —
2898            // including the unstable one; the non-hash classes are not.
2899            let mut backfilled: Vec<(&str, &str)> = outcome
2900                .hash_backfill
2901                .iter()
2902                .map(|b| (b.entity.as_str(), b.artifact.as_str()))
2903                .collect();
2904            backfilled.sort();
2905            backfilled.dedup();
2906            assert_eq!(
2907                backfilled,
2908                vec![
2909                    ("engine--e", "src/other.rs"),
2910                    ("engine--e", "src/present.rs"),
2911                ],
2912                "hash-bearing anchors backfill; authored/informed-by never appear"
2913            );
2914            // Backfill candidates are clean-by-construction this pass —
2915            // nothing queued, nothing drifted (the recheck queue drains).
2916            assert_eq!(
2917                outcome.backlog, 0,
2918                "no recheck queue for backfilled anchors"
2919            );
2920            let store = read_findings_store(root, "engine", "graph")
2921                .unwrap()
2922                .unwrap();
2923            assert!(
2924                store
2925                    .current(&outcome.key)
2926                    .iter()
2927                    .all(|f| !matches!(f.target, FindingTarget::Anchor { .. })),
2928                "no anchor finding on the backfill pass: {:?}",
2929                store.current(&outcome.key)
2930            );
2931
2932            // The sanctioned post-run write records the hashes.
2933            let written =
2934                record_anchor_hash_backfill(&mut engine, "engine", &outcome, None).unwrap();
2935            assert_eq!(
2936                written, 3,
2937                "anchored + derived + unstable-anchored gain hashes"
2938            );
2939        }
2940
2941        // The sidecar now carries the observed prepared-form hashes — and the
2942        // non-hash classes still carry none (class semantics preserved).
2943        let expected_present = crate::anchor::prepared_content_hash(
2944            &std::fs::read(root.join("src").join("present.rs")).unwrap(),
2945        );
2946        {
2947            let sc = AnchorSidecar::from_bytes(
2948                &std::fs::read(mem_dir.join(crate::anchor::ANCHOR_SIDECAR_PATH)).unwrap(),
2949            )
2950            .unwrap();
2951            for a in sc.get("engine--e") {
2952                if a.class.is_hash_bearing() {
2953                    assert!(a.hash.is_some(), "hash-bearing anchor backfilled: {a:?}");
2954                } else {
2955                    assert!(a.hash.is_none(), "non-hash class never gains a hash: {a:?}");
2956                }
2957                if a.artifact == "src/present.rs" && a.class.is_hash_bearing() {
2958                    assert_eq!(a.hash.as_deref(), Some(expected_present.as_str()));
2959                }
2960            }
2961        }
2962
2963        // --- Pass 2 (fresh engine): idempotent — nothing to backfill, clean. ---
2964        {
2965            let mut engine = Engine::from_workspace_root(root).unwrap();
2966            let outcome = verify_binding(&engine, root, binding, &resolved).unwrap();
2967            assert!(
2968                outcome.hash_backfill.is_empty(),
2969                "backfill happens once — a re-verify observes an empty worklist"
2970            );
2971            assert_eq!(outcome.backlog, 0, "steady state: nothing re-queues");
2972            let store = read_findings_store(root, "engine", "graph")
2973                .unwrap()
2974                .unwrap();
2975            assert!(
2976                store
2977                    .current(&outcome.key)
2978                    .iter()
2979                    .all(|f| !matches!(f.target, FindingTarget::Anchor { .. })),
2980                "recorded hashes match the source — no anchor finding"
2981            );
2982            let written =
2983                record_anchor_hash_backfill(&mut engine, "engine", &outcome, None).unwrap();
2984            assert_eq!(written, 0, "no write, no commit on the idempotent pass");
2985        }
2986
2987        // --- Source change: both anchored artifacts move (head B). ---
2988        std::fs::write(
2989            root.join("src").join("present.rs"),
2990            "fn a() { /* changed */ }\n",
2991        )
2992        .unwrap();
2993        std::fs::write(
2994            root.join("src").join("other.rs"),
2995            "fn o() { /* changed */ }\n",
2996        )
2997        .unwrap();
2998        git(&["add", "-A"]);
2999        git(&["commit", "-qm", "head-b"]);
3000
3001        // --- Pass 3: deterministic adjudication — stable drifts, unstable
3002        //     rechecks, non-hash classes stay silent. ---
3003        {
3004            let engine = Engine::from_workspace_root(root).unwrap();
3005            let outcome = verify_binding(&engine, root, binding, &resolved).unwrap();
3006            assert!(
3007                outcome.hash_backfill.is_empty(),
3008                "recorded hashes are never overwritten by observation"
3009            );
3010            let store = read_findings_store(root, "engine", "graph")
3011                .unwrap()
3012                .unwrap();
3013            let current = store.current(&outcome.key);
3014            let drifted: Vec<&Finding> = current
3015                .iter()
3016                .filter(|f| f.class == FindingClass::Drifted)
3017                .collect();
3018            // The stable `anchored` + `derived` anchors on present.rs drift —
3019            // deterministically, from the hash comparison alone.
3020            assert_eq!(
3021                drifted.len(),
3022                2,
3023                "stable-medium mismatch → drifted: {current:?}"
3024            );
3025            assert!(drifted.iter().all(|f| matches!(
3026                &f.target,
3027                FindingTarget::Anchor { artifact, .. } if artifact == "src/present.rs"
3028            )));
3029            // REFUSAL: the unstable anchor on other.rs resolves recheck →
3030            // queued, never drifted.
3031            assert!(
3032                current
3033                    .iter()
3034                    .any(|f| f.class == FindingClass::QueuedForAdjudication
3035                        && matches!(
3036                            &f.target,
3037                            FindingTarget::Anchor { artifact, .. } if artifact == "src/other.rs"
3038                        )),
3039                "unstable medium resolves recheck (queued), not drifted: {current:?}"
3040            );
3041            assert!(
3042                !current.iter().any(|f| f.class == FindingClass::Drifted
3043                    && matches!(
3044                        &f.target,
3045                        FindingTarget::Anchor { artifact, .. } if artifact == "src/other.rs"
3046                    )),
3047                "an unstable hash break must never assert drift"
3048            );
3049        }
3050    }
3051
3052    /// The plain-TREE sibling of the backfill lifecycle: a hash-less `tree`
3053    /// anchor under NO preparation observes the plain per-file digest of its
3054    /// scoped files, backfills once, and thereafter adjudicates
3055    /// deterministically — `drifted` on any scoped-file byte change or a
3056    /// file joining the tree, `resolves` when nothing moved. Before the
3057    /// plain tree digest existed, such an anchor observed no hash at all and
3058    /// re-issued `recheck` (queued-for-adjudication) on every pass, forever
3059    /// — the loop this test seals shut.
3060    #[test]
3061    fn plain_tree_anchor_backfills_then_adjudicates_deterministically() {
3062        let tmp = tempfile::tempdir().unwrap();
3063        let root = tmp.path();
3064        let mem_dir = root.join("mem");
3065        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
3066        std::fs::write(
3067            mem_dir.join(".memstead").join("config.json"),
3068            r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
3069        )
3070        .unwrap();
3071        std::fs::create_dir_all(root.join(".memstead")).unwrap();
3072        std::fs::write(
3073            root.join(".memstead").join("workspace.toml"),
3074            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
3075        )
3076        .unwrap();
3077        crate::FileWorkspaceStore::new()
3078            .save_state(
3079                root,
3080                &Workspace {
3081                    mounts: vec![Mount {
3082                        mem: "engine".to_string(),
3083                        schema: Some("default@1.0.0".parse().unwrap()),
3084                        storage: MountStorage::Folder {
3085                            path: mem_dir.clone(),
3086                        },
3087                        capability: MountCapability::Write,
3088                        lifecycle: MountLifecycle::Eager,
3089                        cross_linkable: false,
3090                        migration_target: None,
3091                    }],
3092                    settings: WorkspaceSettings::default(),
3093                },
3094            )
3095            .unwrap();
3096
3097        let git = |args: &[&str]| {
3098            let out = std::process::Command::new("git")
3099                .args(args)
3100                .current_dir(root)
3101                .env("GIT_AUTHOR_NAME", "t")
3102                .env("GIT_AUTHOR_EMAIL", "t@t")
3103                .env("GIT_COMMITTER_NAME", "t")
3104                .env("GIT_COMMITTER_EMAIL", "t@t")
3105                .output()
3106                .unwrap();
3107            assert!(
3108                out.status.success(),
3109                "git {args:?}: {}",
3110                String::from_utf8_lossy(&out.stderr)
3111            );
3112        };
3113        git(&["init", "-q"]);
3114        std::fs::create_dir_all(root.join("src")).unwrap();
3115        std::fs::write(root.join("src").join("a.rs"), "fn a() {}\n").unwrap();
3116        std::fs::write(root.join("src").join("b.rs"), "fn b() {}\n").unwrap();
3117        git(&["add", "-A"]);
3118        git(&["commit", "-qm", "head-a"]);
3119
3120        std::fs::write(
3121            mem_dir.join("e.md"),
3122            "---\ntype: decision\n---\n\n# E\n\n## Decision\n\nBody.\n",
3123        )
3124        .unwrap();
3125        // One hash-less TREE anchor over `src`, carrying the declaring
3126        // source's NAME — the join is what scopes the enumeration, so a
3127        // tree anchor without a resolvable source stays honest `recheck`.
3128        let mut sidecar = AnchorSidecar::default();
3129        sidecar.set(
3130            "engine--e",
3131            vec![Anchor {
3132                artifact: "src".to_string(),
3133                grain: AnchorGrain::Tree,
3134                class: AnchorProvenanceClass::Anchored,
3135                at_version: None,
3136                hash: None,
3137                hash_stability: AnchorHashStability::Stable,
3138                derived_from: Vec::new(),
3139                binding: None,
3140                source: Some("graph".to_string()),
3141                span_unvalidated: false,
3142                hash_source: None,
3143                last_observed: None,
3144            }],
3145        );
3146        std::fs::write(
3147            mem_dir.join(crate::anchor::ANCHOR_SIDECAR_PATH),
3148            sidecar.to_bytes(),
3149        )
3150        .unwrap();
3151
3152        write_binding(
3153            root,
3154            "engine",
3155            "graph",
3156            &Binding {
3157                version: BINDING_VERSION,
3158                intent: None,
3159                sources: vec![crate::pipeline::Source {
3160                    name: "graph".to_string(),
3161                    medium_type: MediumType::Codebase,
3162                    pointer: String::new(),
3163                    change_detection: Some("git".to_string()),
3164                    scope: vec![PatternEntry {
3165                        path: "src/**/*.rs".to_string(),
3166                        mode: PatternMode::Allow,
3167                    }],
3168                    engagement: None,
3169                    preparation: None,
3170                }],
3171                reference_mems: Vec::new(),
3172                destination_mem: "engine".to_string(),
3173                deny_paths: Vec::new(),
3174                coverage_semantics: None,
3175                rules: None,
3176                prune: None,
3177                operations: Operations {
3178                    build: None,
3179                    sync: None,
3180                    verify: Some(VerifyOperation {
3181                        trigger: IngestTrigger::Manual,
3182                        batch_size: 20,
3183                        adjudication_cap: DEFAULT_ADJUDICATION_CAP,
3184                        full_resync_every: DEFAULT_FULL_RESYNC_EVERY,
3185                    }),
3186                },
3187            },
3188        )
3189        .unwrap();
3190
3191        let configs = load_pipeline_configs(root).unwrap();
3192        let binding = &configs.bindings[0].config;
3193        let resolved = resolve_binding_run("engine/graph", binding).unwrap();
3194
3195        // --- Pass 1: the tree observes the plain digest and backfills. ---
3196        let expected_digest = crate::anchor::prepared_content_hash(
3197            crate::preparation::plain_tree_digest(&[
3198                ("src/a.rs".to_string(), b"fn a() {}\n".to_vec()),
3199                ("src/b.rs".to_string(), b"fn b() {}\n".to_vec()),
3200            ])
3201            .as_bytes(),
3202        );
3203        {
3204            let mut engine = Engine::from_workspace_root(root).unwrap();
3205            let outcome = verify_binding(&engine, root, binding, &resolved).unwrap();
3206            let backfilled: Vec<(&str, &str, &str)> = outcome
3207                .hash_backfill
3208                .iter()
3209                .map(|b| (b.entity.as_str(), b.artifact.as_str(), b.hash.as_str()))
3210                .collect();
3211            assert_eq!(
3212                backfilled,
3213                vec![("engine--e", "src", expected_digest.as_str())],
3214                "the tree anchor observes the plain digest and backfills"
3215            );
3216            assert_eq!(outcome.backlog, 0, "no recheck queue: the digest exists");
3217            let written =
3218                record_anchor_hash_backfill(&mut engine, "engine", &outcome, None).unwrap();
3219            assert_eq!(written, 1);
3220        }
3221
3222        // --- Pass 2: idempotent and clean. ---
3223        {
3224            let engine = Engine::from_workspace_root(root).unwrap();
3225            let outcome = verify_binding(&engine, root, binding, &resolved).unwrap();
3226            assert!(outcome.hash_backfill.is_empty(), "backfill happens once");
3227            assert_eq!(outcome.backlog, 0, "steady state: nothing re-queues");
3228            let store = read_findings_store(root, "engine", "graph")
3229                .unwrap()
3230                .unwrap();
3231            assert!(
3232                store
3233                    .current(&outcome.key)
3234                    .iter()
3235                    .all(|f| !matches!(f.target, FindingTarget::Anchor { .. })),
3236                "unchanged tree resolves clean: {:?}",
3237                store.current(&outcome.key)
3238            );
3239        }
3240
3241        // --- A file JOINS the tree: the digest moves. ---
3242        std::fs::write(root.join("src").join("c.rs"), "fn c() {}\n").unwrap();
3243        git(&["add", "-A"]);
3244        git(&["commit", "-qm", "head-b"]);
3245
3246        // --- Pass 3: deterministic drift, no queued deferral. ---
3247        {
3248            let engine = Engine::from_workspace_root(root).unwrap();
3249            let outcome = verify_binding(&engine, root, binding, &resolved).unwrap();
3250            assert!(outcome.hash_backfill.is_empty());
3251            assert_eq!(outcome.backlog, 0, "drift is asserted, never queued");
3252            let store = read_findings_store(root, "engine", "graph")
3253                .unwrap()
3254                .unwrap();
3255            let current = store.current(&outcome.key);
3256            assert!(
3257                current.iter().any(|f| f.class == FindingClass::Drifted
3258                    && matches!(
3259                        &f.target,
3260                        FindingTarget::Anchor { artifact, .. } if artifact == "src"
3261                    )),
3262                "a joined file drifts the tree anchor deterministically: {current:?}"
3263            );
3264            assert!(
3265                !current
3266                    .iter()
3267                    .any(|f| f.class == FindingClass::QueuedForAdjudication),
3268                "the perpetual recheck loop is sealed: {current:?}"
3269            );
3270        }
3271    }
3272
3273    /// The engine's backfill writer enforces the class guard at the write
3274    /// seam: an `authored` / `informed-by` anchor never gains a hash even if
3275    /// a (buggy or malicious) caller hands one in, and a recorded hash is
3276    /// never overwritten.
3277    #[test]
3278    fn backfill_writer_refuses_non_hash_classes_and_never_overwrites() {
3279        let tmp = tempfile::tempdir().unwrap();
3280        let root = tmp.path();
3281        let mem_dir = root.join("mem");
3282        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
3283        std::fs::write(
3284            mem_dir.join(".memstead").join("config.json"),
3285            r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
3286        )
3287        .unwrap();
3288        std::fs::create_dir_all(root.join(".memstead")).unwrap();
3289        std::fs::write(
3290            root.join(".memstead").join("workspace.toml"),
3291            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
3292        )
3293        .unwrap();
3294        crate::FileWorkspaceStore::new()
3295            .save_state(
3296                root,
3297                &Workspace {
3298                    mounts: vec![Mount {
3299                        mem: "engine".to_string(),
3300                        schema: Some("default@1.0.0".parse().unwrap()),
3301                        storage: MountStorage::Folder {
3302                            path: mem_dir.clone(),
3303                        },
3304                        capability: MountCapability::Write,
3305                        lifecycle: MountLifecycle::Eager,
3306                        cross_linkable: false,
3307                        migration_target: None,
3308                    }],
3309                    settings: WorkspaceSettings::default(),
3310                },
3311            )
3312            .unwrap();
3313
3314        let anchor = |class: AnchorProvenanceClass, hash: Option<&str>| Anchor {
3315            artifact: "src/a.rs".to_string(),
3316            grain: AnchorGrain::File,
3317            class,
3318            at_version: None,
3319            hash: hash.map(str::to_string),
3320            hash_stability: AnchorHashStability::Stable,
3321            derived_from: Vec::new(),
3322            binding: None,
3323            source: None,
3324            span_unvalidated: false,
3325            hash_source: None,
3326            last_observed: None,
3327        };
3328        // The entity the sidecar is keyed to. Written, because it exists:
3329        // a row whose entity does not is DANGLING (consistency-sweep 03/02)
3330        // and leaves the population before any figure counts it.
3331        std::fs::write(
3332            mem_dir.join("e.md"),
3333            "---\ntype: decision\n---\n\n# E\n\n## Decision\n\nBody.\n",
3334        )
3335        .unwrap();
3336        let mut sidecar = AnchorSidecar::default();
3337        sidecar.set(
3338            "engine--e",
3339            vec![
3340                anchor(AnchorProvenanceClass::Authored, None),
3341                anchor(AnchorProvenanceClass::InformedBy, None),
3342                anchor(AnchorProvenanceClass::Anchored, Some("recorded")),
3343            ],
3344        );
3345        std::fs::write(
3346            mem_dir.join(crate::anchor::ANCHOR_SIDECAR_PATH),
3347            sidecar.to_bytes(),
3348        )
3349        .unwrap();
3350
3351        let mut engine = Engine::from_workspace_root(root).unwrap();
3352        let written = engine
3353            .record_anchor_observed_hashes(
3354                "engine",
3355                &[crate::anchor::ObservedArtifactHash {
3356                    entity: "engine--e".to_string(),
3357                    artifact: "src/a.rs".to_string(),
3358                    hash: "observed".to_string(),
3359                }],
3360                None,
3361            )
3362            .unwrap();
3363        assert_eq!(
3364            written, 0,
3365            "non-hash classes refuse the hash; a recorded hash is never overwritten"
3366        );
3367        let sc = AnchorSidecar::from_bytes(
3368            &std::fs::read(mem_dir.join(crate::anchor::ANCHOR_SIDECAR_PATH)).unwrap(),
3369        )
3370        .unwrap();
3371        for a in sc.get("engine--e") {
3372            match a.class {
3373                AnchorProvenanceClass::Anchored => {
3374                    assert_eq!(a.hash.as_deref(), Some("recorded"), "baseline stands")
3375                }
3376                _ => assert!(a.hash.is_none(), "non-hash class stays hash-less: {a:?}"),
3377            }
3378        }
3379    }
3380
3381    /// The completed-run `#verified` writer (backlog 2026-07-11): a verify
3382    /// pass surfaces its observed facet heads on the outcome (the per-facet
3383    /// decomposition of `key.source_head`), and [`record_verified_baseline`]
3384    /// records them as `<binding>/<facet>#verified` through the engine's
3385    /// sync-state writer — durable on disk, visible to the same config read
3386    /// `report`/`status` consume. A failed pass returns
3387    /// `Err` before any caller reaches the writer, so the token never
3388    /// advances on an aborted run.
3389    /// A vanished source directory must refuse verify with the typed
3390    /// `SourceUnreachable` error instead of degrading to an empty
3391    /// enumeration: pre-fix, the missing tree produced an empty stat map
3392    /// whose aggregate (the digest of nothing) completed the run and let
3393    /// the caller overwrite a genuine `#verified` baseline with fake
3394    /// state. The engine mem itself stays loadable — only the binding's
3395    /// source is gone.
3396    #[test]
3397    fn verify_refuses_unreachable_source_with_typed_error() {
3398        let tmp = tempfile::tempdir().unwrap();
3399        let root = tmp.path();
3400        let mem_dir = root.join("mem");
3401        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
3402        std::fs::write(
3403            mem_dir.join(".memstead").join("config.json"),
3404            r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
3405        )
3406        .unwrap();
3407        std::fs::create_dir_all(root.join(".memstead")).unwrap();
3408        std::fs::write(
3409            root.join(".memstead").join("workspace.toml"),
3410            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
3411        )
3412        .unwrap();
3413        let mount = Mount {
3414            mem: "engine".to_string(),
3415            schema: Some("default@1.0.0".parse().unwrap()),
3416            storage: MountStorage::Folder {
3417                path: mem_dir.clone(),
3418            },
3419            capability: MountCapability::Write,
3420            lifecycle: MountLifecycle::Eager,
3421            cross_linkable: false,
3422            migration_target: None,
3423        };
3424        crate::FileWorkspaceStore::new()
3425            .save_state(
3426                root,
3427                &Workspace {
3428                    mounts: vec![mount],
3429                    settings: WorkspaceSettings::default(),
3430                },
3431            )
3432            .unwrap();
3433
3434        // The medium points at a subdirectory that does NOT exist — the
3435        // vanished-source case (`git` declared, so pre-fix the strategy
3436        // layer silently degraded instead of refusing).
3437        write_binding(
3438            root,
3439            "engine",
3440            "gone",
3441            &Binding {
3442                version: BINDING_VERSION,
3443                intent: None,
3444                sources: vec![crate::pipeline::Source {
3445                    name: "gone".to_string(),
3446                    medium_type: MediumType::Codebase,
3447                    pointer: "vanished-src".to_string(),
3448                    change_detection: Some("git".to_string()),
3449                    scope: vec![PatternEntry {
3450                        path: "**/*.rs".to_string(),
3451                        mode: PatternMode::Allow,
3452                    }],
3453                    engagement: None,
3454                    preparation: None,
3455                }],
3456                reference_mems: Vec::new(),
3457                destination_mem: "engine".to_string(),
3458                deny_paths: Vec::new(),
3459                coverage_semantics: None,
3460                rules: None,
3461                prune: None,
3462                operations: Operations {
3463                    build: None,
3464                    sync: None,
3465                    verify: Some(VerifyOperation {
3466                        trigger: IngestTrigger::Manual,
3467                        batch_size: 20,
3468                        adjudication_cap: DEFAULT_ADJUDICATION_CAP,
3469                        full_resync_every: DEFAULT_FULL_RESYNC_EVERY,
3470                    }),
3471                },
3472            },
3473        )
3474        .unwrap();
3475
3476        let engine = Engine::from_workspace_root(root).unwrap();
3477        let configs = load_pipeline_configs(root).unwrap();
3478        let binding = &configs.bindings[0].config;
3479        let resolved = resolve_binding_run("engine/gone", binding).unwrap();
3480
3481        match verify_binding(&engine, root, binding, &resolved) {
3482            Err(FindingsError::SourceUnreachable { source_name, path }) => {
3483                assert_eq!(source_name, "gone");
3484                assert!(
3485                    path.ends_with("vanished-src"),
3486                    "refusal must name the resolved missing path, got `{path}`",
3487                );
3488            }
3489            other => panic!("expected SourceUnreachable refusal, got {other:?}"),
3490        }
3491
3492        // Nothing was observed → no `#verified` token exists (the caller
3493        // never reaches its baseline write on an Err).
3494        assert!(
3495            !engine
3496                .mem_config_for("engine")
3497                .unwrap()
3498                .sync_state
3499                .keys()
3500                .any(|k| k.ends_with("#verified")),
3501            "a refused verify must not leave any #verified token",
3502        );
3503    }
3504
3505    #[test]
3506    fn completed_verify_records_the_verified_baseline() {
3507        let tmp = tempfile::tempdir().unwrap();
3508        let root = tmp.path();
3509        let mem_dir = root.join("mem");
3510        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
3511        std::fs::write(
3512            mem_dir.join(".memstead").join("config.json"),
3513            r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
3514        )
3515        .unwrap();
3516        std::fs::create_dir_all(root.join(".memstead")).unwrap();
3517        std::fs::write(
3518            root.join(".memstead").join("workspace.toml"),
3519            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
3520        )
3521        .unwrap();
3522        let mount = Mount {
3523            mem: "engine".to_string(),
3524            schema: Some("default@1.0.0".parse().unwrap()),
3525            storage: MountStorage::Folder {
3526                path: mem_dir.clone(),
3527            },
3528            capability: MountCapability::Write,
3529            lifecycle: MountLifecycle::Eager,
3530            cross_linkable: false,
3531            migration_target: None,
3532        };
3533        crate::FileWorkspaceStore::new()
3534            .save_state(
3535                root,
3536                &Workspace {
3537                    mounts: vec![mount],
3538                    settings: WorkspaceSettings::default(),
3539                },
3540            )
3541            .unwrap();
3542        let out = std::process::Command::new("git")
3543            .args(["init", "-q"])
3544            .current_dir(root)
3545            .output()
3546            .unwrap();
3547        assert!(out.status.success());
3548
3549        write_binding(
3550            root,
3551            "engine",
3552            "graph",
3553            &Binding {
3554                version: BINDING_VERSION,
3555                intent: None,
3556                sources: vec![crate::pipeline::Source {
3557                    name: "graph".to_string(),
3558                    medium_type: MediumType::Codebase,
3559                    pointer: String::new(),
3560                    change_detection: Some("git".to_string()),
3561                    scope: vec![PatternEntry {
3562                        path: "src/**/*.rs".to_string(),
3563                        mode: PatternMode::Allow,
3564                    }],
3565                    engagement: None,
3566                    preparation: None,
3567                }],
3568                reference_mems: Vec::new(),
3569                destination_mem: "engine".to_string(),
3570                deny_paths: Vec::new(),
3571                coverage_semantics: None,
3572                rules: None,
3573                prune: None,
3574                operations: Operations {
3575                    build: Some(BuildOperation {
3576                        mode: BuildMode::Discovery,
3577                        trigger: IngestTrigger::Loop,
3578                        batch_size: 20,
3579                        post_actions: None,
3580                    }),
3581                    sync: None,
3582                    verify: Some(VerifyOperation {
3583                        trigger: IngestTrigger::Manual,
3584                        batch_size: 20,
3585                        adjudication_cap: DEFAULT_ADJUDICATION_CAP,
3586                        full_resync_every: DEFAULT_FULL_RESYNC_EVERY,
3587                    }),
3588                },
3589            },
3590        )
3591        .unwrap();
3592
3593        let mut engine = Engine::from_workspace_root(root).unwrap();
3594        // A recorded `#synced` baseline is this facet's current head (the git
3595        // work tree has no commits, so the cursor contributes no newer token).
3596        engine
3597            .set_mem_sync_state("engine", "engine/graph/graph#synced", "deadbeef", None)
3598            .unwrap();
3599
3600        let configs = load_pipeline_configs(root).unwrap();
3601        let binding = &configs.bindings[0].config;
3602        let resolved = resolve_binding_run("engine/graph", binding).unwrap();
3603
3604        let outcome = verify_binding(&engine, root, binding, &resolved).unwrap();
3605        // The outcome decomposes its own key: joined facet heads == source_head.
3606        assert_eq!(
3607            outcome.facet_heads.get("graph").map(String::as_str),
3608            Some("deadbeef")
3609        );
3610        assert_eq!(outcome.key.source_head, "graph=deadbeef");
3611        assert_eq!(
3612            join_facet_heads(&outcome.facet_heads),
3613            outcome.key.source_head
3614        );
3615
3616        // No `#verified` token exists before the writer runs.
3617        assert!(
3618            !engine
3619                .mem_config_for("engine")
3620                .unwrap()
3621                .sync_state
3622                .contains_key("engine/graph/graph#verified")
3623        );
3624
3625        let written = record_verified_baseline(&mut engine, "engine", &outcome, None).unwrap();
3626        assert_eq!(written, vec!["engine/graph/graph#verified".to_string()]);
3627
3628        // Visible to the engine's config read (the app's sync_state source)…
3629        assert_eq!(
3630            engine
3631                .mem_config_for("engine")
3632                .unwrap()
3633                .sync_state
3634                .get("engine/graph/graph#verified")
3635                .map(String::as_str),
3636            Some("deadbeef")
3637        );
3638        // …and durable on disk (what a fresh CLI process reads).
3639        let disk: serde_json::Value = serde_json::from_slice(
3640            &std::fs::read(mem_dir.join(".memstead").join("config.json")).unwrap(),
3641        )
3642        .unwrap();
3643        assert_eq!(
3644            disk["syncState"]["engine/graph/graph#verified"],
3645            serde_json::json!("deadbeef")
3646        );
3647    }
3648
3649    // ---- D1: per-run adjudication cap -----------------------------------
3650
3651    /// D1 — the per-run cap queues the remainder. A rotation window covering
3652    /// only a subset of drift candidates adjudicates the in-window ones and
3653    /// QUEUES every out-of-window candidate as `queued-for-adjudication` (the
3654    /// tier-3 backlog). Uncapped (`window = None`) adjudicates every candidate.
3655    #[test]
3656    fn adjudication_cap_queues_the_remainder() {
3657        let k = key("h", "s");
3658        let mk = |art: &str| {
3659            let mut a = anchor(AnchorProvenanceClass::Anchored);
3660            a.artifact = art.to_string();
3661            a
3662        };
3663        let candidates = vec![
3664            (
3665                "engine--a".to_string(),
3666                mk("src/a.rs"),
3667                AnchorState::Drifted,
3668            ),
3669            (
3670                "engine--b".to_string(),
3671                mk("src/b.rs"),
3672                AnchorState::Drifted,
3673            ),
3674            (
3675                "engine--c".to_string(),
3676                mk("src/c.rs"),
3677                AnchorState::Drifted,
3678            ),
3679        ];
3680        // A cap-1 window selects only src/a.rs.
3681        let window: BTreeSet<String> = [candidate_key("engine--a", &mk("src/a.rs"))]
3682            .into_iter()
3683            .collect();
3684        let out = adjudicate_candidates(&k, "f", &candidates, Some(&window), "1");
3685        let drifted = out
3686            .iter()
3687            .filter(|f| f.class == FindingClass::Drifted)
3688            .count();
3689        let queued = out
3690            .iter()
3691            .filter(|f| f.class == FindingClass::QueuedForAdjudication)
3692            .count();
3693        assert_eq!(drifted, 1, "only the in-window candidate is adjudicated");
3694        assert_eq!(queued, 2, "the remainder is queued as the tier-3 backlog");
3695        // A queued remainder finding carries the queued detail, not a drift claim.
3696        assert!(
3697            out.iter()
3698                .any(|f| f.class == FindingClass::QueuedForAdjudication
3699                    && f.detail.contains("cap reached")),
3700            "capped remainder states it was deferred by the cap"
3701        );
3702
3703        // Uncapped: every candidate adjudicated, none queued.
3704        let uncapped = adjudicate_candidates(&k, "f", &candidates, None, "1");
3705        assert_eq!(
3706            uncapped
3707                .iter()
3708                .filter(|f| f.class == FindingClass::Drifted)
3709                .count(),
3710            3,
3711            "uncapped adjudicates every candidate"
3712        );
3713        assert_eq!(
3714            uncapped
3715                .iter()
3716                .filter(|f| f.class == FindingClass::QueuedForAdjudication)
3717                .count(),
3718            0
3719        );
3720    }
3721
3722    // ---- D3: full_resync scheduling + non-enumerable refusal ------------
3723
3724    /// D3 — `schedule_full_resync`: disabled at cadence 0; not-due off-cadence
3725    /// (with a countdown); due on-cadence for an enumerable facet (walked, no
3726    /// refusal).
3727    #[test]
3728    fn full_resync_schedule_disabled_notdue_due() {
3729        let codebase = FacetEnumerability {
3730            facet: "src".to_string(),
3731            medium_type: "codebase".to_string(),
3732            enumerable: true,
3733        };
3734        assert_eq!(
3735            schedule_full_resync(0, 5, std::slice::from_ref(&codebase)),
3736            FullResyncDecision::Disabled
3737        );
3738        match schedule_full_resync(3, 2, std::slice::from_ref(&codebase)) {
3739            FullResyncDecision::NotDue { runs_until_due, .. } => assert_eq!(runs_until_due, 1),
3740            other => panic!("expected NotDue, got {other:?}"),
3741        }
3742        match schedule_full_resync(3, 3, std::slice::from_ref(&codebase)) {
3743            FullResyncDecision::Due {
3744                walked_facets,
3745                refused,
3746                ..
3747            } => {
3748                assert_eq!(walked_facets, vec!["src".to_string()]);
3749                assert!(refused.is_empty(), "enumerable facet is not refused");
3750            }
3751            other => panic!("expected Due, got {other:?}"),
3752        }
3753    }
3754
3755    /// D3 REFUSAL — a scheduled full walk over a NON-enumerable medium refuses
3756    /// with a typed signal: it never claims coverage and is never a silent skip.
3757    #[test]
3758    fn full_resync_refuses_non_enumerable_medium() {
3759        let web = FacetEnumerability {
3760            facet: "manual".to_string(),
3761            medium_type: "web".to_string(),
3762            enumerable: false,
3763        };
3764        let d = schedule_full_resync(1, 1, &[web]);
3765        assert!(
3766            d.is_full_walk(),
3767            "a due sweep is a full walk even when refused"
3768        );
3769        match d {
3770            FullResyncDecision::Due {
3771                walked_facets,
3772                refused,
3773                ..
3774            } => {
3775                assert!(walked_facets.is_empty(), "nothing enumerable to walk");
3776                assert_eq!(refused.len(), 1, "the non-enumerable facet is refused");
3777                assert_eq!(refused[0].facet, "manual");
3778                assert_eq!(refused[0].medium_type, "web");
3779                assert!(
3780                    refused[0].reason.contains("non-enumerable"),
3781                    "the refusal is typed and states why"
3782                );
3783            }
3784            other => panic!("expected Due with a refusal, got {other:?}"),
3785        }
3786    }
3787
3788    /// D3 — a scheduled full walk fires the WHOLE-source enumeration this run:
3789    /// with `full_resync_every = 1` (due every run) and a sample `batch_size` of
3790    /// 1, all three uncovered source files are flagged, not just one — the full
3791    /// walk overrides the bounded rotating sample for an enumerable medium.
3792    #[test]
3793    fn full_resync_full_walk_covers_whole_source() {
3794        let tmp = tempfile::tempdir().unwrap();
3795        let root = tmp.path();
3796        let mem_dir = root.join("mem");
3797        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
3798        std::fs::write(
3799            mem_dir.join(".memstead").join("config.json"),
3800            r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
3801        )
3802        .unwrap();
3803        std::fs::create_dir_all(root.join(".memstead")).unwrap();
3804        std::fs::write(
3805            root.join(".memstead").join("workspace.toml"),
3806            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
3807        )
3808        .unwrap();
3809        let mount = Mount {
3810            mem: "engine".to_string(),
3811            schema: Some("default@1.0.0".parse().unwrap()),
3812            storage: MountStorage::Folder {
3813                path: mem_dir.clone(),
3814            },
3815            capability: MountCapability::Write,
3816            lifecycle: MountLifecycle::Eager,
3817            cross_linkable: false,
3818            migration_target: None,
3819        };
3820        crate::FileWorkspaceStore::new()
3821            .save_state(
3822                root,
3823                &Workspace {
3824                    mounts: vec![mount],
3825                    settings: WorkspaceSettings::default(),
3826                },
3827            )
3828            .unwrap();
3829        let out = std::process::Command::new("git")
3830            .args(["init", "-q"])
3831            .current_dir(root)
3832            .output()
3833            .unwrap();
3834        assert!(out.status.success());
3835        std::fs::create_dir_all(root.join("src")).unwrap();
3836        for f in ["a.rs", "b.rs", "c.rs"] {
3837            std::fs::write(root.join("src").join(f), "fn x() {}\n").unwrap();
3838        }
3839
3840        write_binding(
3841            root,
3842            "engine",
3843            "graph",
3844            &Binding {
3845                version: BINDING_VERSION,
3846                intent: None,
3847                sources: vec![crate::pipeline::Source {
3848                    name: "graph".to_string(),
3849                    medium_type: MediumType::Codebase,
3850                    pointer: String::new(),
3851                    change_detection: Some("git".to_string()),
3852                    scope: vec![PatternEntry {
3853                        path: "src/**/*.rs".to_string(),
3854                        mode: PatternMode::Allow,
3855                    }],
3856                    engagement: None,
3857                    preparation: None,
3858                }],
3859                reference_mems: Vec::new(),
3860                destination_mem: "engine".to_string(),
3861                deny_paths: Vec::new(),
3862                coverage_semantics: None,
3863                rules: None,
3864                prune: None,
3865                operations: Operations {
3866                    build: Some(BuildOperation {
3867                        mode: BuildMode::Discovery,
3868                        trigger: IngestTrigger::Loop,
3869                        batch_size: 20,
3870                        post_actions: None,
3871                    }),
3872                    sync: None,
3873                    verify: Some(VerifyOperation {
3874                        trigger: IngestTrigger::Manual,
3875                        batch_size: 1, // a tiny rotating sample …
3876                        adjudication_cap: DEFAULT_ADJUDICATION_CAP,
3877                        full_resync_every: 1, // … but a full walk fires EVERY run
3878                    }),
3879                },
3880            },
3881        )
3882        .unwrap();
3883
3884        let engine = Engine::from_workspace_root(root).unwrap();
3885        let configs = load_pipeline_configs(root).unwrap();
3886        let binding = &configs.bindings[0].config;
3887        let resolved = resolve_binding_run("engine/graph", binding).unwrap();
3888
3889        let outcome = verify_binding(&engine, root, binding, &resolved).unwrap();
3890        // The full walk is due on run 1 and covers the enumerable facet.
3891        match &outcome.full_resync {
3892            FullResyncDecision::Due {
3893                walked_facets,
3894                refused,
3895                run_count,
3896                ..
3897            } => {
3898                assert_eq!(*run_count, 1);
3899                assert_eq!(walked_facets, &vec!["graph".to_string()]);
3900                assert!(refused.is_empty());
3901            }
3902            other => panic!("expected a due full walk, got {other:?}"),
3903        }
3904        // All three uncovered files flagged despite the batch_size-1 sample.
3905        let store = read_findings_store(root, "engine", "graph")
3906            .unwrap()
3907            .unwrap();
3908        let uncovered = store
3909            .current(&outcome.key)
3910            .iter()
3911            .filter(|f| f.class == FindingClass::Uncovered)
3912            .count();
3913        assert_eq!(
3914            uncovered, 3,
3915            "the scheduled full walk covers the whole source, not a batch of one"
3916        );
3917    }
3918
3919    /// A SCHEDULED full walk consults partiality the way `--full` does: a facet
3920    /// whose enumeration is known-incomplete (here: a scope pattern still in
3921    /// the retired workspace-relative dialect) is demoted into the typed
3922    /// refusal list instead of being walked and announced as full. Without the
3923    /// demotion one report carries both "full-enumeration walk fired" and
3924    /// "`S(D)` is partial, no percentage".
3925    #[test]
3926    fn scheduled_full_walk_demotes_partial_facet_to_refusal() {
3927        let tmp = tempfile::tempdir().unwrap();
3928        let root = tmp.path();
3929        let mem_dir = root.join("mem");
3930        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
3931        std::fs::write(
3932            mem_dir.join(".memstead").join("config.json"),
3933            r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
3934        )
3935        .unwrap();
3936        std::fs::create_dir_all(root.join(".memstead")).unwrap();
3937        std::fs::write(
3938            root.join(".memstead").join("workspace.toml"),
3939            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
3940        )
3941        .unwrap();
3942        let mount = Mount {
3943            mem: "engine".to_string(),
3944            schema: Some("default@1.0.0".parse().unwrap()),
3945            storage: MountStorage::Folder {
3946                path: mem_dir.clone(),
3947            },
3948            capability: MountCapability::Write,
3949            lifecycle: MountLifecycle::Eager,
3950            cross_linkable: false,
3951            migration_target: None,
3952        };
3953        crate::FileWorkspaceStore::new()
3954            .save_state(
3955                root,
3956                &Workspace {
3957                    mounts: vec![mount],
3958                    settings: WorkspaceSettings::default(),
3959                },
3960            )
3961            .unwrap();
3962        let out = std::process::Command::new("git")
3963            .args(["init", "-q"])
3964            .current_dir(root)
3965            .output()
3966            .unwrap();
3967        assert!(out.status.success());
3968        std::fs::create_dir_all(root.join("src")).unwrap();
3969        std::fs::write(root.join("src").join("a.rs"), "fn x() {}\n").unwrap();
3970
3971        write_binding(
3972            root,
3973            "engine",
3974            "graph",
3975            &Binding {
3976                version: BINDING_VERSION,
3977                intent: None,
3978                sources: vec![crate::pipeline::Source {
3979                    name: "graph".to_string(),
3980                    medium_type: MediumType::Codebase,
3981                    pointer: "src".to_string(),
3982                    change_detection: Some("git".to_string()),
3983                    // A MIXED scope: the prefix-free pattern still enumerates,
3984                    // so the facet is non-empty and looks like a population —
3985                    // while the retired-dialect pattern's share is absent.
3986                    scope: vec![
3987                        PatternEntry {
3988                            path: "**/*.rs".to_string(),
3989                            mode: PatternMode::Allow,
3990                        },
3991                        PatternEntry {
3992                            path: "src/nested.rs".to_string(),
3993                            mode: PatternMode::Allow,
3994                        },
3995                    ],
3996                    engagement: None,
3997                    preparation: None,
3998                }],
3999                reference_mems: Vec::new(),
4000                destination_mem: "engine".to_string(),
4001                deny_paths: Vec::new(),
4002                coverage_semantics: None,
4003                rules: None,
4004                prune: None,
4005                operations: Operations {
4006                    build: Some(BuildOperation {
4007                        mode: BuildMode::Discovery,
4008                        trigger: IngestTrigger::Loop,
4009                        batch_size: 20,
4010                        post_actions: None,
4011                    }),
4012                    sync: None,
4013                    verify: Some(VerifyOperation {
4014                        trigger: IngestTrigger::Manual,
4015                        batch_size: 1,
4016                        adjudication_cap: DEFAULT_ADJUDICATION_CAP,
4017                        full_resync_every: 1, // a full walk fires EVERY run …
4018                    }),
4019                },
4020            },
4021        )
4022        .unwrap();
4023
4024        let engine = Engine::from_workspace_root(root).unwrap();
4025        let configs = load_pipeline_configs(root).unwrap();
4026        let binding = &configs.bindings[0].config;
4027        let resolved = resolve_binding_run("engine/graph", binding).unwrap();
4028
4029        let outcome = verify_binding(&engine, root, binding, &resolved).unwrap();
4030        match &outcome.full_resync {
4031            FullResyncDecision::Due {
4032                walked_facets,
4033                refused,
4034                ..
4035            } => {
4036                assert!(
4037                    walked_facets.is_empty(),
4038                    "a partial facet must not be announced as walked-in-full: {walked_facets:?}"
4039                );
4040                assert_eq!(refused.len(), 1, "the partial facet is refused, typed");
4041                assert_eq!(refused[0].facet, "graph");
4042                assert!(
4043                    refused[0].reason.contains("incomplete"),
4044                    "the refusal names the partiality: {}",
4045                    refused[0].reason
4046                );
4047            }
4048            other => panic!("expected a due full walk decision, got {other:?}"),
4049        }
4050    }
4051
4052    // ---- explicit full measurement (`verify_binding_full`) ----------------
4053
4054    /// An explicit full measurement walks the whole `S(D)` and treats the
4055    /// adjudication cap as unlimited — every drift candidate adjudicates and
4056    /// every uncovered artifact is flagged in ONE run, with nothing deferred
4057    /// to a cap or a rotating sample, and the decision reports `Forced`.
4058    /// REFUSAL half (byte-compat): a no-flag run over the same binding keeps
4059    /// today's capped/sampled behavior exactly — cap-1 adjudicates one
4060    /// candidate and queues the remainder with the cap-reached detail, and
4061    /// the batch-1 sample flags at most one uncovered file.
4062    #[test]
4063    fn full_verify_uncaps_adjudication_and_walks_whole_source() {
4064        let tmp = tempfile::tempdir().unwrap();
4065        let root = tmp.path();
4066        let mem_dir = root.join("mem");
4067        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
4068        std::fs::write(
4069            mem_dir.join(".memstead").join("config.json"),
4070            r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
4071        )
4072        .unwrap();
4073        std::fs::create_dir_all(root.join(".memstead")).unwrap();
4074        std::fs::write(
4075            root.join(".memstead").join("workspace.toml"),
4076            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
4077        )
4078        .unwrap();
4079        crate::FileWorkspaceStore::new()
4080            .save_state(
4081                root,
4082                &Workspace {
4083                    mounts: vec![Mount {
4084                        mem: "engine".to_string(),
4085                        schema: Some("default@1.0.0".parse().unwrap()),
4086                        storage: MountStorage::Folder {
4087                            path: mem_dir.clone(),
4088                        },
4089                        capability: MountCapability::Write,
4090                        lifecycle: MountLifecycle::Eager,
4091                        cross_linkable: false,
4092                        migration_target: None,
4093                    }],
4094                    settings: WorkspaceSettings::default(),
4095                },
4096            )
4097            .unwrap();
4098        let out = std::process::Command::new("git")
4099            .args(["init", "-q"])
4100            .current_dir(root)
4101            .output()
4102            .unwrap();
4103        assert!(out.status.success());
4104        std::fs::create_dir_all(root.join("src")).unwrap();
4105        // Three anchored (drift-candidate) files + three uncovered files.
4106        for f in ["a.rs", "b.rs", "c.rs", "d.rs", "e.rs", "f.rs"] {
4107            std::fs::write(root.join("src").join(f), "fn x() {}\n").unwrap();
4108        }
4109        let mk = |art: &str| Anchor {
4110            artifact: art.to_string(),
4111            grain: AnchorGrain::File,
4112            class: AnchorProvenanceClass::Anchored,
4113            at_version: None,
4114            hash: Some("stale-recorded-hash".to_string()), // mismatches → drift candidate
4115            hash_stability: AnchorHashStability::Stable,
4116            derived_from: Vec::new(),
4117            binding: None,
4118            source: None,
4119            span_unvalidated: false,
4120            hash_source: None,
4121            last_observed: None,
4122        };
4123        // The entity the sidecar is keyed to. Written, because it exists:
4124        // a row whose entity does not is DANGLING (consistency-sweep 03/02)
4125        // and leaves the population before any figure counts it.
4126        std::fs::write(
4127            mem_dir.join("e.md"),
4128            "---\ntype: decision\n---\n\n# E\n\n## Decision\n\nBody.\n",
4129        )
4130        .unwrap();
4131        let mut sidecar = AnchorSidecar::default();
4132        sidecar.set(
4133            "engine--e",
4134            vec![mk("src/a.rs"), mk("src/b.rs"), mk("src/c.rs")],
4135        );
4136        std::fs::write(
4137            mem_dir.join(crate::anchor::ANCHOR_SIDECAR_PATH),
4138            sidecar.to_bytes(),
4139        )
4140        .unwrap();
4141
4142        write_binding(
4143            root,
4144            "engine",
4145            "graph",
4146            &Binding {
4147                version: BINDING_VERSION,
4148                intent: None,
4149                sources: vec![crate::pipeline::Source {
4150                    name: "graph".to_string(),
4151                    medium_type: MediumType::Codebase,
4152                    pointer: String::new(),
4153                    change_detection: Some("git".to_string()),
4154                    scope: vec![PatternEntry {
4155                        path: "src/**/*.rs".to_string(),
4156                        mode: PatternMode::Allow,
4157                    }],
4158                    engagement: None,
4159                    preparation: None,
4160                }],
4161                reference_mems: Vec::new(),
4162                destination_mem: "engine".to_string(),
4163                deny_paths: Vec::new(),
4164                coverage_semantics: None,
4165                rules: None,
4166                prune: None,
4167                operations: Operations {
4168                    build: None,
4169                    sync: None,
4170                    verify: Some(VerifyOperation {
4171                        trigger: IngestTrigger::Manual,
4172                        batch_size: 1,        // tiny rotating sample …
4173                        adjudication_cap: 1,  // … and a tiny cap
4174                        full_resync_every: 0, // scheduled walks disabled
4175                    }),
4176                },
4177            },
4178        )
4179        .unwrap();
4180
4181        let engine = Engine::from_workspace_root(root).unwrap();
4182        let configs = load_pipeline_configs(root).unwrap();
4183        let binding = &configs.bindings[0].config;
4184        let resolved = resolve_binding_run("engine/graph", binding).unwrap();
4185
4186        // Byte-compat leg — the no-flag run keeps today's capped/sampled
4187        // economics: one candidate adjudicated, two queued by the cap, at
4188        // most one uncovered file from the batch-1 sample, no full walk.
4189        let sampled = verify_binding(&engine, root, binding, &resolved).unwrap();
4190        assert_eq!(sampled.full_resync, FullResyncDecision::Disabled);
4191        let store = read_findings_store(root, "engine", "graph")
4192            .unwrap()
4193            .unwrap();
4194        let current = store.current(&sampled.key);
4195        let count = |c: FindingClass| current.iter().filter(|f| f.class == c).count();
4196        assert_eq!(count(FindingClass::Drifted), 1, "cap-1 adjudicates one");
4197        assert_eq!(
4198            count(FindingClass::QueuedForAdjudication),
4199            2,
4200            "the remainder queues"
4201        );
4202        assert!(
4203            current
4204                .iter()
4205                .any(|f| f.class == FindingClass::QueuedForAdjudication
4206                    && f.detail.contains("cap reached")),
4207            "the sampled deferral states the cap"
4208        );
4209        assert!(
4210            count(FindingClass::Uncovered) <= 1,
4211            "batch-1 sample looks at one artifact"
4212        );
4213
4214        // Full measurement: everything adjudicates, everything is walked,
4215        // nothing deferred — no sampling/truncation residue anywhere.
4216        let full = verify_binding_full(&engine, root, binding, &resolved).unwrap();
4217        assert_eq!(
4218            full.full_resync,
4219            FullResyncDecision::Forced {
4220                walked_facets: vec!["graph".to_string()]
4221            }
4222        );
4223        assert_eq!(full.backlog, 0, "cap treated as unlimited — no backlog");
4224        let store = read_findings_store(root, "engine", "graph")
4225            .unwrap()
4226            .unwrap();
4227        let current = store.current(&full.key);
4228        let count = |c: FindingClass| current.iter().filter(|f| f.class == c).count();
4229        assert_eq!(
4230            count(FindingClass::Drifted),
4231            3,
4232            "every candidate adjudicated"
4233        );
4234        assert_eq!(count(FindingClass::QueuedForAdjudication), 0);
4235        assert_eq!(
4236            count(FindingClass::Uncovered),
4237            3,
4238            "the whole S(D) walked — every uncovered file flagged"
4239        );
4240        assert!(
4241            current.iter().all(|f| !f.detail.contains("cap reached")),
4242            "a full run's findings carry no cap-deferral caveat"
4243        );
4244    }
4245
4246    /// REFUSAL — an explicit full measurement over a non-enumerable medium
4247    /// refuses the whole run with the typed capability error (nothing
4248    /// observed, nothing recorded — never a fabricated-complete report),
4249    /// while the no-flag sampled verify over the same binding still runs.
4250    #[test]
4251    fn full_verify_refuses_non_enumerable_medium_typed() {
4252        let tmp = tempfile::tempdir().unwrap();
4253        let root = tmp.path();
4254        let mem_dir = root.join("mem");
4255        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
4256        std::fs::write(
4257            mem_dir.join(".memstead").join("config.json"),
4258            r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
4259        )
4260        .unwrap();
4261        std::fs::create_dir_all(root.join(".memstead")).unwrap();
4262        std::fs::write(
4263            root.join(".memstead").join("workspace.toml"),
4264            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
4265        )
4266        .unwrap();
4267        crate::FileWorkspaceStore::new()
4268            .save_state(
4269                root,
4270                &Workspace {
4271                    mounts: vec![Mount {
4272                        mem: "engine".to_string(),
4273                        schema: Some("default@1.0.0".parse().unwrap()),
4274                        storage: MountStorage::Folder {
4275                            path: mem_dir.clone(),
4276                        },
4277                        capability: MountCapability::Write,
4278                        lifecycle: MountLifecycle::Eager,
4279                        cross_linkable: false,
4280                        migration_target: None,
4281                    }],
4282                    settings: WorkspaceSettings::default(),
4283                },
4284            )
4285            .unwrap();
4286
4287        // A web medium — the capability matrix marks it non-enumerable.
4288        write_binding(
4289            root,
4290            "engine",
4291            "manual",
4292            &Binding {
4293                version: BINDING_VERSION,
4294                intent: None,
4295                sources: vec![crate::pipeline::Source {
4296                    name: "manual".to_string(),
4297                    medium_type: MediumType::Web,
4298                    pointer: "https://example.com/docs".to_string(),
4299                    change_detection: None,
4300                    scope: Vec::new(),
4301                    engagement: None,
4302                    preparation: None,
4303                }],
4304                reference_mems: Vec::new(),
4305                destination_mem: "engine".to_string(),
4306                deny_paths: Vec::new(),
4307                coverage_semantics: Some(CoverageSemantics::Curated),
4308                rules: None,
4309                prune: None,
4310                operations: Operations {
4311                    build: None,
4312                    sync: None,
4313                    verify: Some(VerifyOperation {
4314                        trigger: IngestTrigger::Manual,
4315                        batch_size: 20,
4316                        adjudication_cap: DEFAULT_ADJUDICATION_CAP,
4317                        full_resync_every: DEFAULT_FULL_RESYNC_EVERY,
4318                    }),
4319                },
4320            },
4321        )
4322        .unwrap();
4323
4324        let engine = Engine::from_workspace_root(root).unwrap();
4325        let configs = load_pipeline_configs(root).unwrap();
4326        let binding = &configs.bindings[0].config;
4327        let resolved = resolve_binding_run("engine/manual", binding).unwrap();
4328
4329        // Full: typed refusal naming the facet and medium type; nothing recorded.
4330        let err = verify_binding_full(&engine, root, binding, &resolved).unwrap_err();
4331        match &err {
4332            FindingsError::FullWalkNonEnumerable(refusal) => {
4333                assert_eq!(refusal.facet, "manual");
4334                assert_eq!(refusal.medium_type, "web");
4335                assert!(refusal.reason.contains("non-enumerable"));
4336            }
4337            other => panic!("expected FullWalkNonEnumerable, got {other:?}"),
4338        }
4339        assert!(
4340            read_findings_store(root, "engine", "manual")
4341                .unwrap()
4342                .is_none(),
4343            "a refused full run records nothing"
4344        );
4345
4346        // No-flag: the sampled verify over the same binding still runs.
4347        let sampled = verify_binding(&engine, root, binding, &resolved).unwrap();
4348        assert_eq!(sampled.binding, "engine/manual");
4349    }
4350
4351    fn sourceless_binding() -> crate::binding::Binding {
4352        crate::binding::Binding {
4353            version: crate::binding::BINDING_VERSION,
4354            intent: None,
4355            sources: Vec::new(),
4356            reference_mems: Vec::new(),
4357            destination_mem: "m".to_string(),
4358            deny_paths: Vec::new(),
4359            coverage_semantics: None,
4360            rules: None,
4361            prune: None,
4362            operations: crate::binding::Operations {
4363                build: None,
4364                sync: None,
4365                verify: None,
4366            },
4367        }
4368    }
4369
4370    fn uncovered(key: &FindingKey, artifact: &str) -> Finding {
4371        Finding {
4372            key: key.clone(),
4373            facet: "src".to_string(),
4374            target: FindingTarget::Artifact {
4375                artifact: artifact.to_string(),
4376            },
4377            class: FindingClass::Uncovered,
4378            detail: "source artifact in scope has no anchor in the destination mem".to_string(),
4379            created_at: "1".to_string(),
4380        }
4381    }
4382
4383    /// An exclusion `projection exclude` just accepted takes effect on the
4384    /// VERY NEXT brief read, with no verify pass in between: the stored batch
4385    /// still carries the uncovered finding, and `current_findings` drops it
4386    /// against the durable exclusion ledger. Non-uncovered findings and
4387    /// uncovered artifacts the ledger does not name are untouched.
4388    #[test]
4389    fn current_findings_drops_ledger_excluded_uncovered_without_a_verify() {
4390        let ws = tempfile::tempdir().unwrap();
4391        let root = ws.path();
4392        let engine = crate::engine::Engine::from_mounts(Vec::new()).unwrap();
4393        let binding = sourceless_binding();
4394        let resolved = resolve_binding_run("m/s", &binding).unwrap();
4395
4396        let key = FindingKey {
4397            binding_hash: crate::binding::hash_binding(&binding),
4398            source_head: String::new(),
4399        };
4400        let mut store = FindingsStore {
4401            binding: "m/s".to_string(),
4402            ..Default::default()
4403        };
4404        store.record(
4405            key.clone(),
4406            "1".to_string(),
4407            vec![uncovered(&key, "docs/a.md"), uncovered(&key, "docs/b.md")],
4408        );
4409        write_findings_store(root, "m", "s", &store).unwrap();
4410
4411        // Before the exclusion: both present.
4412        let (_, before) = current_findings(&engine, root, &binding, &resolved).unwrap();
4413        assert_eq!(before.len(), 2);
4414
4415        // The exclusion lands in the durable ledger (as `projection exclude`
4416        // records it) — no verify rewrites the batch.
4417        let state = crate::ingest::advance::AdvanceState {
4418            binding: "m/s".to_string(),
4419            exclusions: [("docs/a.md".to_string(), "generated; no entity".to_string())]
4420                .into_iter()
4421                .collect(),
4422            ..Default::default()
4423        };
4424        crate::ingest::advance::write_advance_store(root, "m", "s", &state).unwrap();
4425
4426        let (_, after) = current_findings(&engine, root, &binding, &resolved).unwrap();
4427        assert_eq!(after.len(), 1);
4428        assert!(matches!(
4429            &after[0].target,
4430            FindingTarget::Artifact { artifact } if artifact == "docs/b.md"
4431        ));
4432    }
4433
4434    /// Findings recorded under a prior `hash(D)` are superseded and never
4435    /// surface through `current_findings` — the brief renders the current
4436    /// batch alone.
4437    #[test]
4438    fn current_findings_never_serves_superseded_batches() {
4439        let ws = tempfile::tempdir().unwrap();
4440        let root = ws.path();
4441        let engine = crate::engine::Engine::from_mounts(Vec::new()).unwrap();
4442        let binding = sourceless_binding();
4443        let resolved = resolve_binding_run("m/s", &binding).unwrap();
4444
4445        let old_key = key("a-prior-binding-hash", "head0");
4446        let cur_key = FindingKey {
4447            binding_hash: crate::binding::hash_binding(&binding),
4448            source_head: String::new(),
4449        };
4450        let mut store = FindingsStore {
4451            binding: "m/s".to_string(),
4452            ..Default::default()
4453        };
4454        store.record(
4455            old_key.clone(),
4456            "1".to_string(),
4457            vec![uncovered(&old_key, "docs/stale.md")],
4458        );
4459        store.record(
4460            cur_key.clone(),
4461            "2".to_string(),
4462            vec![uncovered(&cur_key, "docs/live.md")],
4463        );
4464        write_findings_store(root, "m", "s", &store).unwrap();
4465
4466        let (_, current) = current_findings(&engine, root, &binding, &resolved).unwrap();
4467        assert_eq!(current.len(), 1);
4468        assert!(matches!(
4469            &current[0].target,
4470            FindingTarget::Artifact { artifact } if artifact == "docs/live.md"
4471        ));
4472    }
4473}