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            let class = match a.state.as_str() {
384                "drifted" => FindingClass::Drifted,
385                "unresolvable" => FindingClass::UnresolvableAnchor,
386                _ => return None,
387            };
388            Some(Finding {
389                key: key.clone(),
390                facet: STANDALONE_KEY.to_string(),
391                target: FindingTarget::Anchor {
392                    entity: a.entity_id.clone(),
393                    artifact: a.artifact.clone(),
394                },
395                class,
396                detail: format!("{} ({} {})", a.state, a.class, a.grain),
397                created_at: now.clone(),
398            })
399        })
400        .collect();
401
402    let mut store =
403        read_findings_store(workspace_root, mem, STANDALONE_KEY)?.unwrap_or_else(|| {
404            FindingsStore {
405                binding: format!("{mem}/{STANDALONE_KEY}"),
406                ..Default::default()
407            }
408        });
409    let prior: BTreeSet<(String, String)> = store
410        .current(&key)
411        .iter()
412        .map(|f| {
413            (
414                serde_json::to_string(&f.target).unwrap_or_default(),
415                f.class.as_wire().to_string(),
416            )
417        })
418        .collect();
419    let annotated: Vec<AnnotatedStandaloneFinding> = findings
420        .iter()
421        .map(|f| AnnotatedStandaloneFinding {
422            finding: f.clone(),
423            already_seen: prior.contains(&(
424                serde_json::to_string(&f.target).unwrap_or_default(),
425                f.class.as_wire().to_string(),
426            )),
427        })
428        .collect();
429    store.record(key, now, findings);
430    write_findings_store(workspace_root, mem, STANDALONE_KEY, &store)?;
431    Ok(annotated)
432}
433
434/// Read the durable findings store for a binding, or `None` when none exists.
435/// A malformed file surfaces a typed [`StoreError::Parse`] naming the path.
436pub fn read_findings_store(
437    workspace_root: &Path,
438    mem: &str,
439    name: &str,
440) -> Result<Option<FindingsStore>, StoreError> {
441    let path = findings_store_path(workspace_root, mem, name);
442    match std::fs::read(&path) {
443        Ok(bytes) => serde_json::from_slice(&bytes)
444            .map(Some)
445            .map_err(|e| StoreError::Parse {
446                path,
447                message: e.to_string(),
448            }),
449        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
450        Err(e) => Err(StoreError::Io { path, source: e }),
451    }
452}
453
454/// Create an engine-owned store subtree and drop a self-ignoring
455/// `.gitignore` (`*`) at its root if none exists. The `state/findings/`
456/// and `state/advance/` stores are per-checkout ephemeral engine state
457/// living inside a possibly-tracked workspace (where `state/mounts.json`
458/// IS tracked) — without the ignore they surface as untracked noise and
459/// would churn if committed. Best-effort: an ignore-write failure never
460/// fails the store write itself.
461pub(crate) fn ensure_selfignoring_store_dir(subtree_root: &Path) -> Result<(), StoreError> {
462    std::fs::create_dir_all(subtree_root).map_err(|e| StoreError::Io {
463        path: subtree_root.to_path_buf(),
464        source: e,
465    })?;
466    let gitignore = subtree_root.join(".gitignore");
467    if !gitignore.exists() {
468        let _ = std::fs::write(&gitignore, "*\n");
469    }
470    Ok(())
471}
472
473/// Persist the durable findings store for a binding (pretty JSON), creating
474/// parent directories.
475pub fn write_findings_store(
476    workspace_root: &Path,
477    mem: &str,
478    name: &str,
479    store: &FindingsStore,
480) -> Result<(), StoreError> {
481    ensure_selfignoring_store_dir(
482        &workspace_root
483            .join(WORKSPACE_STORE_DIR)
484            .join(STATE_DIR)
485            .join(FINDINGS_DIR),
486    )?;
487    let path = findings_store_path(workspace_root, mem, name);
488    if let Some(parent) = path.parent() {
489        std::fs::create_dir_all(parent).map_err(|e| StoreError::Io {
490            path: parent.to_path_buf(),
491            source: e,
492        })?;
493    }
494    let bytes = serde_json::to_vec_pretty(store).map_err(|e| StoreError::Parse {
495        path: path.clone(),
496        message: e.to_string(),
497    })?;
498    std::fs::write(&path, bytes).map_err(|e| StoreError::Io { path, source: e })
499}
500
501/// Drop the durable findings store for a binding. A missing file is a
502/// successful no-op.
503pub fn delete_findings_store(
504    workspace_root: &Path,
505    mem: &str,
506    name: &str,
507) -> Result<(), StoreError> {
508    let path = findings_store_path(workspace_root, mem, name);
509    match std::fs::remove_file(&path) {
510        Ok(()) => Ok(()),
511        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
512        Err(e) => Err(StoreError::Io { path, source: e }),
513    }
514}
515
516// ---------------------------------------------------------------------------
517// Verify write path
518// ---------------------------------------------------------------------------
519
520/// Why [`verify_binding`] could not complete.
521#[derive(Debug, thiserror::Error)]
522pub enum FindingsError {
523    /// The binding id is not the canonical `<mem>/<stem>` shape.
524    #[error("malformed binding id '{0}': expected `<mem>/<stem>`")]
525    MalformedId(String),
526    /// Reading or writing the durable findings store failed.
527    #[error("findings store error: {0}")]
528    Store(#[source] StoreError),
529    /// A path-based primary source's base directory does not exist — a
530    /// vanished or unmounted source. Verify refuses rather than measures:
531    /// enumerating a missing tree yields an empty stat map whose aggregate
532    /// (the hash of nothing) is indistinguishable from a genuinely empty
533    /// source and would overwrite a real `#verified` baseline with fake
534    /// state. Typed and visible, mirroring the D3 non-enumerable refusal.
535    #[error("source '{source_name}' unreachable: `{path}` does not exist")]
536    SourceUnreachable {
537        /// The source whose pointer resolved to the missing path.
538        source_name: String,
539        /// The resolved base path that does not exist.
540        path: String,
541    },
542    /// A full measurement ([`verify_binding_full`]) was requested over a
543    /// facet whose medium the capability matrix marks **non-enumerable**: the
544    /// full `S(D)` walk cannot cover it, so the whole run refuses — typed,
545    /// carrying the same [`FullResyncRefusal`] shape the scheduled walk emits
546    /// — rather than render a report with fabricated completeness. (The
547    /// *scheduled* walk refuses per facet and walks the rest; an explicit
548    /// full measurement promises complete figures, so a partial walk is not
549    /// an answer.)
550    #[error(
551        "full verify refused: facet '{}' over medium type '{}' cannot be fully walked — {}",
552        .0.facet, .0.medium_type, .0.reason
553    )]
554    FullWalkNonEnumerable(FullResyncRefusal),
555}
556
557/// The outcome of a [`verify_binding`] pass.
558#[derive(Debug, Clone, PartialEq, Eq)]
559pub struct VerifyOutcome {
560    /// The binding id verified.
561    pub binding: String,
562    /// The key the findings were recorded under this pass.
563    pub key: FindingKey,
564    /// How many findings were recorded under the current key.
565    pub recorded: usize,
566    /// How many findings remain under prior (superseded) keys (A3).
567    pub superseded: usize,
568    /// The tier-3 backlog depth — findings queued for adjudication.
569    pub backlog: usize,
570    /// The full-enumeration scheduling decision for this run (D3) — whether a
571    /// scheduled full walk fired, is not yet due, is disabled, and any typed
572    /// non-enumerable refusals. Surfaced (never a silent skip) to the caller.
573    pub full_resync: FullResyncDecision,
574    /// Each source facet's current head token as observed by this run — the
575    /// per-facet decomposition of `key.source_head`. The completed-run
576    /// baseline [`record_verified_baseline`] writes as `#verified`.
577    pub facet_heads: BTreeMap<String, String>,
578    /// Prepared-content hashes this pass observed for **hash-less**
579    /// hash-bearing (`anchored` / `derived`) anchors whose artifact resolved —
580    /// the backfill worklist. The caller records them onto the anchors via
581    /// [`record_anchor_hash_backfill`] after the pass returns `Ok` (the same
582    /// sanctioned post-run-write pattern as [`record_verified_baseline`]);
583    /// once recorded, subsequent verifies adjudicate those anchors
584    /// deterministically and this list comes back empty. `authored` /
585    /// `informed-by` anchors never appear here — the observation computes no
586    /// hash for them.
587    pub hash_backfill: Vec<ObservedArtifactHash>,
588}
589
590/// Record a **completed** verify run's baseline: for each facet head the run
591/// observed, `<binding>/<facet>#verified = <token>` on the destination mem,
592/// through the engine's lifecycle sync-state writer (the backlog-prescribed
593/// `#verified` writer — the counterpart of the advance path's `#synced`).
594///
595/// Deliberately a separate step from [`verify_binding`], which keeps its
596/// shared `&Engine` borrow (A5 — measurement is structurally incapable of a
597/// mem mutation): the caller invokes this **only after** a verify pass
598/// returned `Ok`, so an aborted or failed run never advances the token. The
599/// selection loop reads the token to decide when a verify is due again; the
600/// CLI `status`/report paths render it.
601///
602/// Returns the written sync-state keys. A binding whose run observed no facet
603/// head (nothing recorded, nothing moved) writes nothing.
604pub fn record_verified_baseline(
605    engine: &mut Engine,
606    destination_mem: &str,
607    outcome: &VerifyOutcome,
608    note: Option<&str>,
609) -> Result<Vec<String>, crate::engine::EngineError> {
610    let mut written = Vec::with_capacity(outcome.facet_heads.len());
611    for (facet, token) in &outcome.facet_heads {
612        let key = format!("{}/{facet}#verified", outcome.binding);
613        engine.set_mem_sync_state(destination_mem, &key, token, note)?;
614        written.push(key);
615    }
616    Ok(written)
617}
618
619/// Record a **completed** verify run's prepared-hash backfill: every hash the
620/// pass observed for a hash-less hash-bearing anchor
621/// ([`VerifyOutcome::hash_backfill`]) is written onto that anchor in the
622/// destination mem's engine-owned anchors sidecar, through
623/// [`Engine::record_anchor_observed_hashes`].
624///
625/// Measurement bookkeeping only: the write touches the sidecar and nothing
626/// else — no entity content, no section, no `_hash`. Deliberately a separate
627/// step from [`verify_binding`] (which keeps its shared `&Engine` borrow —
628/// A5), mirroring [`record_verified_baseline`]: the caller invokes this only
629/// after a verify pass returned `Ok`, so an aborted or failed run never
630/// records a hash. Idempotent — the engine writer skips anchors that already
631/// carry a hash, and a pass over fully-backfilled anchors observes an empty
632/// worklist, so re-verifying stages nothing and produces no commit.
633///
634/// Returns how many anchors gained a recorded hash.
635pub fn record_anchor_hash_backfill(
636    engine: &mut Engine,
637    destination_mem: &str,
638    outcome: &VerifyOutcome,
639    note: Option<&str>,
640) -> Result<usize, crate::engine::EngineError> {
641    engine.record_anchor_observed_hashes(destination_mem, &outcome.hash_backfill, note)
642}
643
644/// Split a canonical binding id `<mem>/<stem>` into its two path-safe halves,
645/// or refuse. Uses the same guard as the advance store so a caller-supplied id
646/// can never escape the `.memstead/state/findings/` tier.
647fn split_binding_id(binding_id: &str) -> Result<(String, String), FindingsError> {
648    binding_id
649        .split_once('/')
650        .filter(|(m, n)| is_single_component(m) && is_single_component(n))
651        .map(|(m, n)| (m.to_string(), n.to_string()))
652        .ok_or_else(|| FindingsError::MalformedId(binding_id.to_string()))
653}
654
655/// A single facet label for the thin verify: the lone primary facet when there
656/// is exactly one, else a comma-join. Per-anchor facet attribution is a
657/// group-B refinement.
658fn source_facet_label(resolved: &ResolvedIngest) -> String {
659    let facets: Vec<&str> = resolved
660        .sources
661        .iter()
662        .filter_map(|s| match s {
663            ResolvedSource::Primary(p) => Some(p.name.as_str()),
664            ResolvedSource::Reference { .. } => None,
665        })
666        .collect();
667    facets.join(",")
668}
669
670/// Opaque recording timestamp — unix seconds as a decimal string.
671fn now_seconds() -> String {
672    let secs = SystemTime::now()
673        .duration_since(UNIX_EPOCH)
674        .map(|d| d.as_secs())
675        .unwrap_or(0);
676    secs.to_string()
677}
678
679/// Each source facet's **current head token**, keyed by facet. Starts from the
680/// destination mem's recorded `#synced` tokens for the binding, then overlays
681/// the cursor's current-head tokens for any facet that has moved or is newly
682/// seen — so the map reflects the source's current state. These are the tokens
683/// [`current_source_head`] joins into the composite key, and the per-facet
684/// values [`record_verified_baseline`] writes as `#verified` after a completed
685/// verify run.
686fn current_facet_heads(
687    engine: &Engine,
688    workspace_root: &Path,
689    resolved: &ResolvedIngest,
690) -> BTreeMap<String, String> {
691    let binding_id = &resolved.name;
692    let prefix = format!("{binding_id}/");
693    let mut tokens: BTreeMap<String, String> = BTreeMap::new();
694
695    // Recorded baselines for facets that have not moved since the last sync.
696    if let Some(cfg) = engine.mem_config_for(&resolved.destination_mem) {
697        for (k, v) in &cfg.sync_state {
698            if let Some(rest) = k.strip_prefix(&prefix)
699                && let Some(facet) = rest.strip_suffix("#synced")
700            {
701                tokens.insert(facet.to_string(), v.clone());
702            }
703        }
704    }
705
706    // Current-head tokens for facets that moved / reseeded this pass win.
707    let cursor = compute_source_cursor(engine, resolved, workspace_root);
708    for c in cursor.write_commands.iter().chain(cursor.reseed.iter()) {
709        if let Some(rest) = c.key.strip_prefix(&prefix)
710            && let Some(facet) = rest.strip_suffix("#synced")
711        {
712            tokens.insert(facet.to_string(), c.token.clone());
713        }
714    }
715
716    tokens
717}
718
719/// Join a facet-head map into the composite source-head token,
720/// deterministically (`facet=token;facet=token`).
721fn join_facet_heads(tokens: &BTreeMap<String, String>) -> String {
722    tokens
723        .iter()
724        .map(|(facet, token)| format!("{facet}={token}"))
725        .collect::<Vec<_>>()
726        .join(";")
727}
728
729/// The composite current source-head token: each source facet's current
730/// baseline token, joined deterministically — the value changes iff any
731/// facet's head changes (the A3 "source head moved" trigger).
732fn current_source_head(
733    engine: &Engine,
734    workspace_root: &Path,
735    resolved: &ResolvedIngest,
736) -> String {
737    join_facet_heads(&current_facet_heads(engine, workspace_root, resolved))
738}
739
740/// `hash(D)` for a v2 binding — the record alone carries every content
741/// input, so the resolved shape is not needed.
742fn binding_hash_of(binding: &Binding, _resolved: &ResolvedIngest) -> String {
743    hash_binding(binding)
744}
745
746/// The current [`FindingKey`] for a binding — `hash(D)` (the half that
747/// keys the store) plus the current `source_head` (observation
748/// metadata carried on each finding, not part of the store key).
749fn current_key(
750    engine: &Engine,
751    workspace_root: &Path,
752    binding: &Binding,
753    resolved: &ResolvedIngest,
754) -> FindingKey {
755    FindingKey {
756        binding_hash: binding_hash_of(binding, resolved),
757        source_head: current_source_head(engine, workspace_root, resolved),
758    }
759}
760
761/// The current `(hash(D), source_head)` key plus the open findings under the
762/// key's `hash(D)` for a binding — the read the **sync brief** (group C)
763/// consumes. It resolves the current key exactly as [`verify_binding`] does,
764/// reads the durable store, and returns the `current(key)` slice cloned —
765/// which presents **all open findings regardless of the head they were
766/// recorded at** (findings survive source movement; each carries its observed
767/// head on its own key). **Read-only** on the destination mem (shared
768/// `&Engine`): no findings recording, no mutation. A binding whose store does
769/// not exist yet yields the key and an empty vec.
770pub fn current_findings(
771    engine: &Engine,
772    workspace_root: &Path,
773    binding: &Binding,
774    resolved: &ResolvedIngest,
775) -> Result<(FindingKey, Vec<Finding>), FindingsError> {
776    let (mem, name) = split_binding_id(&resolved.name)?;
777    let key = current_key(engine, workspace_root, binding, resolved);
778    let findings = read_findings_store(workspace_root, &mem, &name)
779        .map_err(FindingsError::Store)?
780        .map(|s| s.current(&key).to_vec())
781        .unwrap_or_default();
782    Ok((key, findings))
783}
784
785/// Adjudicate one resolved anchor into a finding, or `None` when it resolves
786/// clean.
787///
788/// **A2 enforcement — hash-drift exclusion.** A `drifted` / `recheck` state is
789/// turned into a finding **only** for a hash-bearing class (`anchored` /
790/// `derived`). An `authored` or `informed-by` anchor is excluded from hash-drift
791/// adjudication by design: it never yields a `drifted` / `queued-for-adjudication`
792/// finding here, whatever its content did. (Existence failures — `orphaned` —
793/// are class-independent and reported for any class: a vanished artifact is not
794/// a hash-drift claim.)
795pub fn adjudicate_anchor(
796    key: &FindingKey,
797    facet: &str,
798    entity: &str,
799    anchor: &Anchor,
800    state: AnchorState,
801    created_at: &str,
802) -> Option<Finding> {
803    let (class, detail) = match state {
804        AnchorState::Resolves => return None,
805        AnchorState::Orphaned => (
806            FindingClass::UnresolvableAnchor,
807            format!(
808                "artifact '{}' the anchor references is no longer present in the medium",
809                anchor.artifact
810            ),
811        ),
812        AnchorState::Drifted | AnchorState::Recheck => {
813            // Hash-drift adjudication — excluded for non-hash-bearing classes (A2).
814            if !anchor.class.is_hash_bearing() {
815                return None;
816            }
817            match state {
818                AnchorState::Drifted => (
819                    FindingClass::Drifted,
820                    format!(
821                        "prepared-content hash of '{}' drifted from the anchored hash",
822                        anchor.artifact
823                    ),
824                ),
825                _ => (
826                    FindingClass::QueuedForAdjudication,
827                    format!(
828                        "hash adjudication of '{}' deferred (recheck); queued",
829                        anchor.artifact
830                    ),
831                ),
832            }
833        }
834    };
835    Some(Finding {
836        key: key.clone(),
837        facet: facet.to_string(),
838        target: FindingTarget::Anchor {
839            entity: entity.to_string(),
840            artifact: anchor.artifact.clone(),
841        },
842        class,
843        detail,
844        created_at: created_at.to_string(),
845    })
846}
847
848// ---------------------------------------------------------------------------
849// Tier-3 caps + scheduling (group D)
850// ---------------------------------------------------------------------------
851
852/// One source facet's enumerability — the input the full-resync scheduler
853/// reasons over (D3). Built from the capability matrix per primary facet.
854#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
855pub struct FacetEnumerability {
856    /// The source facet.
857    pub facet: String,
858    /// The medium type wire string.
859    pub medium_type: String,
860    /// Whether the medium's scope is enumerable (`S(D)` computable).
861    pub enumerable: bool,
862}
863
864/// A typed refusal from the scheduled full-enumeration walk (D3): a source facet
865/// whose medium the capability matrix marks **non-enumerable**, which the walk
866/// cannot cover. Emitted instead of a silent skip or a fabricated full-coverage
867/// claim.
868#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
869pub struct FullResyncRefusal {
870    /// The refused facet.
871    pub facet: String,
872    /// The non-enumerable medium type.
873    pub medium_type: String,
874    /// Why the scheduled walk refuses this facet.
875    pub reason: String,
876}
877
878/// The full-enumeration scheduling decision for a verify run (D3). A closed,
879/// serialized vocabulary so the caller (and the fidelity report) can render the
880/// outcome without inferring it.
881#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
882#[serde(tag = "state", rename_all = "kebab-case")]
883pub enum FullResyncDecision {
884    /// `full_resync_every == 0` — scheduled full walks are disabled; the run
885    /// uses the rotating sample only.
886    Disabled,
887    /// Scheduled but not due this run — the rotating sample runs; the counter
888    /// advances toward the next full walk.
889    NotDue {
890        /// This run's 1-based verify-run count.
891        run_count: u64,
892        /// The configured cadence.
893        every: u32,
894        /// How many further runs until the next scheduled full walk.
895        runs_until_due: u32,
896    },
897    /// Due this run: a full-enumeration walk fires for the **enumerable** facets
898    /// (guaranteeing a complete coverage picture), and every **non-enumerable**
899    /// facet is refused with a typed signal — never a silent skip, never a
900    /// fabricated full-coverage claim.
901    Due {
902        /// This run's 1-based verify-run count.
903        run_count: u64,
904        /// The configured cadence.
905        every: u32,
906        /// The facets a full enumeration walk covers this run.
907        walked_facets: Vec<String>,
908        /// The non-enumerable facets the walk refuses (typed).
909        refused: Vec<FullResyncRefusal>,
910    },
911    /// A full walk was **explicitly requested** ([`verify_binding_full`] —
912    /// the CLI's `--full`), not schedule-triggered: the whole enumerable
913    /// `S(D)` is walked, the sampling scheduler is bypassed, and the
914    /// adjudication cap is treated as unlimited. Only ever constructed after
915    /// the every-facet-enumerable gate, so it carries no per-facet refusal
916    /// list — a non-enumerable facet refuses the entire run instead
917    /// ([`FindingsError::FullWalkNonEnumerable`]).
918    Forced {
919        /// The facets the full enumeration walk covers.
920        walked_facets: Vec<String>,
921    },
922}
923
924impl FullResyncDecision {
925    /// Whether this run performs a full-enumeration walk (a scheduled sweep
926    /// is due, or an explicit full measurement was requested). `false` for
927    /// `Disabled` / `NotDue`.
928    pub fn is_full_walk(&self) -> bool {
929        matches!(
930            self,
931            FullResyncDecision::Due { .. } | FullResyncDecision::Forced { .. }
932        )
933    }
934}
935
936/// Decide the `full_resync_every` scheduling outcome for a verify run (D3) —
937/// pure and level-triggered on the persisted run counter. `every == 0` disables
938/// scheduled walks; otherwise the walk is **due** when `run_count` is a multiple
939/// of `every`. When due, enumerable facets are walked and non-enumerable facets
940/// are refused with a typed [`FullResyncRefusal`] (never silently skipped).
941pub fn schedule_full_resync(
942    every: u32,
943    run_count: u64,
944    facets: &[FacetEnumerability],
945) -> FullResyncDecision {
946    if every == 0 {
947        return FullResyncDecision::Disabled;
948    }
949    let modulo = run_count % u64::from(every);
950    if modulo != 0 {
951        return FullResyncDecision::NotDue {
952            run_count,
953            every,
954            runs_until_due: (u64::from(every) - modulo) as u32,
955        };
956    }
957    let mut walked_facets = Vec::new();
958    let mut refused = Vec::new();
959    for f in facets {
960        if f.enumerable {
961            walked_facets.push(f.facet.clone());
962        } else {
963            refused.push(FullResyncRefusal {
964                facet: f.facet.clone(),
965                medium_type: f.medium_type.clone(),
966                reason: format!(
967                    "medium type '{}' is non-enumerable — a full-enumeration walk cannot cover \
968                     it; the scheduled full resync refuses rather than claim full coverage",
969                    f.medium_type
970                ),
971            });
972        }
973    }
974    FullResyncDecision::Due {
975        run_count,
976        every,
977        walked_facets,
978        refused,
979    }
980}
981
982/// The rotation item key a drift-adjudication candidate is selected under (D2) —
983/// stable across runs for a given `(entity, artifact)` so the rotating window
984/// covers a reproducible sequence.
985fn candidate_key(entity: &str, anchor: &Anchor) -> String {
986    format!("{entity}\u{1f}{}", anchor.artifact)
987}
988
989/// Adjudicate the hash-drift **candidates** under the per-run cap (D1). Each
990/// candidate is an anchor observation that hash-drift adjudication applies to
991/// (a hash-bearing anchor in a `drifted` / `recheck` state). `window` is the
992/// rotation-selected key set this run adjudicates (D2); a candidate whose
993/// [`candidate_key`] is **not** in the window is **queued** as
994/// `queued-for-adjudication` (the tier-3 backlog remainder) rather than
995/// adjudicated. `window = None` means uncapped — every candidate is adjudicated.
996///
997/// Existence failures (`orphaned`) are **not** candidates: they are cheap
998/// existence checks, always reported by [`verify_binding`] regardless of the
999/// cap. Non-hash-bearing classes never reach here (they produce no adjudication).
1000fn adjudicate_candidates(
1001    key: &FindingKey,
1002    facet: &str,
1003    candidates: &[(String, Anchor, AnchorState)],
1004    window: Option<&BTreeSet<String>>,
1005    created_at: &str,
1006) -> Vec<Finding> {
1007    let mut out = Vec::new();
1008    for (entity, anchor, state) in candidates {
1009        let ck = candidate_key(entity, anchor);
1010        let adjudicate_now = window.is_none_or(|w| w.contains(&ck));
1011        if adjudicate_now {
1012            if let Some(f) = adjudicate_anchor(key, facet, entity, anchor, *state, created_at) {
1013                out.push(f);
1014            }
1015        } else {
1016            // Beyond the per-run cap: queue the remainder (D1) — it re-presents
1017            // in a later run's rotation window (D2), so the whole candidate set
1018            // is covered over a full rotation.
1019            out.push(Finding {
1020                key: key.clone(),
1021                facet: facet.to_string(),
1022                target: FindingTarget::Anchor {
1023                    entity: entity.clone(),
1024                    artifact: anchor.artifact.clone(),
1025                },
1026                class: FindingClass::QueuedForAdjudication,
1027                detail: format!(
1028                    "adjudication of '{}' deferred (per-run adjudication cap reached); queued",
1029                    anchor.artifact
1030                ),
1031                created_at: created_at.to_string(),
1032            });
1033        }
1034    }
1035    out
1036}
1037
1038/// Stable identity of a finding's subject, class-independent — the unit the
1039/// head-durable merge ([`merge_with_prior`]) matches prior and fresh findings
1040/// on.
1041fn target_key(target: &FindingTarget) -> String {
1042    match target {
1043        FindingTarget::Anchor { entity, artifact } => format!("a\u{1f}{entity}\u{1f}{artifact}"),
1044        FindingTarget::Artifact { artifact } => format!("f\u{1f}{artifact}"),
1045    }
1046}
1047
1048/// What one verify pass **observed** and what still exists — the inputs the
1049/// head-durable merge judges prior findings against.
1050struct PassObservation {
1051    /// Anchor targets ([`target_key`] form) whose live state this pass
1052    /// resolved (`Some(state)`).
1053    anchors_observed: BTreeSet<String>,
1054    /// Anchor targets still present in the mem's sidecar — any state,
1055    /// observed or not.
1056    anchors_existing: BTreeSet<String>,
1057    /// Artifact ids the coverage leg looked at this pass (the sample window,
1058    /// or the whole of `S(D)` on a full walk).
1059    files_observed: BTreeSet<String>,
1060    /// The binding's enumerable source set `S(D)`.
1061    s_d: BTreeSet<String>,
1062}
1063
1064/// Merge this pass's fresh findings with the prior open batch — the write half
1065/// of head-durable findings (the store keys on `hash(D)` alone; see the module
1066/// docs).
1067///
1068/// A **re-observed** target's outcome is this pass's: a prior finding for it
1069/// is closed (observed clean — no fresh finding) or replaced (observed still
1070/// wrong — fresh finding wins). One exception keeps supersession honest: a
1071/// fresh `queued-for-adjudication` entry is a scheduling deferral, not an
1072/// observation, so it never downgrades a prior substantive adjudication —
1073/// a prior `drifted`/`wrong` verdict stands in its place.
1074///
1075/// An **unobserved** prior finding carries forward iff its subject is still
1076/// open:
1077/// - an anchor finding carries while its anchor still exists but was
1078///   unobservable this pass; a vanished anchor closes it;
1079/// - a coverage (artifact) finding carries while the artifact is still in
1080///   `S(D)` and still carries no covering anchor (`covered_now`); departure
1081///   from `S(D)` or gained coverage closes it.
1082///
1083/// Carried findings keep their original [`Finding::key`] (the head they were
1084/// observed at). The carry rules are the growth bound: nothing is carried
1085/// whose subject left the source or re-adjudicated clean, so the open set
1086/// cannot grow without bound — and a closed/superseded finding is never
1087/// resurrected (it is simply absent from the recorded batch).
1088fn merge_with_prior(
1089    mut fresh: Vec<Finding>,
1090    prior: &[Finding],
1091    obs: &PassObservation,
1092    covered_now: impl Fn(&str) -> bool,
1093) -> Vec<Finding> {
1094    let fresh_idx: BTreeMap<String, usize> = fresh
1095        .iter()
1096        .enumerate()
1097        .map(|(i, f)| (target_key(&f.target), i))
1098        .collect();
1099    let mut carried: Vec<Finding> = Vec::new();
1100    for f in prior {
1101        let tkey = target_key(&f.target);
1102        let observed = match &f.target {
1103            FindingTarget::Anchor { .. } => obs.anchors_observed.contains(&tkey),
1104            FindingTarget::Artifact { artifact } => obs.files_observed.contains(artifact),
1105        };
1106        if observed {
1107            // Deferral must not supersede a substantive prior verdict.
1108            if matches!(f.class, FindingClass::Drifted | FindingClass::Wrong)
1109                && let Some(&i) = fresh_idx.get(&tkey)
1110                && fresh[i].class == FindingClass::QueuedForAdjudication
1111            {
1112                fresh[i] = f.clone();
1113            }
1114            continue;
1115        }
1116        if fresh_idx.contains_key(&tkey) {
1117            continue; // a fresh outcome exists for this target anyway
1118        }
1119        let still_open = match &f.target {
1120            FindingTarget::Anchor { .. } => obs.anchors_existing.contains(&tkey),
1121            FindingTarget::Artifact { artifact } => {
1122                obs.s_d.contains(artifact) && !covered_now(artifact)
1123            }
1124        };
1125        if still_open {
1126            carried.push(f.clone());
1127        }
1128    }
1129    fresh.extend(carried);
1130    fresh
1131}
1132
1133/// The thin `projection verify` write path (group A). Measures a binding's
1134/// fidelity and records durable findings under the current `(hash(D),
1135/// source_head)` key; **read-only on the destination mem** — the `&Engine`
1136/// (shared, not `&mut`) makes a mem mutation structurally impossible (A5).
1137///
1138/// It does two things a real verify does, enough to populate and exercise the
1139/// store (A1/A2): it adjudicates the destination mem's anchors against their
1140/// live source observation (via [`adjudicate_anchor`], honouring the A2
1141/// hash-drift exclusion), and it samples in-scope source artifacts through the
1142/// retained [`next_batch`] rotation (A4 — the rotation's sole surviving
1143/// consumer, used only to schedule which artifacts a pass looks at) to surface
1144/// uncovered ones. The full tier-1 fidelity report and the sync brief are
1145/// group B/C — this path deliberately renders neither.
1146pub fn verify_binding(
1147    engine: &Engine,
1148    workspace_root: &Path,
1149    binding: &Binding,
1150    resolved: &ResolvedIngest,
1151) -> Result<VerifyOutcome, FindingsError> {
1152    run_verify(engine, workspace_root, binding, resolved, false)
1153}
1154
1155/// [`verify_binding`]'s **full-measurement** mode (the CLI's `--full`):
1156/// enumerate the whole `S(D)` (the sampling scheduler is bypassed — the
1157/// rotation state is neither consulted nor advanced), treat the per-run
1158/// adjudication cap as unlimited, and observe every anchor — so the recorded
1159/// findings, and the tier-1 report computed over them, carry no
1160/// sampling/truncation caveat: coverage and accuracy are computed, not
1161/// sampled. The prepared-hash backfill worklist rides the outcome exactly as
1162/// on a sampled pass.
1163///
1164/// REFUSAL: a facet whose medium the capability matrix marks non-enumerable
1165/// refuses the **whole** run with the typed
1166/// [`FindingsError::FullWalkNonEnumerable`] — an explicit full measurement
1167/// promises complete figures, so a partial walk is never silently substituted
1168/// and a fabricated-complete report is never rendered. The sampled path
1169/// ([`verify_binding`]) is untouched by this mode's existence.
1170pub fn verify_binding_full(
1171    engine: &Engine,
1172    workspace_root: &Path,
1173    binding: &Binding,
1174    resolved: &ResolvedIngest,
1175) -> Result<VerifyOutcome, FindingsError> {
1176    run_verify(engine, workspace_root, binding, resolved, true)
1177}
1178
1179/// The shared verify pass behind [`verify_binding`] (`full = false`, the
1180/// capped/sampled loop economics) and [`verify_binding_full`] (`full = true`,
1181/// the uncapped whole-`S(D)` measurement).
1182fn run_verify(
1183    engine: &Engine,
1184    workspace_root: &Path,
1185    binding: &Binding,
1186    resolved: &ResolvedIngest,
1187    full: bool,
1188) -> Result<VerifyOutcome, FindingsError> {
1189    let binding_id = resolved.name.clone();
1190    let (mem, name) = split_binding_id(&binding_id)?;
1191
1192    // Full measurement requires every primary facet to be enumerable — refuse
1193    // the whole run typed before observing anything (never a fake-complete
1194    // report over a partially-walkable source).
1195    if full {
1196        for source in &resolved.sources {
1197            if let ResolvedSource::Primary(p) = source {
1198                let medium_type = medium_type_wire(p.medium_type);
1199                if !medium_capabilities(p.medium_type).enumerable {
1200                    return Err(FindingsError::FullWalkNonEnumerable(FullResyncRefusal {
1201                        facet: p.name.clone(),
1202                        medium_type: medium_type.clone(),
1203                        reason: format!(
1204                            "medium type '{medium_type}' is non-enumerable — a full-enumeration \
1205                             walk cannot cover it; the full measurement refuses rather than \
1206                             render a report with fabricated completeness"
1207                        ),
1208                    }));
1209                }
1210            }
1211        }
1212
1213        // The matrix claiming enumerability is not evidence that a walk
1214        // happened. When a medium is declared enumerable but its walk yields
1215        // nothing, `--full` used to sail through the gate above and return
1216        // clean over a zero-artifact measurement — coverage 0/0, every anchor
1217        // unobserved, verdict green. That is the exact shape a full
1218        // measurement exists to make impossible, so refuse it.
1219        //
1220        // This guard survives the enumerator being fixed: it is the standing
1221        // check that a future medium cannot be added to the matrix as
1222        // enumerable without an enumeration arm and still report green.
1223        // Checked PER FACET. A binding-level union hides the mixed case: one
1224        // facet that walks makes the union non-empty, so `--full` returned
1225        // clean while a sibling enumerable facet was never walked at all —
1226        // complete coverage claimed over a scope nobody looked at. Each
1227        // enumerable facet must produce something of its own.
1228        for source in &resolved.sources {
1229            if let ResolvedSource::Primary(p) = source
1230                && medium_capabilities(p.medium_type).enumerable
1231                && enumerate_source_artifacts(engine, p, &resolved.deny_paths, workspace_root)
1232                    .is_empty()
1233            {
1234                let medium_type = medium_type_wire(p.medium_type);
1235                return Err(FindingsError::FullWalkNonEnumerable(FullResyncRefusal {
1236                    facet: p.name.clone(),
1237                    medium_type: medium_type.clone(),
1238                    reason: format!(
1239                        "medium type '{medium_type}' claims to be enumerable, but this facet's \
1240                         enumeration yielded no artifacts — a full measurement over an empty \
1241                         walk would report complete coverage of nothing. Check that its scope \
1242                         patterns actually select something"
1243                    ),
1244                }));
1245            }
1246        }
1247    }
1248
1249    // Refuse a vanished or unmounted path-based source before observing
1250    // anything: a missing tree would otherwise degrade to an empty
1251    // enumeration whose head token (the digest of nothing) masquerades as
1252    // a real observation — and the caller's completed-run baseline write
1253    // would clobber a genuine `#verified` token with it.
1254    for source in &resolved.sources {
1255        if let ResolvedSource::Primary(p) = source
1256            && matches!(
1257                p.medium_type,
1258                crate::pipeline::MediumType::Codebase
1259                    | crate::pipeline::MediumType::Filesystem
1260                    | crate::pipeline::MediumType::Git
1261            )
1262        {
1263            let base = super::resolve::source_base_path(p, workspace_root);
1264            // Unreachable is not only "absent". A directory that exists but
1265            // cannot be entered (permissions, a broken mount) enumerates
1266            // nothing, and the pass then reports every anchor unresolvable —
1267            // drift, in the verdict, blamed on a mem that did not move. The
1268            // read attempt is the test: existence alone let that through.
1269            // These mediums (codebase / filesystem / git) are all
1270            // directory-shaped — their scope globs enumerate under a tree —
1271            // so reachable means it IS a readable directory. A regular file
1272            // where the pointer promises a tree enumerates nothing and used
1273            // to slip through to be reported as drift, though the refusal
1274            // text already promised "present but not enumerable".
1275            let reachable = base.is_dir() && std::fs::read_dir(&base).is_ok();
1276            if !reachable {
1277                return Err(FindingsError::SourceUnreachable {
1278                    source_name: p.name.clone(),
1279                    path: base.display().to_string(),
1280                });
1281            }
1282        }
1283    }
1284
1285    // The same refusal for a graph source, which needs it just as badly and
1286    // for a worse reason. A graph source's "tree" is a mounted mem; if that
1287    // mem is absent from the workspace, every entity anchor into it misses
1288    // the store and observes as ABSENT — a definite `orphaned`, not an
1289    // honest "unobserved". The pass would then report drift, tell the reader
1290    // to repoint or unset anchors that are perfectly fine, and — because
1291    // `orphaned` is the one state that satisfies prune's all-orphaned gate —
1292    // let prune propose deleting the destination entities. An unmounted mem
1293    // must never be indistinguishable from a deleted one.
1294    for source in &resolved.sources {
1295        if let ResolvedSource::Primary(p) = source
1296            && p.medium_type == crate::pipeline::MediumType::Graph
1297            && !engine.mem_names().iter().any(|m| *m == p.pointer)
1298        {
1299            return Err(FindingsError::SourceUnreachable {
1300                source_name: p.name.clone(),
1301                path: format!("mem `{}` (not mounted in this workspace)", p.pointer),
1302            });
1303        }
1304    }
1305
1306    // The facet-head map is the key's per-facet decomposition: computed once,
1307    // joined into `key.source_head`, and returned on the outcome so a
1308    // completed run's baseline write records exactly what this run observed.
1309    let facet_heads = current_facet_heads(engine, workspace_root, resolved);
1310    let key = FindingKey {
1311        binding_hash: binding_hash_of(binding, resolved),
1312        source_head: join_facet_heads(&facet_heads),
1313    };
1314    let now = now_seconds();
1315    let facet = source_facet_label(resolved);
1316    let cache_root = workspace_root.join(".memstead.cache").join("ingest");
1317
1318    // Tier-3 operations knobs (group D): the per-run adjudication cap (D1), the
1319    // scheduled full-walk cadence (D3), and the sample window size. All come off
1320    // the `verify` block, defaulting to the dogfood-tuned engine defaults when it
1321    // is absent (verify has no mutating operation to gate — an absent block is
1322    // defaults, never a refusal).
1323    let verify_op = binding.operations.verify.as_ref();
1324    let cap = verify_op.map_or(DEFAULT_ADJUDICATION_CAP, |v| v.adjudication_cap);
1325    let full_resync_every = verify_op.map_or(DEFAULT_FULL_RESYNC_EVERY, |v| v.full_resync_every);
1326    let sample_batch = verify_op
1327        .map_or(resolved.batch_size, |v| v.batch_size)
1328        .max(1) as usize;
1329
1330    // Level-trigger clock + full-resync schedule (D3) — the counter ticks every
1331    // run (even a non-enumerable one) so the schedule can refuse on time. An
1332    // explicit full measurement ticks the same clock (it is a verify run) but
1333    // its walk decision is `Forced`, not schedule-derived: the every-facet-
1334    // enumerable gate above already held, so no per-facet refusal list exists.
1335    let run_count = bump_verify_runs(&cache_root, &binding_id);
1336    let facet_enum: Vec<FacetEnumerability> = resolved
1337        .sources
1338        .iter()
1339        .filter_map(|s| match s {
1340            ResolvedSource::Primary(p) => Some(FacetEnumerability {
1341                facet: p.name.clone(),
1342                medium_type: medium_type_wire(p.medium_type),
1343                enumerable: medium_capabilities(p.medium_type).enumerable,
1344            }),
1345            ResolvedSource::Reference { .. } => None,
1346        })
1347        .collect();
1348    let full_resync = if full {
1349        FullResyncDecision::Forced {
1350            walked_facets: facet_enum.iter().map(|f| f.facet.clone()).collect(),
1351        }
1352    } else {
1353        schedule_full_resync(full_resync_every, run_count, &facet_enum)
1354    };
1355
1356    let mut findings: Vec<Finding> = Vec::new();
1357
1358    // 1. Adjudicate the destination mem's anchors against the live source, under
1359    //    the per-run cap (D1) with a rotating window (D2). Existence failures
1360    //    (orphaned) are cheap and always reported; hash-drift candidates are
1361    //    bounded — the cap-sized rotation window is adjudicated, the remainder
1362    //    queued, and successive runs rotate the window so the whole anchor set is
1363    //    covered over a full rotation.
1364    let mut existence: Vec<(String, Anchor, AnchorState)> = Vec::new();
1365    let mut candidates: Vec<(String, Anchor, AnchorState)> = Vec::new();
1366    // First-observation backfill worklist: a hash-less hash-bearing anchor
1367    // whose artifact resolved and yielded a prepared-content hash is not a
1368    // drift candidate (there is no recorded hash to compare — recorded ==
1369    // observed by construction once the backfill lands); it resolves clean
1370    // this pass and the observed hash rides the outcome for the caller's
1371    // [`record_anchor_hash_backfill`] write. From the next pass on the
1372    // anchor adjudicates deterministically — the recheck queue drains
1373    // instead of re-queueing forever.
1374    let mut hash_backfill: Vec<ObservedArtifactHash> = Vec::new();
1375    let mut backfill_seen: BTreeSet<(String, String)> = BTreeSet::new();
1376    // Observation bookkeeping for the head-durable merge: which anchor
1377    // targets exist, and which of them this pass actually resolved.
1378    let mut anchors_existing: BTreeSet<String> = BTreeSet::new();
1379    let mut anchors_observed: BTreeSet<String> = BTreeSet::new();
1380    for (eid, resolved_anchor) in engine.mem_anchors_resolved(&resolved.destination_mem) {
1381        let tkey = target_key(&FindingTarget::Anchor {
1382            entity: eid.as_ref().to_string(),
1383            artifact: resolved_anchor.anchor.artifact.clone(),
1384        });
1385        anchors_existing.insert(tkey.clone());
1386        let Some(state) = resolved_anchor.state else {
1387            continue;
1388        };
1389        anchors_observed.insert(tkey);
1390        let observed_hash = resolved_anchor.observed_hash;
1391        let anchor = resolved_anchor.anchor;
1392        match state {
1393            AnchorState::Resolves => {}
1394            AnchorState::Orphaned => existence.push((eid.as_ref().to_string(), anchor, state)),
1395            AnchorState::Drifted | AnchorState::Recheck => {
1396                // Only hash-bearing anchors are hash-drift candidates (A2); a
1397                // non-hash-bearing class yields no adjudication.
1398                if !anchor.class.is_hash_bearing() {
1399                    continue;
1400                }
1401                if anchor.hash.is_none()
1402                    && let Some(hash) = observed_hash
1403                {
1404                    // First observation of a hash-less anchor on a resolvable
1405                    // artifact: backfill, not adjudication.
1406                    if backfill_seen.insert((eid.as_ref().to_string(), anchor.artifact.clone())) {
1407                        hash_backfill.push(ObservedArtifactHash {
1408                            entity: eid.as_ref().to_string(),
1409                            artifact: anchor.artifact.clone(),
1410                            hash,
1411                        });
1412                    }
1413                    continue;
1414                }
1415                candidates.push((eid.as_ref().to_string(), anchor, state));
1416            }
1417        }
1418    }
1419    for (entity, anchor, state) in &existence {
1420        if let Some(f) = adjudicate_anchor(&key, &facet, entity, anchor, *state, &now) {
1421            findings.push(f);
1422        }
1423    }
1424    // `cap == 0` disables the cap (adjudicate every candidate), and a full
1425    // measurement treats any configured cap as unlimited — its rotation state
1426    // is neither consulted nor advanced (the scheduler is bypassed, so the
1427    // sampled loop's window sequence is untouched by a full run). Otherwise a
1428    // cap-sized rotation window selects this run's adjudicated set (D1/D2).
1429    let window: Option<BTreeSet<String>> = if full || cap == 0 {
1430        None
1431    } else {
1432        let mut keys: Vec<String> = candidates
1433            .iter()
1434            .map(|(e, a, _)| candidate_key(e, a))
1435            .collect();
1436        keys.sort();
1437        keys.dedup();
1438        next_rotation_batch(
1439            &cache_root,
1440            &binding_id,
1441            ROTATION_ANCHOR_ADJUDICATION,
1442            keys,
1443            cap as usize,
1444        )
1445        .map(|b| b.files.into_iter().collect())
1446    };
1447    findings.extend(adjudicate_candidates(
1448        &key,
1449        &facet,
1450        &candidates,
1451        window.as_ref(),
1452        &now,
1453    ));
1454
1455    // 2. Sample in-scope source artifacts for coverage. When a full walk is due
1456    //    (D3) or explicitly requested (`Forced`), enumerate the WHOLE source of
1457    //    every enumerable facet — guaranteeing complete coverage this run;
1458    //    otherwise sample a bounded rotating window (D2). Non-enumerable facets
1459    //    are refused (scheduled: the typed refusal rides on `full_resync`;
1460    //    explicit: the whole run refused before observing), never silently
1461    //    claimed as covered.
1462    let sample_files: Vec<String> = if full_resync.is_full_walk() {
1463        let mut all: Vec<String> = Vec::new();
1464        for source in &resolved.sources {
1465            if let ResolvedSource::Primary(p) = source
1466                && medium_capabilities(p.medium_type).enumerable
1467            {
1468                all.extend(enumerate_source_artifacts(
1469                    engine,
1470                    p,
1471                    &resolved.deny_paths,
1472                    workspace_root,
1473                ));
1474            }
1475        }
1476        all.sort();
1477        all.dedup();
1478        all
1479    } else {
1480        next_batch(engine, resolved, workspace_root, &cache_root, sample_batch)
1481            .map(|b| b.files)
1482            .unwrap_or_default()
1483    };
1484    let covered_now = |artifact: &str| {
1485        engine
1486            .anchors_referencing_artifact(artifact)
1487            .iter()
1488            .any(|(eid, _)| eid.mem() == resolved.destination_mem.as_str())
1489    };
1490    for file in &sample_files {
1491        if !covered_now(file) {
1492            findings.push(Finding {
1493                key: key.clone(),
1494                facet: facet.clone(),
1495                target: FindingTarget::Artifact {
1496                    artifact: file.clone(),
1497                },
1498                class: FindingClass::Uncovered,
1499                detail: "source artifact in scope has no anchor in the destination mem".to_string(),
1500                created_at: now.clone(),
1501            });
1502        }
1503    }
1504
1505    // 3. Head-durable merge (the store keys on hash(D) alone): fold the prior
1506    //    open batch into this pass's findings — re-observed targets take this
1507    //    pass's outcome; unobserved-but-still-open ones carry forward with
1508    //    their original observed head; departed/covered/vanished subjects
1509    //    close. Sync briefs thus keep presenting an open finding across
1510    //    source-head movement until a pass observes it clean.
1511    let mut store = read_findings_store(workspace_root, &mem, &name)
1512        .map_err(FindingsError::Store)?
1513        .unwrap_or_else(|| FindingsStore {
1514            binding: binding_id.clone(),
1515            ..Default::default()
1516        });
1517    let mut s_d: BTreeSet<String> = BTreeSet::new();
1518    for source in &resolved.sources {
1519        if let ResolvedSource::Primary(p) = source
1520            && medium_capabilities(p.medium_type).enumerable
1521        {
1522            s_d.extend(enumerate_source_artifacts(
1523                engine,
1524                p,
1525                &resolved.deny_paths,
1526                workspace_root,
1527            ));
1528        }
1529    }
1530    let obs = PassObservation {
1531        anchors_observed,
1532        anchors_existing,
1533        files_observed: sample_files.into_iter().collect(),
1534        s_d,
1535    };
1536    let prior = store.current(&key).to_vec();
1537    let findings = merge_with_prior(findings, &prior, &obs, covered_now);
1538
1539    let backlog = findings
1540        .iter()
1541        .filter(|f| f.class == FindingClass::QueuedForAdjudication)
1542        .count();
1543
1544    // Record under the current key (prior-hash batches retained, segregated —
1545    // A3), persist to the durable state tier (A1).
1546    let recorded = findings.len();
1547    store.record(key.clone(), now, findings);
1548    let superseded = store.superseded(&key).len();
1549    write_findings_store(workspace_root, &mem, &name, &store).map_err(FindingsError::Store)?;
1550
1551    Ok(VerifyOutcome {
1552        binding: binding_id,
1553        key,
1554        recorded,
1555        superseded,
1556        backlog,
1557        full_resync,
1558        facet_heads,
1559        hash_backfill,
1560    })
1561}
1562
1563/// The medium type's wire string (`codebase` / `web` / …) — the serde form the
1564/// capability matrix and reports use.
1565fn medium_type_wire(t: crate::pipeline::MediumType) -> String {
1566    serde_json::to_value(t)
1567        .ok()
1568        .and_then(|v| v.as_str().map(str::to_string))
1569        .unwrap_or_default()
1570}
1571
1572#[cfg(test)]
1573mod tests {
1574    use super::*;
1575    use crate::anchor::{Anchor, AnchorGrain, AnchorHashStability, AnchorProvenanceClass};
1576
1577    fn key(hash: &str, head: &str) -> FindingKey {
1578        FindingKey {
1579            binding_hash: hash.to_string(),
1580            source_head: head.to_string(),
1581        }
1582    }
1583
1584    fn anchor(class: AnchorProvenanceClass) -> Anchor {
1585        Anchor {
1586            artifact: "src/lib.rs".to_string(),
1587            grain: AnchorGrain::File,
1588            class,
1589            at_version: None,
1590            hash: if class.is_hash_bearing() {
1591                Some("h1".to_string())
1592            } else {
1593                None
1594            },
1595            hash_stability: AnchorHashStability::Stable,
1596            derived_from: Vec::new(),
1597            binding: None,
1598            source: None,
1599        }
1600    }
1601
1602    /// The store round-trips through serde and survives a write/read cycle on
1603    /// disk — the durability A1 rests on.
1604    #[test]
1605    fn store_round_trips_on_disk_and_delete_is_idempotent() {
1606        let tmp = tempfile::tempdir().unwrap();
1607        let root = tmp.path();
1608        assert!(
1609            read_findings_store(root, "engine", "graph")
1610                .unwrap()
1611                .is_none()
1612        );
1613
1614        let mut store = FindingsStore {
1615            binding: "engine/graph".to_string(),
1616            ..Default::default()
1617        };
1618        let k = key("hashA", "head1");
1619        store.record(
1620            k.clone(),
1621            "1".to_string(),
1622            vec![Finding {
1623                key: k.clone(),
1624                facet: "src".to_string(),
1625                target: FindingTarget::Artifact {
1626                    artifact: "src/a.rs".to_string(),
1627                },
1628                class: FindingClass::Uncovered,
1629                detail: "d".to_string(),
1630                created_at: "1".to_string(),
1631            }],
1632        );
1633        write_findings_store(root, "engine", "graph", &store).unwrap();
1634        assert!(findings_store_path(root, "engine", "graph").exists());
1635
1636        // The store subtree self-ignores: per-checkout engine state must
1637        // not surface as untracked noise in a tracked workspace.
1638        let ignore = root
1639            .join(WORKSPACE_STORE_DIR)
1640            .join(STATE_DIR)
1641            .join(FINDINGS_DIR)
1642            .join(".gitignore");
1643        assert_eq!(std::fs::read_to_string(&ignore).unwrap(), "*\n");
1644
1645        // Fresh read from disk (a later process) sees the findings (A1).
1646        let back = read_findings_store(root, "engine", "graph")
1647            .unwrap()
1648            .unwrap();
1649        assert_eq!(back, store);
1650        assert_eq!(back.current(&k).len(), 1);
1651
1652        delete_findings_store(root, "engine", "graph").unwrap();
1653        assert!(
1654            read_findings_store(root, "engine", "graph")
1655                .unwrap()
1656                .is_none()
1657        );
1658        // Idempotent.
1659        delete_findings_store(root, "engine", "graph").unwrap();
1660    }
1661
1662    /// A3 — a changed `hash(D)` segregates the prior batch: findings under the
1663    /// old hash are never `current` under the new key, only `superseded`.
1664    #[test]
1665    fn changed_binding_hash_supersedes_prior_findings() {
1666        let mut store = FindingsStore::default();
1667        let old = key("hashOLD", "head1");
1668        let new = key("hashNEW", "head1");
1669        let f_old = Finding {
1670            key: old.clone(),
1671            facet: "src".to_string(),
1672            target: FindingTarget::Artifact {
1673                artifact: "src/old.rs".to_string(),
1674            },
1675            class: FindingClass::Uncovered,
1676            detail: "old".to_string(),
1677            created_at: "1".to_string(),
1678        };
1679        store.record(old.clone(), "1".to_string(), vec![f_old.clone()]);
1680
1681        // Recording under the new key must not touch the old batch.
1682        store.record(new.clone(), "2".to_string(), Vec::new());
1683        assert!(store.current(&new).is_empty(), "new key has its own view");
1684        let superseded = store.superseded(&new);
1685        assert_eq!(superseded.len(), 1, "old batch is segregated as superseded");
1686        assert_eq!(superseded[0], &f_old);
1687        // The old findings are never presented as current under the new key.
1688        assert!(!store.current(&new).contains(&f_old));
1689    }
1690
1691    /// Criterion — findings survive head movement: the store keys on `hash(D)`
1692    /// alone, so a finding recorded at head1 stays `current` when read at
1693    /// head2 (the sync brief's read is head-agnostic), still carrying the head
1694    /// it was observed at as metadata. REFUSAL half: recording the hash's next
1695    /// batch (verify's post-merge write) replaces it — a finding absent from
1696    /// that batch (resolved) never re-presents, at any head.
1697    #[test]
1698    fn moved_source_head_keeps_findings_current_until_superseded() {
1699        let mut store = FindingsStore::default();
1700        let before = key("hashA", "head1");
1701        let after = key("hashA", "head2");
1702        let f = Finding {
1703            key: before.clone(),
1704            facet: "src".to_string(),
1705            target: FindingTarget::Anchor {
1706                entity: "engine--e".to_string(),
1707                artifact: "src/x.rs".to_string(),
1708            },
1709            class: FindingClass::UnresolvableAnchor,
1710            detail: "gone".to_string(),
1711            created_at: "1".to_string(),
1712        };
1713        store.record(before.clone(), "1".to_string(), vec![f.clone()]);
1714
1715        // The head moved; the finding is still presented, with its observed
1716        // head intact, and it is not "superseded".
1717        assert_eq!(store.current(&after), std::slice::from_ref(&f));
1718        assert_eq!(store.current(&after)[0].key.source_head, "head1");
1719        assert!(store.superseded(&after).is_empty());
1720
1721        // A verify at head2 records the hash's next batch WITHOUT the finding
1722        // (its target observed clean) → resolved, never re-presented.
1723        store.record(after.clone(), "2".to_string(), Vec::new());
1724        assert!(store.current(&after).is_empty());
1725        assert!(store.current(&before).is_empty(), "at the old head too");
1726        assert_eq!(store.batches.len(), 1, "one batch per hash(D)");
1727    }
1728
1729    /// Migration/compat — a store written by the pre-re-key engine (batches
1730    /// keyed `(hash(D), source_head)`; the exact on-disk shape live dogfood
1731    /// workspaces carry) loads without loss: the other-hash batch stays
1732    /// segregated as superseded, the current-hash batch presents at ANY head,
1733    /// and a legacy same-hash pair collapses to its latest-recorded batch —
1734    /// never resurrecting the older (superseded-at-write-time) one. The next
1735    /// `record` folds the same-hash siblings into one batch.
1736    #[test]
1737    fn legacy_per_head_store_loads_and_presents_head_agnostically() {
1738        let tmp = tempfile::tempdir().unwrap();
1739        let root = tmp.path();
1740        let path = findings_store_path(root, "engine", "graph");
1741        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1742        // Trimmed replica of the live on-disk format: `{binding, batches:[{key:
1743        // {binding_hash, source_head}, recorded_at, findings:[{key, facet,
1744        // target:{kind,...}, class, detail, created_at}]}]}` — one batch under
1745        // an old hash, two batches under the current hash at different heads.
1746        std::fs::write(
1747            &path,
1748            r#"{
1749              "binding": "engine/graph",
1750              "batches": [
1751                {
1752                  "key": { "binding_hash": "hashOLD", "source_head": "src=aaa" },
1753                  "recorded_at": "100",
1754                  "findings": [
1755                    {
1756                      "key": { "binding_hash": "hashOLD", "source_head": "src=aaa" },
1757                      "facet": "src",
1758                      "target": { "kind": "artifact", "artifact": "src/old.rs" },
1759                      "class": "uncovered",
1760                      "detail": "old declaration",
1761                      "created_at": "100"
1762                    }
1763                  ]
1764                },
1765                {
1766                  "key": { "binding_hash": "hashCUR", "source_head": "src=bbb" },
1767                  "recorded_at": "200",
1768                  "findings": [
1769                    {
1770                      "key": { "binding_hash": "hashCUR", "source_head": "src=bbb" },
1771                      "facet": "src",
1772                      "target": { "kind": "artifact", "artifact": "src/resolved-at-ccc.rs" },
1773                      "class": "uncovered",
1774                      "detail": "was open at bbb, absent from the ccc batch",
1775                      "created_at": "200"
1776                    }
1777                  ]
1778                },
1779                {
1780                  "key": { "binding_hash": "hashCUR", "source_head": "src=ccc" },
1781                  "recorded_at": "300",
1782                  "findings": [
1783                    {
1784                      "key": { "binding_hash": "hashCUR", "source_head": "src=ccc" },
1785                      "facet": "src",
1786                      "target": { "kind": "anchor", "entity": "engine--e", "artifact": "src/x.rs" },
1787                      "class": "unresolvable-anchor",
1788                      "detail": "gone",
1789                      "created_at": "300"
1790                    }
1791                  ]
1792                }
1793              ]
1794            }"#,
1795        )
1796        .unwrap();
1797
1798        let mut store = read_findings_store(root, "engine", "graph")
1799            .unwrap()
1800            .expect("the legacy on-disk format loads as-is");
1801        assert_eq!(store.binding, "engine/graph");
1802        assert_eq!(store.batches.len(), 3, "loaded without loss");
1803
1804        // Head-agnostic current view: reading at a NEWLY moved head (ddd —
1805        // recorded nowhere) presents the latest current-hash batch.
1806        let now = key("hashCUR", "src=ddd");
1807        let current = store.current(&now);
1808        assert_eq!(current.len(), 1);
1809        assert_eq!(current[0].detail, "gone");
1810        assert_eq!(
1811            current[0].key.source_head, "src=ccc",
1812            "the finding keeps the head it was observed at"
1813        );
1814        // The pre-re-key superseded batches (old hash + the older same-hash
1815        // head) stay segregated — never mixed into the current view.
1816        let superseded = store.superseded(&now);
1817        assert_eq!(superseded.len(), 2);
1818        assert!(
1819            !current.iter().any(|f| f.detail.contains("was open at bbb")),
1820            "the older same-hash batch was superseded at write time and is not resurrected"
1821        );
1822
1823        // The next record under the current hash collapses the legacy
1824        // same-hash pair into one batch; the old-hash batch is untouched.
1825        store.record(now.clone(), "400".to_string(), Vec::new());
1826        assert_eq!(store.batches.len(), 2, "hashCUR collapsed, hashOLD kept");
1827        assert_eq!(store.superseded(&now).len(), 1);
1828    }
1829
1830    /// The head-durable merge: an unobserved-but-still-open prior finding
1831    /// carries forward (original observed head intact); a prior finding whose
1832    /// artifact left `S(D)`, gained coverage, or whose anchor vanished closes;
1833    /// a re-observed target takes this pass's outcome (clean → closed).
1834    #[test]
1835    fn merge_carries_unobserved_open_findings_and_closes_departed() {
1836        let k_old = key("h", "head1");
1837        let mk_artifact = |artifact: &str, detail: &str| Finding {
1838            key: k_old.clone(),
1839            facet: "src".to_string(),
1840            target: FindingTarget::Artifact {
1841                artifact: artifact.to_string(),
1842            },
1843            class: FindingClass::Uncovered,
1844            detail: detail.to_string(),
1845            created_at: "1".to_string(),
1846        };
1847        let anchor_finding = Finding {
1848            key: k_old.clone(),
1849            facet: "src".to_string(),
1850            target: FindingTarget::Anchor {
1851                entity: "engine--gone".to_string(),
1852                artifact: "src/gone.rs".to_string(),
1853            },
1854            class: FindingClass::UnresolvableAnchor,
1855            detail: "anchor since removed from the mem".to_string(),
1856            created_at: "1".to_string(),
1857        };
1858        let prior = vec![
1859            mk_artifact("src/unsampled.rs", "still open, not in this window"),
1860            mk_artifact("src/departed.rs", "left S(D)"),
1861            mk_artifact("src/now-covered.rs", "gained an anchor since"),
1862            mk_artifact("src/observed-clean.rs", "re-sampled and now covered"),
1863            anchor_finding,
1864        ];
1865        let obs = PassObservation {
1866            anchors_observed: BTreeSet::new(),
1867            anchors_existing: BTreeSet::new(), // the anchor vanished
1868            files_observed: ["src/observed-clean.rs".to_string()].into(),
1869            s_d: [
1870                "src/unsampled.rs".to_string(),
1871                "src/now-covered.rs".to_string(),
1872                "src/observed-clean.rs".to_string(),
1873            ]
1874            .into(),
1875        };
1876        let merged = merge_with_prior(Vec::new(), &prior, &obs, |artifact| {
1877            artifact == "src/now-covered.rs" || artifact == "src/observed-clean.rs"
1878        });
1879        assert_eq!(merged.len(), 1, "only the still-open unsampled one carries");
1880        assert_eq!(
1881            merged[0].target,
1882            FindingTarget::Artifact {
1883                artifact: "src/unsampled.rs".to_string()
1884            }
1885        );
1886        assert_eq!(
1887            merged[0].key.source_head, "head1",
1888            "a carried finding keeps the head it was observed at"
1889        );
1890    }
1891
1892    /// Supersession honesty: a fresh `queued-for-adjudication` entry is a
1893    /// scheduling deferral, not an observation — it never downgrades a prior
1894    /// substantive `drifted` verdict for the same target. A fresh substantive
1895    /// outcome (or a clean observation) still supersedes normally.
1896    #[test]
1897    fn merge_deferral_never_downgrades_prior_adjudication() {
1898        let k_old = key("h", "head1");
1899        let k_new = key("h", "head2");
1900        let target = FindingTarget::Anchor {
1901            entity: "engine--e".to_string(),
1902            artifact: "src/x.rs".to_string(),
1903        };
1904        let prior_drifted = Finding {
1905            key: k_old.clone(),
1906            facet: "src".to_string(),
1907            target: target.clone(),
1908            class: FindingClass::Drifted,
1909            detail: "adjudicated drifted at head1".to_string(),
1910            created_at: "1".to_string(),
1911        };
1912        let fresh_queued = Finding {
1913            key: k_new.clone(),
1914            facet: "src".to_string(),
1915            target: target.clone(),
1916            class: FindingClass::QueuedForAdjudication,
1917            detail: "deferred by the cap this run".to_string(),
1918            created_at: "2".to_string(),
1919        };
1920        let obs = PassObservation {
1921            anchors_observed: [target_key(&target)].into(),
1922            anchors_existing: [target_key(&target)].into(),
1923            files_observed: BTreeSet::new(),
1924            s_d: BTreeSet::new(),
1925        };
1926        let merged = merge_with_prior(
1927            vec![fresh_queued],
1928            std::slice::from_ref(&prior_drifted),
1929            &obs,
1930            |_| true,
1931        );
1932        assert_eq!(merged.len(), 1);
1933        assert_eq!(
1934            merged[0].class,
1935            FindingClass::Drifted,
1936            "the prior verdict stands over a deferral"
1937        );
1938        assert_eq!(merged[0].key.source_head, "head1");
1939    }
1940
1941    /// A2 — hash-drift adjudication is excluded for `informed-by` (and every
1942    /// non-hash-bearing class): a drifted/recheck state yields NO finding.
1943    #[test]
1944    fn informed_by_anchor_never_drifts() {
1945        let k = key("h", "s");
1946        for class in [
1947            AnchorProvenanceClass::InformedBy,
1948            AnchorProvenanceClass::Authored,
1949        ] {
1950            let a = anchor(class);
1951            assert!(
1952                adjudicate_anchor(&k, "f", "engine--e", &a, AnchorState::Drifted, "1").is_none(),
1953                "{class:?} must not produce a drift finding"
1954            );
1955            assert!(
1956                adjudicate_anchor(&k, "f", "engine--e", &a, AnchorState::Recheck, "1").is_none(),
1957                "{class:?} must not produce a queued finding"
1958            );
1959        }
1960    }
1961
1962    /// A2 — hash-bearing classes DO produce drift/recheck findings, and every
1963    /// class produces an existence (`unresolvable-anchor`) finding when orphaned.
1964    #[test]
1965    fn hash_bearing_drifts_and_orphan_is_class_independent() {
1966        let k = key("h", "s");
1967        let anchored = anchor(AnchorProvenanceClass::Anchored);
1968        let drifted =
1969            adjudicate_anchor(&k, "f", "engine--e", &anchored, AnchorState::Drifted, "1").unwrap();
1970        assert_eq!(drifted.class, FindingClass::Drifted);
1971        assert_eq!(drifted.key, k, "the finding carries its recording key (A2)");
1972
1973        let queued =
1974            adjudicate_anchor(&k, "f", "engine--e", &anchored, AnchorState::Recheck, "1").unwrap();
1975        assert_eq!(queued.class, FindingClass::QueuedForAdjudication);
1976
1977        // Orphaned is existence, not hash-drift — reported for informed-by too.
1978        let informed = anchor(AnchorProvenanceClass::InformedBy);
1979        let orphan =
1980            adjudicate_anchor(&k, "f", "engine--e", &informed, AnchorState::Orphaned, "1").unwrap();
1981        assert_eq!(orphan.class, FindingClass::UnresolvableAnchor);
1982
1983        // Resolves yields nothing.
1984        assert!(
1985            adjudicate_anchor(&k, "f", "engine--e", &anchored, AnchorState::Resolves, "1")
1986                .is_none()
1987        );
1988    }
1989
1990    /// The finding class vocabulary round-trips through its wire form.
1991    #[test]
1992    fn finding_class_wire_round_trips() {
1993        for w in FindingClass::WIRE_VALUES {
1994            let c = FindingClass::from_wire(w).expect("known wire value");
1995            assert_eq!(c.as_wire(), *w);
1996        }
1997        assert!(FindingClass::from_wire("nonsense").is_none());
1998    }
1999
2000    /// A malformed binding id refuses before touching the store tier.
2001    #[test]
2002    fn malformed_binding_id_refuses() {
2003        assert!(matches!(
2004            split_binding_id("../escape"),
2005            Err(FindingsError::MalformedId(_))
2006        ));
2007        assert!(matches!(
2008            split_binding_id("no-slash"),
2009            Err(FindingsError::MalformedId(_))
2010        ));
2011        assert_eq!(
2012            split_binding_id("engine/graph").unwrap(),
2013            ("engine".to_string(), "graph".to_string())
2014        );
2015    }
2016
2017    // ---- A1/A5 end-to-end: verify writes durable findings, no entity write --
2018
2019    use crate::anchor::AnchorSidecar;
2020    use crate::binding::{
2021        BINDING_VERSION, BuildMode, BuildOperation, CoverageSemantics, DEFAULT_ADJUDICATION_CAP,
2022        DEFAULT_FULL_RESYNC_EVERY, Operations, VerifyOperation,
2023    };
2024    use crate::ingest::resolve::resolve_binding_run;
2025    use crate::pipeline::{IngestTrigger, MediumType, PatternEntry, PatternMode};
2026    use crate::pipeline_store::{load_pipeline_configs, write_binding};
2027    use crate::workspace::{
2028        Mount, MountCapability, MountLifecycle, MountStorage, Workspace, WorkspaceSettings,
2029    };
2030    use crate::workspace_store::WorkspaceStoreAdapter;
2031
2032    /// A full verify pass over a folder mem: it adjudicates the mem's anchors
2033    /// against the live source (orphaned → unresolvable-anchor; present
2034    /// hash-bearing whose recorded hash mismatches the observed prepared form
2035    /// → deterministic `drifted`; informed-by → no finding, A2) and flags an
2036    /// uncovered source file, then persists the findings to the durable state
2037    /// tier. A **fresh** read from disk (a later process) sees them (A1). The
2038    /// pass runs on a shared `&Engine` — structurally read-only on the mem (A5).
2039    #[test]
2040    fn verify_persists_findings_readable_fresh() {
2041        let tmp = tempfile::tempdir().unwrap();
2042        let root = tmp.path();
2043        let mem_dir = root.join("mem");
2044        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
2045        std::fs::write(
2046            mem_dir.join(".memstead").join("config.json"),
2047            r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
2048        )
2049        .unwrap();
2050
2051        // Workspace state so `from_workspace_root` sets `workspace_root` (which
2052        // the anchor observation and cursor need) and mounts the `engine` mem.
2053        std::fs::create_dir_all(root.join(".memstead")).unwrap();
2054        std::fs::write(
2055            root.join(".memstead").join("workspace.toml"),
2056            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2057        )
2058        .unwrap();
2059        let mount = Mount {
2060            mem: "engine".to_string(),
2061            schema: Some("default@1.0.0".parse().unwrap()),
2062            storage: MountStorage::Folder {
2063                path: mem_dir.clone(),
2064            },
2065            capability: MountCapability::Write,
2066            lifecycle: MountLifecycle::Eager,
2067            cross_linkable: false,
2068            migration_target: None,
2069        };
2070        crate::FileWorkspaceStore::new()
2071            .save_state(
2072                root,
2073                &Workspace {
2074                    mounts: vec![mount],
2075                    settings: WorkspaceSettings::default(),
2076                },
2077            )
2078            .unwrap();
2079
2080        // A git work tree at the workspace root so the codebase medium's `git`
2081        // change strategy resolves; source files: one anchored+present, one
2082        // uncovered.
2083        let out = std::process::Command::new("git")
2084            .args(["init", "-q"])
2085            .current_dir(root)
2086            .output()
2087            .unwrap();
2088        assert!(out.status.success());
2089        std::fs::create_dir_all(root.join("src")).unwrap();
2090        std::fs::write(root.join("src").join("present.rs"), "fn a() {}\n").unwrap();
2091        std::fs::write(root.join("src").join("uncovered.rs"), "fn b() {}\n").unwrap();
2092
2093        // Seed the engine-owned anchors sidecar directly (test fixture — the
2094        // production write path is the mutation surface, not this verify code).
2095        let mk = |artifact: &str, class: AnchorProvenanceClass| Anchor {
2096            artifact: artifact.to_string(),
2097            grain: AnchorGrain::File,
2098            class,
2099            at_version: None,
2100            hash: class.is_hash_bearing().then(|| "recorded".to_string()),
2101            hash_stability: AnchorHashStability::Stable,
2102            derived_from: Vec::new(),
2103            binding: None,
2104            source: None,
2105        };
2106        let mut sidecar = AnchorSidecar::default();
2107        sidecar.set(
2108            "engine--e",
2109            vec![
2110                mk("src/present.rs", AnchorProvenanceClass::Anchored), // recorded hash mismatches prepared form → drifted
2111                mk("src/gone.rs", AnchorProvenanceClass::Anchored), // absent → unresolvable-anchor
2112                mk("src/present.rs", AnchorProvenanceClass::InformedBy), // present, non-hash → no finding (A2)
2113            ],
2114        );
2115        std::fs::write(
2116            mem_dir.join(crate::anchor::ANCHOR_SIDECAR_PATH),
2117            sidecar.to_bytes(),
2118        )
2119        .unwrap();
2120
2121        // Binding engine/graph over a codebase facet (medium root = workspace).
2122        write_binding(
2123            root,
2124            "engine",
2125            "graph",
2126            &Binding {
2127                version: BINDING_VERSION,
2128                intent: None,
2129                sources: vec![crate::pipeline::Source {
2130                    name: "graph".to_string(),
2131                    medium_type: MediumType::Codebase,
2132                    pointer: String::new(),
2133                    change_detection: Some("git".to_string()),
2134                    scope: vec![PatternEntry {
2135                        path: "src/**/*.rs".to_string(),
2136                        mode: PatternMode::Allow,
2137                    }],
2138                    engagement: None,
2139                    preparation: None,
2140                }],
2141                reference_mems: Vec::new(),
2142                destination_mem: "engine".to_string(),
2143                deny_paths: Vec::new(),
2144                coverage_semantics: None,
2145                rules: None,
2146                prune: None,
2147                operations: Operations {
2148                    build: Some(BuildOperation {
2149                        mode: BuildMode::Discovery,
2150                        trigger: IngestTrigger::Loop,
2151                        batch_size: 20,
2152                        post_actions: None,
2153                    }),
2154                    sync: None,
2155                    verify: Some(VerifyOperation {
2156                        trigger: IngestTrigger::Manual,
2157                        batch_size: 20,
2158                        adjudication_cap: DEFAULT_ADJUDICATION_CAP,
2159                        full_resync_every: DEFAULT_FULL_RESYNC_EVERY,
2160                    }),
2161                },
2162            },
2163        )
2164        .unwrap();
2165
2166        let engine = Engine::from_workspace_root(root).unwrap();
2167
2168        let configs = load_pipeline_configs(root).unwrap();
2169        let binding = &configs.bindings[0].config;
2170        let resolved = resolve_binding_run("engine/graph", binding).unwrap();
2171
2172        // `&engine` — shared borrow, structurally cannot mutate the mem (A5).
2173        let outcome = verify_binding(&engine, root, binding, &resolved).unwrap();
2174        assert!(
2175            outcome.recorded >= 3,
2176            "orphan + drifted + uncovered at least"
2177        );
2178        assert_eq!(outcome.superseded, 0, "no prior key yet");
2179        assert_eq!(
2180            outcome.backlog, 0,
2181            "the mismatching hash adjudicated deterministically — nothing queued"
2182        );
2183        assert!(
2184            outcome.hash_backfill.is_empty(),
2185            "every hash-bearing anchor already carries a recorded hash — nothing to backfill"
2186        );
2187
2188        // Fresh read from disk — a later process / sync-brief render (A1).
2189        let store = read_findings_store(root, "engine", "graph")
2190            .unwrap()
2191            .unwrap();
2192        let current = store.current(&outcome.key);
2193        assert_eq!(current.len(), outcome.recorded);
2194
2195        let has = |c: FindingClass, art: &str| {
2196            current.iter().any(|f| {
2197                f.class == c
2198                    && match &f.target {
2199                        FindingTarget::Anchor { artifact, .. } => artifact == art,
2200                        FindingTarget::Artifact { artifact } => artifact == art,
2201                    }
2202            })
2203        };
2204        assert!(has(FindingClass::UnresolvableAnchor, "src/gone.rs"));
2205        assert!(
2206            has(FindingClass::Drifted, "src/present.rs"),
2207            "recorded-hash mismatch on a stable medium adjudicates drifted deterministically"
2208        );
2209        assert!(has(FindingClass::Uncovered, "src/uncovered.rs"));
2210        // A2: the informed-by anchor on the present file produced no finding —
2211        // the one drifted finding above belongs to the anchored (hash-bearing)
2212        // anchor, and nothing queued.
2213        assert!(
2214            !current
2215                .iter()
2216                .any(|f| f.class == FindingClass::QueuedForAdjudication
2217                    || f.class == FindingClass::Wrong),
2218            "deterministic adjudication leaves nothing queued"
2219        );
2220        // The covered file is not flagged uncovered.
2221        assert!(!has(FindingClass::Uncovered, "src/present.rs"));
2222    }
2223
2224    /// Criterion, end-to-end — **findings survive head movement**: a finding
2225    /// recorded at head H keeps presenting through the sync brief's read
2226    /// (`current_findings` / `render_sync_brief_for`) after the source
2227    /// advances to H′, until a verify observes its subject clean — and once
2228    /// resolved it never re-presents, at any head.
2229    #[test]
2230    fn finding_recorded_at_old_head_presents_in_brief_at_new_head() {
2231        use crate::ingest::render::render_sync_brief_for;
2232
2233        let tmp = tempfile::tempdir().unwrap();
2234        let root = tmp.path();
2235        let mem_dir = root.join("mem");
2236        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
2237        std::fs::write(
2238            mem_dir.join(".memstead").join("config.json"),
2239            r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
2240        )
2241        .unwrap();
2242        std::fs::create_dir_all(root.join(".memstead")).unwrap();
2243        std::fs::write(
2244            root.join(".memstead").join("workspace.toml"),
2245            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2246        )
2247        .unwrap();
2248        let mount = Mount {
2249            mem: "engine".to_string(),
2250            schema: Some("default@1.0.0".parse().unwrap()),
2251            storage: MountStorage::Folder {
2252                path: mem_dir.clone(),
2253            },
2254            capability: MountCapability::Write,
2255            lifecycle: MountLifecycle::Eager,
2256            cross_linkable: false,
2257            migration_target: None,
2258        };
2259        crate::FileWorkspaceStore::new()
2260            .save_state(
2261                root,
2262                &Workspace {
2263                    mounts: vec![mount],
2264                    settings: WorkspaceSettings::default(),
2265                },
2266            )
2267            .unwrap();
2268
2269        // Git source tree at head A: src/present.rs committed.
2270        let git = |args: &[&str]| {
2271            let out = std::process::Command::new("git")
2272                .args(args)
2273                .current_dir(root)
2274                .env("GIT_AUTHOR_NAME", "t")
2275                .env("GIT_AUTHOR_EMAIL", "t@t")
2276                .env("GIT_COMMITTER_NAME", "t")
2277                .env("GIT_COMMITTER_EMAIL", "t@t")
2278                .output()
2279                .unwrap();
2280            assert!(
2281                out.status.success(),
2282                "git {args:?}: {}",
2283                String::from_utf8_lossy(&out.stderr)
2284            );
2285        };
2286        git(&["init", "-q"]);
2287        std::fs::create_dir_all(root.join("src")).unwrap();
2288        std::fs::write(root.join("src").join("present.rs"), "fn a() {}\n").unwrap();
2289        git(&["add", "-A"]);
2290        git(&["commit", "-qm", "head-a"]);
2291
2292        // Anchors: `informed-by` on the present file (clean, non-hash — no
2293        // finding) and on the ABSENT src/gone.rs (orphaned → the finding).
2294        let mk = |artifact: &str| Anchor {
2295            artifact: artifact.to_string(),
2296            grain: AnchorGrain::File,
2297            class: AnchorProvenanceClass::InformedBy,
2298            at_version: None,
2299            hash: None,
2300            hash_stability: AnchorHashStability::Stable,
2301            derived_from: Vec::new(),
2302            binding: None,
2303            source: None,
2304        };
2305        let mut sidecar = AnchorSidecar::default();
2306        sidecar.set("engine--e", vec![mk("src/present.rs"), mk("src/gone.rs")]);
2307        std::fs::write(
2308            mem_dir.join(crate::anchor::ANCHOR_SIDECAR_PATH),
2309            sidecar.to_bytes(),
2310        )
2311        .unwrap();
2312
2313        write_binding(
2314            root,
2315            "engine",
2316            "graph",
2317            &Binding {
2318                version: BINDING_VERSION,
2319                intent: None,
2320                sources: vec![crate::pipeline::Source {
2321                    name: "graph".to_string(),
2322                    medium_type: MediumType::Codebase,
2323                    pointer: String::new(),
2324                    change_detection: Some("git".to_string()),
2325                    scope: vec![PatternEntry {
2326                        path: "src/**/*.rs".to_string(),
2327                        mode: PatternMode::Allow,
2328                    }],
2329                    engagement: None,
2330                    preparation: None,
2331                }],
2332                reference_mems: Vec::new(),
2333                destination_mem: "engine".to_string(),
2334                deny_paths: Vec::new(),
2335                coverage_semantics: None,
2336                rules: None,
2337                prune: None,
2338                operations: Operations {
2339                    build: None,
2340                    sync: Some(crate::binding::SyncOperation {
2341                        trigger: IngestTrigger::Manual,
2342                        batch_size: 20,
2343                    }),
2344                    verify: Some(VerifyOperation {
2345                        trigger: IngestTrigger::Manual,
2346                        batch_size: 20,
2347                        adjudication_cap: DEFAULT_ADJUDICATION_CAP,
2348                        full_resync_every: DEFAULT_FULL_RESYNC_EVERY,
2349                    }),
2350                },
2351            },
2352        )
2353        .unwrap();
2354
2355        // Verify at head A — records the orphaned-anchor finding.
2356        let configs = load_pipeline_configs(root).unwrap();
2357        let binding = &configs.bindings[0].config;
2358        let resolved = resolve_binding_run("engine/graph", binding).unwrap();
2359        let head_a_outcome = {
2360            let engine = Engine::from_workspace_root(root).unwrap();
2361            verify_binding(&engine, root, binding, &resolved).unwrap()
2362        };
2363        assert!(
2364            head_a_outcome.key.source_head.contains("graph="),
2365            "the run observed a facet head"
2366        );
2367
2368        // The source moves to head B.
2369        std::fs::write(root.join("src").join("present.rs"), "fn a() {} // more\n").unwrap();
2370        git(&["add", "-A"]);
2371        git(&["commit", "-qm", "head-b"]);
2372
2373        // A fresh process at head B: the finding recorded at head A is still
2374        // presented — by the brief's read AND in the rendered sync brief.
2375        {
2376            let engine = Engine::from_workspace_root(root).unwrap();
2377            let (key_b, findings) = current_findings(&engine, root, binding, &resolved).unwrap();
2378            assert_ne!(
2379                key_b.source_head, head_a_outcome.key.source_head,
2380                "the head really moved"
2381            );
2382            assert_eq!(findings.len(), 1);
2383            assert_eq!(findings[0].class, FindingClass::UnresolvableAnchor);
2384            assert_eq!(
2385                findings[0].key.source_head, head_a_outcome.key.source_head,
2386                "the finding still records the head it was observed at"
2387            );
2388
2389            let brief = render_sync_brief_for(&engine, root, "engine/graph").unwrap();
2390            assert!(brief.contains("## Open findings to repair"));
2391            assert!(brief.contains("src/gone.rs"));
2392        }
2393
2394        // The repair lands: src/gone.rs exists again (head C). A verify
2395        // observes the anchor clean → the finding closes…
2396        std::fs::write(root.join("src").join("gone.rs"), "fn g() {}\n").unwrap();
2397        git(&["add", "-A"]);
2398        git(&["commit", "-qm", "head-c"]);
2399        {
2400            let engine = Engine::from_workspace_root(root).unwrap();
2401            verify_binding(&engine, root, binding, &resolved).unwrap();
2402        }
2403        // …and never re-presents (REFUSAL: resolved findings stay resolved).
2404        {
2405            let engine = Engine::from_workspace_root(root).unwrap();
2406            let (_key, findings) = current_findings(&engine, root, binding, &resolved).unwrap();
2407            assert!(
2408                findings
2409                    .iter()
2410                    .all(|f| f.class != FindingClass::UnresolvableAnchor),
2411                "the resolved orphan finding must not re-present: {findings:?}"
2412            );
2413        }
2414    }
2415
2416    /// Prepared-hash backfill + deterministic drift, end-to-end over real git
2417    /// heads and fresh engines:
2418    ///
2419    /// 1. a hash-less `anchored`/`derived` anchor on a resolvable artifact is
2420    ///    backfilled by the first verify (once — a re-verify observes an empty
2421    ///    worklist and the recorded hash is never overwritten);
2422    /// 2. after a source change, a subsequent verify adjudicates `drifted`
2423    ///    deterministically — no LLM sampling, no queued deferral;
2424    /// 3. the tier-3 recheck queue for such anchors drains: post-backfill
2425    ///    clean passes queue nothing, instead of re-queueing forever.
2426    ///
2427    /// REFUSAL half: `authored` / `informed-by` anchors never gain hashes and
2428    /// never adjudicate `drifted`; an `unstable` hash-stability medium
2429    /// resolves `recheck` (queued), never `drifted`.
2430    #[test]
2431    fn hashless_anchor_backfills_once_then_drift_adjudicates_deterministically() {
2432        let tmp = tempfile::tempdir().unwrap();
2433        let root = tmp.path();
2434        let mem_dir = root.join("mem");
2435        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
2436        std::fs::write(
2437            mem_dir.join(".memstead").join("config.json"),
2438            r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
2439        )
2440        .unwrap();
2441        std::fs::create_dir_all(root.join(".memstead")).unwrap();
2442        std::fs::write(
2443            root.join(".memstead").join("workspace.toml"),
2444            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2445        )
2446        .unwrap();
2447        let mount = Mount {
2448            mem: "engine".to_string(),
2449            schema: Some("default@1.0.0".parse().unwrap()),
2450            storage: MountStorage::Folder {
2451                path: mem_dir.clone(),
2452            },
2453            capability: MountCapability::Write,
2454            lifecycle: MountLifecycle::Eager,
2455            cross_linkable: false,
2456            migration_target: None,
2457        };
2458        crate::FileWorkspaceStore::new()
2459            .save_state(
2460                root,
2461                &Workspace {
2462                    mounts: vec![mount],
2463                    settings: WorkspaceSettings::default(),
2464                },
2465            )
2466            .unwrap();
2467
2468        // Git source tree at head A: two committed source files.
2469        let git = |args: &[&str]| {
2470            let out = std::process::Command::new("git")
2471                .args(args)
2472                .current_dir(root)
2473                .env("GIT_AUTHOR_NAME", "t")
2474                .env("GIT_AUTHOR_EMAIL", "t@t")
2475                .env("GIT_COMMITTER_NAME", "t")
2476                .env("GIT_COMMITTER_EMAIL", "t@t")
2477                .output()
2478                .unwrap();
2479            assert!(
2480                out.status.success(),
2481                "git {args:?}: {}",
2482                String::from_utf8_lossy(&out.stderr)
2483            );
2484        };
2485        git(&["init", "-q"]);
2486        std::fs::create_dir_all(root.join("src")).unwrap();
2487        std::fs::write(root.join("src").join("present.rs"), "fn a() {}\n").unwrap();
2488        std::fs::write(root.join("src").join("other.rs"), "fn o() {}\n").unwrap();
2489        git(&["add", "-A"]);
2490        git(&["commit", "-qm", "head-a"]);
2491
2492        // Anchors, all HASH-LESS: `anchored` (stable) + `derived` (stable) on
2493        // present.rs, `anchored` but UNSTABLE on other.rs, and the two
2494        // non-hash classes that must never gain a hash.
2495        let mk = |artifact: &str, class: AnchorProvenanceClass, stab: AnchorHashStability| Anchor {
2496            artifact: artifact.to_string(),
2497            grain: AnchorGrain::File,
2498            class,
2499            at_version: None,
2500            hash: None,
2501            hash_stability: stab,
2502            derived_from: if class == AnchorProvenanceClass::Derived {
2503                vec!["src/present.rs".to_string()]
2504            } else {
2505                Vec::new()
2506            },
2507            binding: None,
2508            source: None,
2509        };
2510        use AnchorHashStability::{Stable, Unstable};
2511        let mut sidecar = AnchorSidecar::default();
2512        sidecar.set(
2513            "engine--e",
2514            vec![
2515                mk("src/present.rs", AnchorProvenanceClass::Anchored, Stable),
2516                mk("src/present.rs", AnchorProvenanceClass::Derived, Stable),
2517                mk("src/other.rs", AnchorProvenanceClass::Anchored, Unstable),
2518                mk("src/present.rs", AnchorProvenanceClass::Authored, Stable),
2519                mk("src/present.rs", AnchorProvenanceClass::InformedBy, Stable),
2520            ],
2521        );
2522        std::fs::write(
2523            mem_dir.join(crate::anchor::ANCHOR_SIDECAR_PATH),
2524            sidecar.to_bytes(),
2525        )
2526        .unwrap();
2527
2528        write_binding(
2529            root,
2530            "engine",
2531            "graph",
2532            &Binding {
2533                version: BINDING_VERSION,
2534                intent: None,
2535                sources: vec![crate::pipeline::Source {
2536                    name: "graph".to_string(),
2537                    medium_type: MediumType::Codebase,
2538                    pointer: String::new(),
2539                    change_detection: Some("git".to_string()),
2540                    scope: vec![PatternEntry {
2541                        path: "src/**/*.rs".to_string(),
2542                        mode: PatternMode::Allow,
2543                    }],
2544                    engagement: None,
2545                    preparation: None,
2546                }],
2547                reference_mems: Vec::new(),
2548                destination_mem: "engine".to_string(),
2549                deny_paths: Vec::new(),
2550                coverage_semantics: None,
2551                rules: None,
2552                prune: None,
2553                operations: Operations {
2554                    build: None,
2555                    sync: None,
2556                    verify: Some(VerifyOperation {
2557                        trigger: IngestTrigger::Manual,
2558                        batch_size: 20,
2559                        adjudication_cap: DEFAULT_ADJUDICATION_CAP,
2560                        full_resync_every: DEFAULT_FULL_RESYNC_EVERY,
2561                    }),
2562                },
2563            },
2564        )
2565        .unwrap();
2566
2567        let configs = load_pipeline_configs(root).unwrap();
2568        let binding = &configs.bindings[0].config;
2569        let resolved = resolve_binding_run("engine/graph", binding).unwrap();
2570
2571        // --- Pass 1: first observation backfills, once. ---
2572        {
2573            let mut engine = Engine::from_workspace_root(root).unwrap();
2574            let outcome = verify_binding(&engine, root, binding, &resolved).unwrap();
2575            // Every hash-less hash-bearing anchor is on the worklist —
2576            // including the unstable one; the non-hash classes are not.
2577            let mut backfilled: Vec<(&str, &str)> = outcome
2578                .hash_backfill
2579                .iter()
2580                .map(|b| (b.entity.as_str(), b.artifact.as_str()))
2581                .collect();
2582            backfilled.sort();
2583            backfilled.dedup();
2584            assert_eq!(
2585                backfilled,
2586                vec![
2587                    ("engine--e", "src/other.rs"),
2588                    ("engine--e", "src/present.rs"),
2589                ],
2590                "hash-bearing anchors backfill; authored/informed-by never appear"
2591            );
2592            // Backfill candidates are clean-by-construction this pass —
2593            // nothing queued, nothing drifted (the recheck queue drains).
2594            assert_eq!(
2595                outcome.backlog, 0,
2596                "no recheck queue for backfilled anchors"
2597            );
2598            let store = read_findings_store(root, "engine", "graph")
2599                .unwrap()
2600                .unwrap();
2601            assert!(
2602                store
2603                    .current(&outcome.key)
2604                    .iter()
2605                    .all(|f| !matches!(f.target, FindingTarget::Anchor { .. })),
2606                "no anchor finding on the backfill pass: {:?}",
2607                store.current(&outcome.key)
2608            );
2609
2610            // The sanctioned post-run write records the hashes.
2611            let written =
2612                record_anchor_hash_backfill(&mut engine, "engine", &outcome, None).unwrap();
2613            assert_eq!(
2614                written, 3,
2615                "anchored + derived + unstable-anchored gain hashes"
2616            );
2617        }
2618
2619        // The sidecar now carries the observed prepared-form hashes — and the
2620        // non-hash classes still carry none (class semantics preserved).
2621        let expected_present = crate::anchor::prepared_content_hash(
2622            &std::fs::read(root.join("src").join("present.rs")).unwrap(),
2623        );
2624        {
2625            let sc = AnchorSidecar::from_bytes(
2626                &std::fs::read(mem_dir.join(crate::anchor::ANCHOR_SIDECAR_PATH)).unwrap(),
2627            )
2628            .unwrap();
2629            for a in sc.get("engine--e") {
2630                if a.class.is_hash_bearing() {
2631                    assert!(a.hash.is_some(), "hash-bearing anchor backfilled: {a:?}");
2632                } else {
2633                    assert!(a.hash.is_none(), "non-hash class never gains a hash: {a:?}");
2634                }
2635                if a.artifact == "src/present.rs" && a.class.is_hash_bearing() {
2636                    assert_eq!(a.hash.as_deref(), Some(expected_present.as_str()));
2637                }
2638            }
2639        }
2640
2641        // --- Pass 2 (fresh engine): idempotent — nothing to backfill, clean. ---
2642        {
2643            let mut engine = Engine::from_workspace_root(root).unwrap();
2644            let outcome = verify_binding(&engine, root, binding, &resolved).unwrap();
2645            assert!(
2646                outcome.hash_backfill.is_empty(),
2647                "backfill happens once — a re-verify observes an empty worklist"
2648            );
2649            assert_eq!(outcome.backlog, 0, "steady state: nothing re-queues");
2650            let store = read_findings_store(root, "engine", "graph")
2651                .unwrap()
2652                .unwrap();
2653            assert!(
2654                store
2655                    .current(&outcome.key)
2656                    .iter()
2657                    .all(|f| !matches!(f.target, FindingTarget::Anchor { .. })),
2658                "recorded hashes match the source — no anchor finding"
2659            );
2660            let written =
2661                record_anchor_hash_backfill(&mut engine, "engine", &outcome, None).unwrap();
2662            assert_eq!(written, 0, "no write, no commit on the idempotent pass");
2663        }
2664
2665        // --- Source change: both anchored artifacts move (head B). ---
2666        std::fs::write(
2667            root.join("src").join("present.rs"),
2668            "fn a() { /* changed */ }\n",
2669        )
2670        .unwrap();
2671        std::fs::write(
2672            root.join("src").join("other.rs"),
2673            "fn o() { /* changed */ }\n",
2674        )
2675        .unwrap();
2676        git(&["add", "-A"]);
2677        git(&["commit", "-qm", "head-b"]);
2678
2679        // --- Pass 3: deterministic adjudication — stable drifts, unstable
2680        //     rechecks, non-hash classes stay silent. ---
2681        {
2682            let engine = Engine::from_workspace_root(root).unwrap();
2683            let outcome = verify_binding(&engine, root, binding, &resolved).unwrap();
2684            assert!(
2685                outcome.hash_backfill.is_empty(),
2686                "recorded hashes are never overwritten by observation"
2687            );
2688            let store = read_findings_store(root, "engine", "graph")
2689                .unwrap()
2690                .unwrap();
2691            let current = store.current(&outcome.key);
2692            let drifted: Vec<&Finding> = current
2693                .iter()
2694                .filter(|f| f.class == FindingClass::Drifted)
2695                .collect();
2696            // The stable `anchored` + `derived` anchors on present.rs drift —
2697            // deterministically, from the hash comparison alone.
2698            assert_eq!(
2699                drifted.len(),
2700                2,
2701                "stable-medium mismatch → drifted: {current:?}"
2702            );
2703            assert!(drifted.iter().all(|f| matches!(
2704                &f.target,
2705                FindingTarget::Anchor { artifact, .. } if artifact == "src/present.rs"
2706            )));
2707            // REFUSAL: the unstable anchor on other.rs resolves recheck →
2708            // queued, never drifted.
2709            assert!(
2710                current
2711                    .iter()
2712                    .any(|f| f.class == FindingClass::QueuedForAdjudication
2713                        && matches!(
2714                            &f.target,
2715                            FindingTarget::Anchor { artifact, .. } if artifact == "src/other.rs"
2716                        )),
2717                "unstable medium resolves recheck (queued), not drifted: {current:?}"
2718            );
2719            assert!(
2720                !current.iter().any(|f| f.class == FindingClass::Drifted
2721                    && matches!(
2722                        &f.target,
2723                        FindingTarget::Anchor { artifact, .. } if artifact == "src/other.rs"
2724                    )),
2725                "an unstable hash break must never assert drift"
2726            );
2727        }
2728    }
2729
2730    /// The engine's backfill writer enforces the class guard at the write
2731    /// seam: an `authored` / `informed-by` anchor never gains a hash even if
2732    /// a (buggy or malicious) caller hands one in, and a recorded hash is
2733    /// never overwritten.
2734    #[test]
2735    fn backfill_writer_refuses_non_hash_classes_and_never_overwrites() {
2736        let tmp = tempfile::tempdir().unwrap();
2737        let root = tmp.path();
2738        let mem_dir = root.join("mem");
2739        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
2740        std::fs::write(
2741            mem_dir.join(".memstead").join("config.json"),
2742            r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
2743        )
2744        .unwrap();
2745        std::fs::create_dir_all(root.join(".memstead")).unwrap();
2746        std::fs::write(
2747            root.join(".memstead").join("workspace.toml"),
2748            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2749        )
2750        .unwrap();
2751        crate::FileWorkspaceStore::new()
2752            .save_state(
2753                root,
2754                &Workspace {
2755                    mounts: vec![Mount {
2756                        mem: "engine".to_string(),
2757                        schema: Some("default@1.0.0".parse().unwrap()),
2758                        storage: MountStorage::Folder {
2759                            path: mem_dir.clone(),
2760                        },
2761                        capability: MountCapability::Write,
2762                        lifecycle: MountLifecycle::Eager,
2763                        cross_linkable: false,
2764                        migration_target: None,
2765                    }],
2766                    settings: WorkspaceSettings::default(),
2767                },
2768            )
2769            .unwrap();
2770
2771        let anchor = |class: AnchorProvenanceClass, hash: Option<&str>| Anchor {
2772            artifact: "src/a.rs".to_string(),
2773            grain: AnchorGrain::File,
2774            class,
2775            at_version: None,
2776            hash: hash.map(str::to_string),
2777            hash_stability: AnchorHashStability::Stable,
2778            derived_from: Vec::new(),
2779            binding: None,
2780            source: None,
2781        };
2782        let mut sidecar = AnchorSidecar::default();
2783        sidecar.set(
2784            "engine--e",
2785            vec![
2786                anchor(AnchorProvenanceClass::Authored, None),
2787                anchor(AnchorProvenanceClass::InformedBy, None),
2788                anchor(AnchorProvenanceClass::Anchored, Some("recorded")),
2789            ],
2790        );
2791        std::fs::write(
2792            mem_dir.join(crate::anchor::ANCHOR_SIDECAR_PATH),
2793            sidecar.to_bytes(),
2794        )
2795        .unwrap();
2796
2797        let mut engine = Engine::from_workspace_root(root).unwrap();
2798        let written = engine
2799            .record_anchor_observed_hashes(
2800                "engine",
2801                &[crate::anchor::ObservedArtifactHash {
2802                    entity: "engine--e".to_string(),
2803                    artifact: "src/a.rs".to_string(),
2804                    hash: "observed".to_string(),
2805                }],
2806                None,
2807            )
2808            .unwrap();
2809        assert_eq!(
2810            written, 0,
2811            "non-hash classes refuse the hash; a recorded hash is never overwritten"
2812        );
2813        let sc = AnchorSidecar::from_bytes(
2814            &std::fs::read(mem_dir.join(crate::anchor::ANCHOR_SIDECAR_PATH)).unwrap(),
2815        )
2816        .unwrap();
2817        for a in sc.get("engine--e") {
2818            match a.class {
2819                AnchorProvenanceClass::Anchored => {
2820                    assert_eq!(a.hash.as_deref(), Some("recorded"), "baseline stands")
2821                }
2822                _ => assert!(a.hash.is_none(), "non-hash class stays hash-less: {a:?}"),
2823            }
2824        }
2825    }
2826
2827    /// The completed-run `#verified` writer (backlog 2026-07-11): a verify
2828    /// pass surfaces its observed facet heads on the outcome (the per-facet
2829    /// decomposition of `key.source_head`), and [`record_verified_baseline`]
2830    /// records them as `<binding>/<facet>#verified` through the engine's
2831    /// sync-state writer — durable on disk, visible to the same config read
2832    /// `report`/`status` consume. A failed pass returns
2833    /// `Err` before any caller reaches the writer, so the token never
2834    /// advances on an aborted run.
2835    /// A vanished source directory must refuse verify with the typed
2836    /// `SourceUnreachable` error instead of degrading to an empty
2837    /// enumeration: pre-fix, the missing tree produced an empty stat map
2838    /// whose aggregate (the digest of nothing) completed the run and let
2839    /// the caller overwrite a genuine `#verified` baseline with fake
2840    /// state. The engine mem itself stays loadable — only the binding's
2841    /// source is gone.
2842    #[test]
2843    fn verify_refuses_unreachable_source_with_typed_error() {
2844        let tmp = tempfile::tempdir().unwrap();
2845        let root = tmp.path();
2846        let mem_dir = root.join("mem");
2847        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
2848        std::fs::write(
2849            mem_dir.join(".memstead").join("config.json"),
2850            r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
2851        )
2852        .unwrap();
2853        std::fs::create_dir_all(root.join(".memstead")).unwrap();
2854        std::fs::write(
2855            root.join(".memstead").join("workspace.toml"),
2856            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2857        )
2858        .unwrap();
2859        let mount = Mount {
2860            mem: "engine".to_string(),
2861            schema: Some("default@1.0.0".parse().unwrap()),
2862            storage: MountStorage::Folder {
2863                path: mem_dir.clone(),
2864            },
2865            capability: MountCapability::Write,
2866            lifecycle: MountLifecycle::Eager,
2867            cross_linkable: false,
2868            migration_target: None,
2869        };
2870        crate::FileWorkspaceStore::new()
2871            .save_state(
2872                root,
2873                &Workspace {
2874                    mounts: vec![mount],
2875                    settings: WorkspaceSettings::default(),
2876                },
2877            )
2878            .unwrap();
2879
2880        // The medium points at a subdirectory that does NOT exist — the
2881        // vanished-source case (`git` declared, so pre-fix the strategy
2882        // layer silently degraded instead of refusing).
2883        write_binding(
2884            root,
2885            "engine",
2886            "gone",
2887            &Binding {
2888                version: BINDING_VERSION,
2889                intent: None,
2890                sources: vec![crate::pipeline::Source {
2891                    name: "gone".to_string(),
2892                    medium_type: MediumType::Codebase,
2893                    pointer: "vanished-src".to_string(),
2894                    change_detection: Some("git".to_string()),
2895                    scope: vec![PatternEntry {
2896                        path: "**/*.rs".to_string(),
2897                        mode: PatternMode::Allow,
2898                    }],
2899                    engagement: None,
2900                    preparation: None,
2901                }],
2902                reference_mems: Vec::new(),
2903                destination_mem: "engine".to_string(),
2904                deny_paths: Vec::new(),
2905                coverage_semantics: None,
2906                rules: None,
2907                prune: None,
2908                operations: Operations {
2909                    build: None,
2910                    sync: None,
2911                    verify: Some(VerifyOperation {
2912                        trigger: IngestTrigger::Manual,
2913                        batch_size: 20,
2914                        adjudication_cap: DEFAULT_ADJUDICATION_CAP,
2915                        full_resync_every: DEFAULT_FULL_RESYNC_EVERY,
2916                    }),
2917                },
2918            },
2919        )
2920        .unwrap();
2921
2922        let engine = Engine::from_workspace_root(root).unwrap();
2923        let configs = load_pipeline_configs(root).unwrap();
2924        let binding = &configs.bindings[0].config;
2925        let resolved = resolve_binding_run("engine/gone", binding).unwrap();
2926
2927        match verify_binding(&engine, root, binding, &resolved) {
2928            Err(FindingsError::SourceUnreachable { source_name, path }) => {
2929                assert_eq!(source_name, "gone");
2930                assert!(
2931                    path.ends_with("vanished-src"),
2932                    "refusal must name the resolved missing path, got `{path}`",
2933                );
2934            }
2935            other => panic!("expected SourceUnreachable refusal, got {other:?}"),
2936        }
2937
2938        // Nothing was observed → no `#verified` token exists (the caller
2939        // never reaches its baseline write on an Err).
2940        assert!(
2941            !engine
2942                .mem_config_for("engine")
2943                .unwrap()
2944                .sync_state
2945                .keys()
2946                .any(|k| k.ends_with("#verified")),
2947            "a refused verify must not leave any #verified token",
2948        );
2949    }
2950
2951    #[test]
2952    fn completed_verify_records_the_verified_baseline() {
2953        let tmp = tempfile::tempdir().unwrap();
2954        let root = tmp.path();
2955        let mem_dir = root.join("mem");
2956        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
2957        std::fs::write(
2958            mem_dir.join(".memstead").join("config.json"),
2959            r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
2960        )
2961        .unwrap();
2962        std::fs::create_dir_all(root.join(".memstead")).unwrap();
2963        std::fs::write(
2964            root.join(".memstead").join("workspace.toml"),
2965            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2966        )
2967        .unwrap();
2968        let mount = Mount {
2969            mem: "engine".to_string(),
2970            schema: Some("default@1.0.0".parse().unwrap()),
2971            storage: MountStorage::Folder {
2972                path: mem_dir.clone(),
2973            },
2974            capability: MountCapability::Write,
2975            lifecycle: MountLifecycle::Eager,
2976            cross_linkable: false,
2977            migration_target: None,
2978        };
2979        crate::FileWorkspaceStore::new()
2980            .save_state(
2981                root,
2982                &Workspace {
2983                    mounts: vec![mount],
2984                    settings: WorkspaceSettings::default(),
2985                },
2986            )
2987            .unwrap();
2988        let out = std::process::Command::new("git")
2989            .args(["init", "-q"])
2990            .current_dir(root)
2991            .output()
2992            .unwrap();
2993        assert!(out.status.success());
2994
2995        write_binding(
2996            root,
2997            "engine",
2998            "graph",
2999            &Binding {
3000                version: BINDING_VERSION,
3001                intent: None,
3002                sources: vec![crate::pipeline::Source {
3003                    name: "graph".to_string(),
3004                    medium_type: MediumType::Codebase,
3005                    pointer: String::new(),
3006                    change_detection: Some("git".to_string()),
3007                    scope: vec![PatternEntry {
3008                        path: "src/**/*.rs".to_string(),
3009                        mode: PatternMode::Allow,
3010                    }],
3011                    engagement: None,
3012                    preparation: None,
3013                }],
3014                reference_mems: Vec::new(),
3015                destination_mem: "engine".to_string(),
3016                deny_paths: Vec::new(),
3017                coverage_semantics: None,
3018                rules: None,
3019                prune: None,
3020                operations: Operations {
3021                    build: Some(BuildOperation {
3022                        mode: BuildMode::Discovery,
3023                        trigger: IngestTrigger::Loop,
3024                        batch_size: 20,
3025                        post_actions: None,
3026                    }),
3027                    sync: None,
3028                    verify: Some(VerifyOperation {
3029                        trigger: IngestTrigger::Manual,
3030                        batch_size: 20,
3031                        adjudication_cap: DEFAULT_ADJUDICATION_CAP,
3032                        full_resync_every: DEFAULT_FULL_RESYNC_EVERY,
3033                    }),
3034                },
3035            },
3036        )
3037        .unwrap();
3038
3039        let mut engine = Engine::from_workspace_root(root).unwrap();
3040        // A recorded `#synced` baseline is this facet's current head (the git
3041        // work tree has no commits, so the cursor contributes no newer token).
3042        engine
3043            .set_mem_sync_state("engine", "engine/graph/graph#synced", "deadbeef", None)
3044            .unwrap();
3045
3046        let configs = load_pipeline_configs(root).unwrap();
3047        let binding = &configs.bindings[0].config;
3048        let resolved = resolve_binding_run("engine/graph", binding).unwrap();
3049
3050        let outcome = verify_binding(&engine, root, binding, &resolved).unwrap();
3051        // The outcome decomposes its own key: joined facet heads == source_head.
3052        assert_eq!(
3053            outcome.facet_heads.get("graph").map(String::as_str),
3054            Some("deadbeef")
3055        );
3056        assert_eq!(outcome.key.source_head, "graph=deadbeef");
3057        assert_eq!(
3058            join_facet_heads(&outcome.facet_heads),
3059            outcome.key.source_head
3060        );
3061
3062        // No `#verified` token exists before the writer runs.
3063        assert!(
3064            !engine
3065                .mem_config_for("engine")
3066                .unwrap()
3067                .sync_state
3068                .contains_key("engine/graph/graph#verified")
3069        );
3070
3071        let written = record_verified_baseline(&mut engine, "engine", &outcome, None).unwrap();
3072        assert_eq!(written, vec!["engine/graph/graph#verified".to_string()]);
3073
3074        // Visible to the engine's config read (the app's sync_state source)…
3075        assert_eq!(
3076            engine
3077                .mem_config_for("engine")
3078                .unwrap()
3079                .sync_state
3080                .get("engine/graph/graph#verified")
3081                .map(String::as_str),
3082            Some("deadbeef")
3083        );
3084        // …and durable on disk (what a fresh CLI process reads).
3085        let disk: serde_json::Value = serde_json::from_slice(
3086            &std::fs::read(mem_dir.join(".memstead").join("config.json")).unwrap(),
3087        )
3088        .unwrap();
3089        assert_eq!(
3090            disk["syncState"]["engine/graph/graph#verified"],
3091            serde_json::json!("deadbeef")
3092        );
3093    }
3094
3095    // ---- D1: per-run adjudication cap -----------------------------------
3096
3097    /// D1 — the per-run cap queues the remainder. A rotation window covering
3098    /// only a subset of drift candidates adjudicates the in-window ones and
3099    /// QUEUES every out-of-window candidate as `queued-for-adjudication` (the
3100    /// tier-3 backlog). Uncapped (`window = None`) adjudicates every candidate.
3101    #[test]
3102    fn adjudication_cap_queues_the_remainder() {
3103        let k = key("h", "s");
3104        let mk = |art: &str| {
3105            let mut a = anchor(AnchorProvenanceClass::Anchored);
3106            a.artifact = art.to_string();
3107            a
3108        };
3109        let candidates = vec![
3110            (
3111                "engine--a".to_string(),
3112                mk("src/a.rs"),
3113                AnchorState::Drifted,
3114            ),
3115            (
3116                "engine--b".to_string(),
3117                mk("src/b.rs"),
3118                AnchorState::Drifted,
3119            ),
3120            (
3121                "engine--c".to_string(),
3122                mk("src/c.rs"),
3123                AnchorState::Drifted,
3124            ),
3125        ];
3126        // A cap-1 window selects only src/a.rs.
3127        let window: BTreeSet<String> = [candidate_key("engine--a", &mk("src/a.rs"))]
3128            .into_iter()
3129            .collect();
3130        let out = adjudicate_candidates(&k, "f", &candidates, Some(&window), "1");
3131        let drifted = out
3132            .iter()
3133            .filter(|f| f.class == FindingClass::Drifted)
3134            .count();
3135        let queued = out
3136            .iter()
3137            .filter(|f| f.class == FindingClass::QueuedForAdjudication)
3138            .count();
3139        assert_eq!(drifted, 1, "only the in-window candidate is adjudicated");
3140        assert_eq!(queued, 2, "the remainder is queued as the tier-3 backlog");
3141        // A queued remainder finding carries the queued detail, not a drift claim.
3142        assert!(
3143            out.iter()
3144                .any(|f| f.class == FindingClass::QueuedForAdjudication
3145                    && f.detail.contains("cap reached")),
3146            "capped remainder states it was deferred by the cap"
3147        );
3148
3149        // Uncapped: every candidate adjudicated, none queued.
3150        let uncapped = adjudicate_candidates(&k, "f", &candidates, None, "1");
3151        assert_eq!(
3152            uncapped
3153                .iter()
3154                .filter(|f| f.class == FindingClass::Drifted)
3155                .count(),
3156            3,
3157            "uncapped adjudicates every candidate"
3158        );
3159        assert_eq!(
3160            uncapped
3161                .iter()
3162                .filter(|f| f.class == FindingClass::QueuedForAdjudication)
3163                .count(),
3164            0
3165        );
3166    }
3167
3168    // ---- D3: full_resync scheduling + non-enumerable refusal ------------
3169
3170    /// D3 — `schedule_full_resync`: disabled at cadence 0; not-due off-cadence
3171    /// (with a countdown); due on-cadence for an enumerable facet (walked, no
3172    /// refusal).
3173    #[test]
3174    fn full_resync_schedule_disabled_notdue_due() {
3175        let codebase = FacetEnumerability {
3176            facet: "src".to_string(),
3177            medium_type: "codebase".to_string(),
3178            enumerable: true,
3179        };
3180        assert_eq!(
3181            schedule_full_resync(0, 5, std::slice::from_ref(&codebase)),
3182            FullResyncDecision::Disabled
3183        );
3184        match schedule_full_resync(3, 2, std::slice::from_ref(&codebase)) {
3185            FullResyncDecision::NotDue { runs_until_due, .. } => assert_eq!(runs_until_due, 1),
3186            other => panic!("expected NotDue, got {other:?}"),
3187        }
3188        match schedule_full_resync(3, 3, std::slice::from_ref(&codebase)) {
3189            FullResyncDecision::Due {
3190                walked_facets,
3191                refused,
3192                ..
3193            } => {
3194                assert_eq!(walked_facets, vec!["src".to_string()]);
3195                assert!(refused.is_empty(), "enumerable facet is not refused");
3196            }
3197            other => panic!("expected Due, got {other:?}"),
3198        }
3199    }
3200
3201    /// D3 REFUSAL — a scheduled full walk over a NON-enumerable medium refuses
3202    /// with a typed signal: it never claims coverage and is never a silent skip.
3203    #[test]
3204    fn full_resync_refuses_non_enumerable_medium() {
3205        let web = FacetEnumerability {
3206            facet: "manual".to_string(),
3207            medium_type: "web".to_string(),
3208            enumerable: false,
3209        };
3210        let d = schedule_full_resync(1, 1, &[web]);
3211        assert!(
3212            d.is_full_walk(),
3213            "a due sweep is a full walk even when refused"
3214        );
3215        match d {
3216            FullResyncDecision::Due {
3217                walked_facets,
3218                refused,
3219                ..
3220            } => {
3221                assert!(walked_facets.is_empty(), "nothing enumerable to walk");
3222                assert_eq!(refused.len(), 1, "the non-enumerable facet is refused");
3223                assert_eq!(refused[0].facet, "manual");
3224                assert_eq!(refused[0].medium_type, "web");
3225                assert!(
3226                    refused[0].reason.contains("non-enumerable"),
3227                    "the refusal is typed and states why"
3228                );
3229            }
3230            other => panic!("expected Due with a refusal, got {other:?}"),
3231        }
3232    }
3233
3234    /// D3 — a scheduled full walk fires the WHOLE-source enumeration this run:
3235    /// with `full_resync_every = 1` (due every run) and a sample `batch_size` of
3236    /// 1, all three uncovered source files are flagged, not just one — the full
3237    /// walk overrides the bounded rotating sample for an enumerable medium.
3238    #[test]
3239    fn full_resync_full_walk_covers_whole_source() {
3240        let tmp = tempfile::tempdir().unwrap();
3241        let root = tmp.path();
3242        let mem_dir = root.join("mem");
3243        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
3244        std::fs::write(
3245            mem_dir.join(".memstead").join("config.json"),
3246            r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
3247        )
3248        .unwrap();
3249        std::fs::create_dir_all(root.join(".memstead")).unwrap();
3250        std::fs::write(
3251            root.join(".memstead").join("workspace.toml"),
3252            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
3253        )
3254        .unwrap();
3255        let mount = Mount {
3256            mem: "engine".to_string(),
3257            schema: Some("default@1.0.0".parse().unwrap()),
3258            storage: MountStorage::Folder {
3259                path: mem_dir.clone(),
3260            },
3261            capability: MountCapability::Write,
3262            lifecycle: MountLifecycle::Eager,
3263            cross_linkable: false,
3264            migration_target: None,
3265        };
3266        crate::FileWorkspaceStore::new()
3267            .save_state(
3268                root,
3269                &Workspace {
3270                    mounts: vec![mount],
3271                    settings: WorkspaceSettings::default(),
3272                },
3273            )
3274            .unwrap();
3275        let out = std::process::Command::new("git")
3276            .args(["init", "-q"])
3277            .current_dir(root)
3278            .output()
3279            .unwrap();
3280        assert!(out.status.success());
3281        std::fs::create_dir_all(root.join("src")).unwrap();
3282        for f in ["a.rs", "b.rs", "c.rs"] {
3283            std::fs::write(root.join("src").join(f), "fn x() {}\n").unwrap();
3284        }
3285
3286        write_binding(
3287            root,
3288            "engine",
3289            "graph",
3290            &Binding {
3291                version: BINDING_VERSION,
3292                intent: None,
3293                sources: vec![crate::pipeline::Source {
3294                    name: "graph".to_string(),
3295                    medium_type: MediumType::Codebase,
3296                    pointer: String::new(),
3297                    change_detection: Some("git".to_string()),
3298                    scope: vec![PatternEntry {
3299                        path: "src/**/*.rs".to_string(),
3300                        mode: PatternMode::Allow,
3301                    }],
3302                    engagement: None,
3303                    preparation: None,
3304                }],
3305                reference_mems: Vec::new(),
3306                destination_mem: "engine".to_string(),
3307                deny_paths: Vec::new(),
3308                coverage_semantics: None,
3309                rules: None,
3310                prune: None,
3311                operations: Operations {
3312                    build: Some(BuildOperation {
3313                        mode: BuildMode::Discovery,
3314                        trigger: IngestTrigger::Loop,
3315                        batch_size: 20,
3316                        post_actions: None,
3317                    }),
3318                    sync: None,
3319                    verify: Some(VerifyOperation {
3320                        trigger: IngestTrigger::Manual,
3321                        batch_size: 1, // a tiny rotating sample …
3322                        adjudication_cap: DEFAULT_ADJUDICATION_CAP,
3323                        full_resync_every: 1, // … but a full walk fires EVERY run
3324                    }),
3325                },
3326            },
3327        )
3328        .unwrap();
3329
3330        let engine = Engine::from_workspace_root(root).unwrap();
3331        let configs = load_pipeline_configs(root).unwrap();
3332        let binding = &configs.bindings[0].config;
3333        let resolved = resolve_binding_run("engine/graph", binding).unwrap();
3334
3335        let outcome = verify_binding(&engine, root, binding, &resolved).unwrap();
3336        // The full walk is due on run 1 and covers the enumerable facet.
3337        match &outcome.full_resync {
3338            FullResyncDecision::Due {
3339                walked_facets,
3340                refused,
3341                run_count,
3342                ..
3343            } => {
3344                assert_eq!(*run_count, 1);
3345                assert_eq!(walked_facets, &vec!["graph".to_string()]);
3346                assert!(refused.is_empty());
3347            }
3348            other => panic!("expected a due full walk, got {other:?}"),
3349        }
3350        // All three uncovered files flagged despite the batch_size-1 sample.
3351        let store = read_findings_store(root, "engine", "graph")
3352            .unwrap()
3353            .unwrap();
3354        let uncovered = store
3355            .current(&outcome.key)
3356            .iter()
3357            .filter(|f| f.class == FindingClass::Uncovered)
3358            .count();
3359        assert_eq!(
3360            uncovered, 3,
3361            "the scheduled full walk covers the whole source, not a batch of one"
3362        );
3363    }
3364
3365    // ---- explicit full measurement (`verify_binding_full`) ----------------
3366
3367    /// An explicit full measurement walks the whole `S(D)` and treats the
3368    /// adjudication cap as unlimited — every drift candidate adjudicates and
3369    /// every uncovered artifact is flagged in ONE run, with nothing deferred
3370    /// to a cap or a rotating sample, and the decision reports `Forced`.
3371    /// REFUSAL half (byte-compat): a no-flag run over the same binding keeps
3372    /// today's capped/sampled behavior exactly — cap-1 adjudicates one
3373    /// candidate and queues the remainder with the cap-reached detail, and
3374    /// the batch-1 sample flags at most one uncovered file.
3375    #[test]
3376    fn full_verify_uncaps_adjudication_and_walks_whole_source() {
3377        let tmp = tempfile::tempdir().unwrap();
3378        let root = tmp.path();
3379        let mem_dir = root.join("mem");
3380        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
3381        std::fs::write(
3382            mem_dir.join(".memstead").join("config.json"),
3383            r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
3384        )
3385        .unwrap();
3386        std::fs::create_dir_all(root.join(".memstead")).unwrap();
3387        std::fs::write(
3388            root.join(".memstead").join("workspace.toml"),
3389            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
3390        )
3391        .unwrap();
3392        crate::FileWorkspaceStore::new()
3393            .save_state(
3394                root,
3395                &Workspace {
3396                    mounts: vec![Mount {
3397                        mem: "engine".to_string(),
3398                        schema: Some("default@1.0.0".parse().unwrap()),
3399                        storage: MountStorage::Folder {
3400                            path: mem_dir.clone(),
3401                        },
3402                        capability: MountCapability::Write,
3403                        lifecycle: MountLifecycle::Eager,
3404                        cross_linkable: false,
3405                        migration_target: None,
3406                    }],
3407                    settings: WorkspaceSettings::default(),
3408                },
3409            )
3410            .unwrap();
3411        let out = std::process::Command::new("git")
3412            .args(["init", "-q"])
3413            .current_dir(root)
3414            .output()
3415            .unwrap();
3416        assert!(out.status.success());
3417        std::fs::create_dir_all(root.join("src")).unwrap();
3418        // Three anchored (drift-candidate) files + three uncovered files.
3419        for f in ["a.rs", "b.rs", "c.rs", "d.rs", "e.rs", "f.rs"] {
3420            std::fs::write(root.join("src").join(f), "fn x() {}\n").unwrap();
3421        }
3422        let mk = |art: &str| Anchor {
3423            artifact: art.to_string(),
3424            grain: AnchorGrain::File,
3425            class: AnchorProvenanceClass::Anchored,
3426            at_version: None,
3427            hash: Some("stale-recorded-hash".to_string()), // mismatches → drift candidate
3428            hash_stability: AnchorHashStability::Stable,
3429            derived_from: Vec::new(),
3430            binding: None,
3431            source: None,
3432        };
3433        let mut sidecar = AnchorSidecar::default();
3434        sidecar.set(
3435            "engine--e",
3436            vec![mk("src/a.rs"), mk("src/b.rs"), mk("src/c.rs")],
3437        );
3438        std::fs::write(
3439            mem_dir.join(crate::anchor::ANCHOR_SIDECAR_PATH),
3440            sidecar.to_bytes(),
3441        )
3442        .unwrap();
3443
3444        write_binding(
3445            root,
3446            "engine",
3447            "graph",
3448            &Binding {
3449                version: BINDING_VERSION,
3450                intent: None,
3451                sources: vec![crate::pipeline::Source {
3452                    name: "graph".to_string(),
3453                    medium_type: MediumType::Codebase,
3454                    pointer: String::new(),
3455                    change_detection: Some("git".to_string()),
3456                    scope: vec![PatternEntry {
3457                        path: "src/**/*.rs".to_string(),
3458                        mode: PatternMode::Allow,
3459                    }],
3460                    engagement: None,
3461                    preparation: None,
3462                }],
3463                reference_mems: Vec::new(),
3464                destination_mem: "engine".to_string(),
3465                deny_paths: Vec::new(),
3466                coverage_semantics: None,
3467                rules: None,
3468                prune: None,
3469                operations: Operations {
3470                    build: None,
3471                    sync: None,
3472                    verify: Some(VerifyOperation {
3473                        trigger: IngestTrigger::Manual,
3474                        batch_size: 1,        // tiny rotating sample …
3475                        adjudication_cap: 1,  // … and a tiny cap
3476                        full_resync_every: 0, // scheduled walks disabled
3477                    }),
3478                },
3479            },
3480        )
3481        .unwrap();
3482
3483        let engine = Engine::from_workspace_root(root).unwrap();
3484        let configs = load_pipeline_configs(root).unwrap();
3485        let binding = &configs.bindings[0].config;
3486        let resolved = resolve_binding_run("engine/graph", binding).unwrap();
3487
3488        // Byte-compat leg — the no-flag run keeps today's capped/sampled
3489        // economics: one candidate adjudicated, two queued by the cap, at
3490        // most one uncovered file from the batch-1 sample, no full walk.
3491        let sampled = verify_binding(&engine, root, binding, &resolved).unwrap();
3492        assert_eq!(sampled.full_resync, FullResyncDecision::Disabled);
3493        let store = read_findings_store(root, "engine", "graph")
3494            .unwrap()
3495            .unwrap();
3496        let current = store.current(&sampled.key);
3497        let count = |c: FindingClass| current.iter().filter(|f| f.class == c).count();
3498        assert_eq!(count(FindingClass::Drifted), 1, "cap-1 adjudicates one");
3499        assert_eq!(
3500            count(FindingClass::QueuedForAdjudication),
3501            2,
3502            "the remainder queues"
3503        );
3504        assert!(
3505            current
3506                .iter()
3507                .any(|f| f.class == FindingClass::QueuedForAdjudication
3508                    && f.detail.contains("cap reached")),
3509            "the sampled deferral states the cap"
3510        );
3511        assert!(
3512            count(FindingClass::Uncovered) <= 1,
3513            "batch-1 sample looks at one artifact"
3514        );
3515
3516        // Full measurement: everything adjudicates, everything is walked,
3517        // nothing deferred — no sampling/truncation residue anywhere.
3518        let full = verify_binding_full(&engine, root, binding, &resolved).unwrap();
3519        assert_eq!(
3520            full.full_resync,
3521            FullResyncDecision::Forced {
3522                walked_facets: vec!["graph".to_string()]
3523            }
3524        );
3525        assert_eq!(full.backlog, 0, "cap treated as unlimited — no backlog");
3526        let store = read_findings_store(root, "engine", "graph")
3527            .unwrap()
3528            .unwrap();
3529        let current = store.current(&full.key);
3530        let count = |c: FindingClass| current.iter().filter(|f| f.class == c).count();
3531        assert_eq!(
3532            count(FindingClass::Drifted),
3533            3,
3534            "every candidate adjudicated"
3535        );
3536        assert_eq!(count(FindingClass::QueuedForAdjudication), 0);
3537        assert_eq!(
3538            count(FindingClass::Uncovered),
3539            3,
3540            "the whole S(D) walked — every uncovered file flagged"
3541        );
3542        assert!(
3543            current.iter().all(|f| !f.detail.contains("cap reached")),
3544            "a full run's findings carry no cap-deferral caveat"
3545        );
3546    }
3547
3548    /// REFUSAL — an explicit full measurement over a non-enumerable medium
3549    /// refuses the whole run with the typed capability error (nothing
3550    /// observed, nothing recorded — never a fabricated-complete report),
3551    /// while the no-flag sampled verify over the same binding still runs.
3552    #[test]
3553    fn full_verify_refuses_non_enumerable_medium_typed() {
3554        let tmp = tempfile::tempdir().unwrap();
3555        let root = tmp.path();
3556        let mem_dir = root.join("mem");
3557        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
3558        std::fs::write(
3559            mem_dir.join(".memstead").join("config.json"),
3560            r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
3561        )
3562        .unwrap();
3563        std::fs::create_dir_all(root.join(".memstead")).unwrap();
3564        std::fs::write(
3565            root.join(".memstead").join("workspace.toml"),
3566            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
3567        )
3568        .unwrap();
3569        crate::FileWorkspaceStore::new()
3570            .save_state(
3571                root,
3572                &Workspace {
3573                    mounts: vec![Mount {
3574                        mem: "engine".to_string(),
3575                        schema: Some("default@1.0.0".parse().unwrap()),
3576                        storage: MountStorage::Folder {
3577                            path: mem_dir.clone(),
3578                        },
3579                        capability: MountCapability::Write,
3580                        lifecycle: MountLifecycle::Eager,
3581                        cross_linkable: false,
3582                        migration_target: None,
3583                    }],
3584                    settings: WorkspaceSettings::default(),
3585                },
3586            )
3587            .unwrap();
3588
3589        // A web medium — the capability matrix marks it non-enumerable.
3590        write_binding(
3591            root,
3592            "engine",
3593            "manual",
3594            &Binding {
3595                version: BINDING_VERSION,
3596                intent: None,
3597                sources: vec![crate::pipeline::Source {
3598                    name: "manual".to_string(),
3599                    medium_type: MediumType::Web,
3600                    pointer: "https://example.com/docs".to_string(),
3601                    change_detection: None,
3602                    scope: Vec::new(),
3603                    engagement: None,
3604                    preparation: None,
3605                }],
3606                reference_mems: Vec::new(),
3607                destination_mem: "engine".to_string(),
3608                deny_paths: Vec::new(),
3609                coverage_semantics: Some(CoverageSemantics::Curated),
3610                rules: None,
3611                prune: None,
3612                operations: Operations {
3613                    build: None,
3614                    sync: None,
3615                    verify: Some(VerifyOperation {
3616                        trigger: IngestTrigger::Manual,
3617                        batch_size: 20,
3618                        adjudication_cap: DEFAULT_ADJUDICATION_CAP,
3619                        full_resync_every: DEFAULT_FULL_RESYNC_EVERY,
3620                    }),
3621                },
3622            },
3623        )
3624        .unwrap();
3625
3626        let engine = Engine::from_workspace_root(root).unwrap();
3627        let configs = load_pipeline_configs(root).unwrap();
3628        let binding = &configs.bindings[0].config;
3629        let resolved = resolve_binding_run("engine/manual", binding).unwrap();
3630
3631        // Full: typed refusal naming the facet and medium type; nothing recorded.
3632        let err = verify_binding_full(&engine, root, binding, &resolved).unwrap_err();
3633        match &err {
3634            FindingsError::FullWalkNonEnumerable(refusal) => {
3635                assert_eq!(refusal.facet, "manual");
3636                assert_eq!(refusal.medium_type, "web");
3637                assert!(refusal.reason.contains("non-enumerable"));
3638            }
3639            other => panic!("expected FullWalkNonEnumerable, got {other:?}"),
3640        }
3641        assert!(
3642            read_findings_store(root, "engine", "manual")
3643                .unwrap()
3644                .is_none(),
3645            "a refused full run records nothing"
3646        );
3647
3648        // No-flag: the sampled verify over the same binding still runs.
3649        let sampled = verify_binding(&engine, root, binding, &resolved).unwrap();
3650        assert_eq!(sampled.binding, "engine/manual");
3651    }
3652}