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