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    /// The impl-version bump's documented invalidation-by-construction: a
1692    /// finding recorded under the `hash(D)` a prior engine generation
1693    /// computed (`PREPARATION_IMPL_VERSION` 0, for a binding declaring no
1694    /// preparation at all) is not current under the live hash — segregated
1695    /// as superseded, never presented — because the impl version is hashed
1696    /// into every binding's identity. The old key still reads its own batch,
1697    /// so nothing is deleted, only retired from the current view.
1698    #[test]
1699    fn impl_version_bump_invalidates_findings_by_construction() {
1700        use crate::binding::{
1701            PREPARATION_IMPL_VERSION, ScaffoldParams, hash_binding, hash_binding_at_impl_version,
1702            scaffold_binding,
1703        };
1704        let binding = scaffold_binding(ScaffoldParams {
1705            destination_mem: "plugin",
1706            source_name: "source-tree",
1707            pointer: "../public",
1708            medium_type: crate::pipeline::MediumType::Codebase,
1709            intent: None,
1710            additional_deny_paths: Vec::new(),
1711        })
1712        .binding;
1713        assert!(binding.sources[0].preparation.is_none());
1714        // The live constant is whatever the latest landed implementation set
1715        // it to; the pin is that the version-0 hash (the pre-registry
1716        // generation) is not the live one.
1717        let _ = PREPARATION_IMPL_VERSION;
1718        let old = key(&hash_binding_at_impl_version(&binding, 0), "head1");
1719        let live = key(&hash_binding(&binding), "head1");
1720        assert_ne!(old.binding_hash, live.binding_hash);
1721
1722        let mut store = FindingsStore::default();
1723        let f_old = Finding {
1724            key: old.clone(),
1725            facet: "source-tree".to_string(),
1726            target: FindingTarget::Artifact {
1727                artifact: "src/old.rs".to_string(),
1728            },
1729            class: FindingClass::Uncovered,
1730            detail: "recorded before the bump".to_string(),
1731            created_at: "1".to_string(),
1732        };
1733        store.record(old.clone(), "1".to_string(), vec![f_old.clone()]);
1734
1735        assert!(
1736            store.current(&live).is_empty(),
1737            "a finding keyed on the pre-bump hash is invalid under the live hash"
1738        );
1739        assert_eq!(store.superseded(&live), vec![&f_old]);
1740        assert_eq!(
1741            store.current(&old),
1742            &[f_old.clone()][..],
1743            "nothing is deleted"
1744        );
1745    }
1746
1747    /// Criterion — findings survive head movement: the store keys on `hash(D)`
1748    /// alone, so a finding recorded at head1 stays `current` when read at
1749    /// head2 (the sync brief's read is head-agnostic), still carrying the head
1750    /// it was observed at as metadata. REFUSAL half: recording the hash's next
1751    /// batch (verify's post-merge write) replaces it — a finding absent from
1752    /// that batch (resolved) never re-presents, at any head.
1753    #[test]
1754    fn moved_source_head_keeps_findings_current_until_superseded() {
1755        let mut store = FindingsStore::default();
1756        let before = key("hashA", "head1");
1757        let after = key("hashA", "head2");
1758        let f = Finding {
1759            key: before.clone(),
1760            facet: "src".to_string(),
1761            target: FindingTarget::Anchor {
1762                entity: "engine--e".to_string(),
1763                artifact: "src/x.rs".to_string(),
1764            },
1765            class: FindingClass::UnresolvableAnchor,
1766            detail: "gone".to_string(),
1767            created_at: "1".to_string(),
1768        };
1769        store.record(before.clone(), "1".to_string(), vec![f.clone()]);
1770
1771        // The head moved; the finding is still presented, with its observed
1772        // head intact, and it is not "superseded".
1773        assert_eq!(store.current(&after), std::slice::from_ref(&f));
1774        assert_eq!(store.current(&after)[0].key.source_head, "head1");
1775        assert!(store.superseded(&after).is_empty());
1776
1777        // A verify at head2 records the hash's next batch WITHOUT the finding
1778        // (its target observed clean) → resolved, never re-presented.
1779        store.record(after.clone(), "2".to_string(), Vec::new());
1780        assert!(store.current(&after).is_empty());
1781        assert!(store.current(&before).is_empty(), "at the old head too");
1782        assert_eq!(store.batches.len(), 1, "one batch per hash(D)");
1783    }
1784
1785    /// Migration/compat — a store written by the pre-re-key engine (batches
1786    /// keyed `(hash(D), source_head)`; the exact on-disk shape live dogfood
1787    /// workspaces carry) loads without loss: the other-hash batch stays
1788    /// segregated as superseded, the current-hash batch presents at ANY head,
1789    /// and a legacy same-hash pair collapses to its latest-recorded batch —
1790    /// never resurrecting the older (superseded-at-write-time) one. The next
1791    /// `record` folds the same-hash siblings into one batch.
1792    #[test]
1793    fn legacy_per_head_store_loads_and_presents_head_agnostically() {
1794        let tmp = tempfile::tempdir().unwrap();
1795        let root = tmp.path();
1796        let path = findings_store_path(root, "engine", "graph");
1797        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1798        // Trimmed replica of the live on-disk format: `{binding, batches:[{key:
1799        // {binding_hash, source_head}, recorded_at, findings:[{key, facet,
1800        // target:{kind,...}, class, detail, created_at}]}]}` — one batch under
1801        // an old hash, two batches under the current hash at different heads.
1802        std::fs::write(
1803            &path,
1804            r#"{
1805              "binding": "engine/graph",
1806              "batches": [
1807                {
1808                  "key": { "binding_hash": "hashOLD", "source_head": "src=aaa" },
1809                  "recorded_at": "100",
1810                  "findings": [
1811                    {
1812                      "key": { "binding_hash": "hashOLD", "source_head": "src=aaa" },
1813                      "facet": "src",
1814                      "target": { "kind": "artifact", "artifact": "src/old.rs" },
1815                      "class": "uncovered",
1816                      "detail": "old declaration",
1817                      "created_at": "100"
1818                    }
1819                  ]
1820                },
1821                {
1822                  "key": { "binding_hash": "hashCUR", "source_head": "src=bbb" },
1823                  "recorded_at": "200",
1824                  "findings": [
1825                    {
1826                      "key": { "binding_hash": "hashCUR", "source_head": "src=bbb" },
1827                      "facet": "src",
1828                      "target": { "kind": "artifact", "artifact": "src/resolved-at-ccc.rs" },
1829                      "class": "uncovered",
1830                      "detail": "was open at bbb, absent from the ccc batch",
1831                      "created_at": "200"
1832                    }
1833                  ]
1834                },
1835                {
1836                  "key": { "binding_hash": "hashCUR", "source_head": "src=ccc" },
1837                  "recorded_at": "300",
1838                  "findings": [
1839                    {
1840                      "key": { "binding_hash": "hashCUR", "source_head": "src=ccc" },
1841                      "facet": "src",
1842                      "target": { "kind": "anchor", "entity": "engine--e", "artifact": "src/x.rs" },
1843                      "class": "unresolvable-anchor",
1844                      "detail": "gone",
1845                      "created_at": "300"
1846                    }
1847                  ]
1848                }
1849              ]
1850            }"#,
1851        )
1852        .unwrap();
1853
1854        let mut store = read_findings_store(root, "engine", "graph")
1855            .unwrap()
1856            .expect("the legacy on-disk format loads as-is");
1857        assert_eq!(store.binding, "engine/graph");
1858        assert_eq!(store.batches.len(), 3, "loaded without loss");
1859
1860        // Head-agnostic current view: reading at a NEWLY moved head (ddd —
1861        // recorded nowhere) presents the latest current-hash batch.
1862        let now = key("hashCUR", "src=ddd");
1863        let current = store.current(&now);
1864        assert_eq!(current.len(), 1);
1865        assert_eq!(current[0].detail, "gone");
1866        assert_eq!(
1867            current[0].key.source_head, "src=ccc",
1868            "the finding keeps the head it was observed at"
1869        );
1870        // The pre-re-key superseded batches (old hash + the older same-hash
1871        // head) stay segregated — never mixed into the current view.
1872        let superseded = store.superseded(&now);
1873        assert_eq!(superseded.len(), 2);
1874        assert!(
1875            !current.iter().any(|f| f.detail.contains("was open at bbb")),
1876            "the older same-hash batch was superseded at write time and is not resurrected"
1877        );
1878
1879        // The next record under the current hash collapses the legacy
1880        // same-hash pair into one batch; the old-hash batch is untouched.
1881        store.record(now.clone(), "400".to_string(), Vec::new());
1882        assert_eq!(store.batches.len(), 2, "hashCUR collapsed, hashOLD kept");
1883        assert_eq!(store.superseded(&now).len(), 1);
1884    }
1885
1886    /// The head-durable merge: an unobserved-but-still-open prior finding
1887    /// carries forward (original observed head intact); a prior finding whose
1888    /// artifact left `S(D)`, gained coverage, or whose anchor vanished closes;
1889    /// a re-observed target takes this pass's outcome (clean → closed).
1890    #[test]
1891    fn merge_carries_unobserved_open_findings_and_closes_departed() {
1892        let k_old = key("h", "head1");
1893        let mk_artifact = |artifact: &str, detail: &str| Finding {
1894            key: k_old.clone(),
1895            facet: "src".to_string(),
1896            target: FindingTarget::Artifact {
1897                artifact: artifact.to_string(),
1898            },
1899            class: FindingClass::Uncovered,
1900            detail: detail.to_string(),
1901            created_at: "1".to_string(),
1902        };
1903        let anchor_finding = Finding {
1904            key: k_old.clone(),
1905            facet: "src".to_string(),
1906            target: FindingTarget::Anchor {
1907                entity: "engine--gone".to_string(),
1908                artifact: "src/gone.rs".to_string(),
1909            },
1910            class: FindingClass::UnresolvableAnchor,
1911            detail: "anchor since removed from the mem".to_string(),
1912            created_at: "1".to_string(),
1913        };
1914        let prior = vec![
1915            mk_artifact("src/unsampled.rs", "still open, not in this window"),
1916            mk_artifact("src/departed.rs", "left S(D)"),
1917            mk_artifact("src/now-covered.rs", "gained an anchor since"),
1918            mk_artifact("src/observed-clean.rs", "re-sampled and now covered"),
1919            anchor_finding,
1920        ];
1921        let obs = PassObservation {
1922            anchors_observed: BTreeSet::new(),
1923            anchors_existing: BTreeSet::new(), // the anchor vanished
1924            files_observed: ["src/observed-clean.rs".to_string()].into(),
1925            s_d: [
1926                "src/unsampled.rs".to_string(),
1927                "src/now-covered.rs".to_string(),
1928                "src/observed-clean.rs".to_string(),
1929            ]
1930            .into(),
1931        };
1932        let merged = merge_with_prior(Vec::new(), &prior, &obs, |artifact| {
1933            artifact == "src/now-covered.rs" || artifact == "src/observed-clean.rs"
1934        });
1935        assert_eq!(merged.len(), 1, "only the still-open unsampled one carries");
1936        assert_eq!(
1937            merged[0].target,
1938            FindingTarget::Artifact {
1939                artifact: "src/unsampled.rs".to_string()
1940            }
1941        );
1942        assert_eq!(
1943            merged[0].key.source_head, "head1",
1944            "a carried finding keeps the head it was observed at"
1945        );
1946    }
1947
1948    /// Supersession honesty: a fresh `queued-for-adjudication` entry is a
1949    /// scheduling deferral, not an observation — it never downgrades a prior
1950    /// substantive `drifted` verdict for the same target. A fresh substantive
1951    /// outcome (or a clean observation) still supersedes normally.
1952    #[test]
1953    fn merge_deferral_never_downgrades_prior_adjudication() {
1954        let k_old = key("h", "head1");
1955        let k_new = key("h", "head2");
1956        let target = FindingTarget::Anchor {
1957            entity: "engine--e".to_string(),
1958            artifact: "src/x.rs".to_string(),
1959        };
1960        let prior_drifted = Finding {
1961            key: k_old.clone(),
1962            facet: "src".to_string(),
1963            target: target.clone(),
1964            class: FindingClass::Drifted,
1965            detail: "adjudicated drifted at head1".to_string(),
1966            created_at: "1".to_string(),
1967        };
1968        let fresh_queued = Finding {
1969            key: k_new.clone(),
1970            facet: "src".to_string(),
1971            target: target.clone(),
1972            class: FindingClass::QueuedForAdjudication,
1973            detail: "deferred by the cap this run".to_string(),
1974            created_at: "2".to_string(),
1975        };
1976        let obs = PassObservation {
1977            anchors_observed: [target_key(&target)].into(),
1978            anchors_existing: [target_key(&target)].into(),
1979            files_observed: BTreeSet::new(),
1980            s_d: BTreeSet::new(),
1981        };
1982        let merged = merge_with_prior(
1983            vec![fresh_queued],
1984            std::slice::from_ref(&prior_drifted),
1985            &obs,
1986            |_| true,
1987        );
1988        assert_eq!(merged.len(), 1);
1989        assert_eq!(
1990            merged[0].class,
1991            FindingClass::Drifted,
1992            "the prior verdict stands over a deferral"
1993        );
1994        assert_eq!(merged[0].key.source_head, "head1");
1995    }
1996
1997    /// A2 — hash-drift adjudication is excluded for `informed-by` (and every
1998    /// non-hash-bearing class): a drifted/recheck state yields NO finding.
1999    #[test]
2000    fn informed_by_anchor_never_drifts() {
2001        let k = key("h", "s");
2002        for class in [
2003            AnchorProvenanceClass::InformedBy,
2004            AnchorProvenanceClass::Authored,
2005        ] {
2006            let a = anchor(class);
2007            assert!(
2008                adjudicate_anchor(&k, "f", "engine--e", &a, AnchorState::Drifted, "1").is_none(),
2009                "{class:?} must not produce a drift finding"
2010            );
2011            assert!(
2012                adjudicate_anchor(&k, "f", "engine--e", &a, AnchorState::Recheck, "1").is_none(),
2013                "{class:?} must not produce a queued finding"
2014            );
2015        }
2016    }
2017
2018    /// A2 — hash-bearing classes DO produce drift/recheck findings, and every
2019    /// class produces an existence (`unresolvable-anchor`) finding when orphaned.
2020    #[test]
2021    fn hash_bearing_drifts_and_orphan_is_class_independent() {
2022        let k = key("h", "s");
2023        let anchored = anchor(AnchorProvenanceClass::Anchored);
2024        let drifted =
2025            adjudicate_anchor(&k, "f", "engine--e", &anchored, AnchorState::Drifted, "1").unwrap();
2026        assert_eq!(drifted.class, FindingClass::Drifted);
2027        assert_eq!(drifted.key, k, "the finding carries its recording key (A2)");
2028
2029        let queued =
2030            adjudicate_anchor(&k, "f", "engine--e", &anchored, AnchorState::Recheck, "1").unwrap();
2031        assert_eq!(queued.class, FindingClass::QueuedForAdjudication);
2032
2033        // Orphaned is existence, not hash-drift — reported for informed-by too.
2034        let informed = anchor(AnchorProvenanceClass::InformedBy);
2035        let orphan =
2036            adjudicate_anchor(&k, "f", "engine--e", &informed, AnchorState::Orphaned, "1").unwrap();
2037        assert_eq!(orphan.class, FindingClass::UnresolvableAnchor);
2038
2039        // Resolves yields nothing.
2040        assert!(
2041            adjudicate_anchor(&k, "f", "engine--e", &anchored, AnchorState::Resolves, "1")
2042                .is_none()
2043        );
2044    }
2045
2046    /// The finding class vocabulary round-trips through its wire form.
2047    #[test]
2048    fn finding_class_wire_round_trips() {
2049        for w in FindingClass::WIRE_VALUES {
2050            let c = FindingClass::from_wire(w).expect("known wire value");
2051            assert_eq!(c.as_wire(), *w);
2052        }
2053        assert!(FindingClass::from_wire("nonsense").is_none());
2054    }
2055
2056    /// A malformed binding id refuses before touching the store tier.
2057    #[test]
2058    fn malformed_binding_id_refuses() {
2059        assert!(matches!(
2060            split_binding_id("../escape"),
2061            Err(FindingsError::MalformedId(_))
2062        ));
2063        assert!(matches!(
2064            split_binding_id("no-slash"),
2065            Err(FindingsError::MalformedId(_))
2066        ));
2067        assert_eq!(
2068            split_binding_id("engine/graph").unwrap(),
2069            ("engine".to_string(), "graph".to_string())
2070        );
2071    }
2072
2073    // ---- A1/A5 end-to-end: verify writes durable findings, no entity write --
2074
2075    use crate::anchor::AnchorSidecar;
2076    use crate::binding::{
2077        BINDING_VERSION, BuildMode, BuildOperation, CoverageSemantics, DEFAULT_ADJUDICATION_CAP,
2078        DEFAULT_FULL_RESYNC_EVERY, Operations, VerifyOperation,
2079    };
2080    use crate::ingest::resolve::resolve_binding_run;
2081    use crate::pipeline::{IngestTrigger, MediumType, PatternEntry, PatternMode};
2082    use crate::pipeline_store::{load_pipeline_configs, write_binding};
2083    use crate::workspace::{
2084        Mount, MountCapability, MountLifecycle, MountStorage, Workspace, WorkspaceSettings,
2085    };
2086    use crate::workspace_store::WorkspaceStoreAdapter;
2087
2088    /// A full verify pass over a folder mem: it adjudicates the mem's anchors
2089    /// against the live source (orphaned → unresolvable-anchor; present
2090    /// hash-bearing whose recorded hash mismatches the observed prepared form
2091    /// → deterministic `drifted`; informed-by → no finding, A2) and flags an
2092    /// uncovered source file, then persists the findings to the durable state
2093    /// tier. A **fresh** read from disk (a later process) sees them (A1). The
2094    /// pass runs on a shared `&Engine` — structurally read-only on the mem (A5).
2095    #[test]
2096    fn verify_persists_findings_readable_fresh() {
2097        let tmp = tempfile::tempdir().unwrap();
2098        let root = tmp.path();
2099        let mem_dir = root.join("mem");
2100        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
2101        std::fs::write(
2102            mem_dir.join(".memstead").join("config.json"),
2103            r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
2104        )
2105        .unwrap();
2106
2107        // Workspace state so `from_workspace_root` sets `workspace_root` (which
2108        // the anchor observation and cursor need) and mounts the `engine` mem.
2109        std::fs::create_dir_all(root.join(".memstead")).unwrap();
2110        std::fs::write(
2111            root.join(".memstead").join("workspace.toml"),
2112            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2113        )
2114        .unwrap();
2115        let mount = Mount {
2116            mem: "engine".to_string(),
2117            schema: Some("default@1.0.0".parse().unwrap()),
2118            storage: MountStorage::Folder {
2119                path: mem_dir.clone(),
2120            },
2121            capability: MountCapability::Write,
2122            lifecycle: MountLifecycle::Eager,
2123            cross_linkable: false,
2124            migration_target: None,
2125        };
2126        crate::FileWorkspaceStore::new()
2127            .save_state(
2128                root,
2129                &Workspace {
2130                    mounts: vec![mount],
2131                    settings: WorkspaceSettings::default(),
2132                },
2133            )
2134            .unwrap();
2135
2136        // A git work tree at the workspace root so the codebase medium's `git`
2137        // change strategy resolves; source files: one anchored+present, one
2138        // uncovered.
2139        let out = std::process::Command::new("git")
2140            .args(["init", "-q"])
2141            .current_dir(root)
2142            .output()
2143            .unwrap();
2144        assert!(out.status.success());
2145        std::fs::create_dir_all(root.join("src")).unwrap();
2146        std::fs::write(root.join("src").join("present.rs"), "fn a() {}\n").unwrap();
2147        std::fs::write(root.join("src").join("uncovered.rs"), "fn b() {}\n").unwrap();
2148
2149        // Seed the engine-owned anchors sidecar directly (test fixture — the
2150        // production write path is the mutation surface, not this verify code).
2151        let mk = |artifact: &str, class: AnchorProvenanceClass| Anchor {
2152            artifact: artifact.to_string(),
2153            grain: AnchorGrain::File,
2154            class,
2155            at_version: None,
2156            hash: class.is_hash_bearing().then(|| "recorded".to_string()),
2157            hash_stability: AnchorHashStability::Stable,
2158            derived_from: Vec::new(),
2159            binding: None,
2160            source: None,
2161        };
2162        let mut sidecar = AnchorSidecar::default();
2163        sidecar.set(
2164            "engine--e",
2165            vec![
2166                mk("src/present.rs", AnchorProvenanceClass::Anchored), // recorded hash mismatches prepared form → drifted
2167                mk("src/gone.rs", AnchorProvenanceClass::Anchored), // absent → unresolvable-anchor
2168                mk("src/present.rs", AnchorProvenanceClass::InformedBy), // present, non-hash → no finding (A2)
2169            ],
2170        );
2171        std::fs::write(
2172            mem_dir.join(crate::anchor::ANCHOR_SIDECAR_PATH),
2173            sidecar.to_bytes(),
2174        )
2175        .unwrap();
2176
2177        // Binding engine/graph over a codebase facet (medium root = workspace).
2178        write_binding(
2179            root,
2180            "engine",
2181            "graph",
2182            &Binding {
2183                version: BINDING_VERSION,
2184                intent: None,
2185                sources: vec![crate::pipeline::Source {
2186                    name: "graph".to_string(),
2187                    medium_type: MediumType::Codebase,
2188                    pointer: String::new(),
2189                    change_detection: Some("git".to_string()),
2190                    scope: vec![PatternEntry {
2191                        path: "src/**/*.rs".to_string(),
2192                        mode: PatternMode::Allow,
2193                    }],
2194                    engagement: None,
2195                    preparation: None,
2196                }],
2197                reference_mems: Vec::new(),
2198                destination_mem: "engine".to_string(),
2199                deny_paths: Vec::new(),
2200                coverage_semantics: None,
2201                rules: None,
2202                prune: None,
2203                operations: Operations {
2204                    build: Some(BuildOperation {
2205                        mode: BuildMode::Discovery,
2206                        trigger: IngestTrigger::Loop,
2207                        batch_size: 20,
2208                        post_actions: None,
2209                    }),
2210                    sync: None,
2211                    verify: Some(VerifyOperation {
2212                        trigger: IngestTrigger::Manual,
2213                        batch_size: 20,
2214                        adjudication_cap: DEFAULT_ADJUDICATION_CAP,
2215                        full_resync_every: DEFAULT_FULL_RESYNC_EVERY,
2216                    }),
2217                },
2218            },
2219        )
2220        .unwrap();
2221
2222        let engine = Engine::from_workspace_root(root).unwrap();
2223
2224        let configs = load_pipeline_configs(root).unwrap();
2225        let binding = &configs.bindings[0].config;
2226        let resolved = resolve_binding_run("engine/graph", binding).unwrap();
2227
2228        // `&engine` — shared borrow, structurally cannot mutate the mem (A5).
2229        let outcome = verify_binding(&engine, root, binding, &resolved).unwrap();
2230        assert!(
2231            outcome.recorded >= 3,
2232            "orphan + drifted + uncovered at least"
2233        );
2234        assert_eq!(outcome.superseded, 0, "no prior key yet");
2235        assert_eq!(
2236            outcome.backlog, 0,
2237            "the mismatching hash adjudicated deterministically — nothing queued"
2238        );
2239        assert!(
2240            outcome.hash_backfill.is_empty(),
2241            "every hash-bearing anchor already carries a recorded hash — nothing to backfill"
2242        );
2243
2244        // Fresh read from disk — a later process / sync-brief render (A1).
2245        let store = read_findings_store(root, "engine", "graph")
2246            .unwrap()
2247            .unwrap();
2248        let current = store.current(&outcome.key);
2249        assert_eq!(current.len(), outcome.recorded);
2250
2251        let has = |c: FindingClass, art: &str| {
2252            current.iter().any(|f| {
2253                f.class == c
2254                    && match &f.target {
2255                        FindingTarget::Anchor { artifact, .. } => artifact == art,
2256                        FindingTarget::Artifact { artifact } => artifact == art,
2257                    }
2258            })
2259        };
2260        assert!(has(FindingClass::UnresolvableAnchor, "src/gone.rs"));
2261        assert!(
2262            has(FindingClass::Drifted, "src/present.rs"),
2263            "recorded-hash mismatch on a stable medium adjudicates drifted deterministically"
2264        );
2265        assert!(has(FindingClass::Uncovered, "src/uncovered.rs"));
2266        // A2: the informed-by anchor on the present file produced no finding —
2267        // the one drifted finding above belongs to the anchored (hash-bearing)
2268        // anchor, and nothing queued.
2269        assert!(
2270            !current
2271                .iter()
2272                .any(|f| f.class == FindingClass::QueuedForAdjudication
2273                    || f.class == FindingClass::Wrong),
2274            "deterministic adjudication leaves nothing queued"
2275        );
2276        // The covered file is not flagged uncovered.
2277        assert!(!has(FindingClass::Uncovered, "src/present.rs"));
2278    }
2279
2280    /// Criterion, end-to-end — **findings survive head movement**: a finding
2281    /// recorded at head H keeps presenting through the sync brief's read
2282    /// (`current_findings` / `render_sync_brief_for`) after the source
2283    /// advances to H′, until a verify observes its subject clean — and once
2284    /// resolved it never re-presents, at any head.
2285    #[test]
2286    fn finding_recorded_at_old_head_presents_in_brief_at_new_head() {
2287        use crate::ingest::render::render_sync_brief_for;
2288
2289        let tmp = tempfile::tempdir().unwrap();
2290        let root = tmp.path();
2291        let mem_dir = root.join("mem");
2292        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
2293        std::fs::write(
2294            mem_dir.join(".memstead").join("config.json"),
2295            r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
2296        )
2297        .unwrap();
2298        std::fs::create_dir_all(root.join(".memstead")).unwrap();
2299        std::fs::write(
2300            root.join(".memstead").join("workspace.toml"),
2301            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2302        )
2303        .unwrap();
2304        let mount = Mount {
2305            mem: "engine".to_string(),
2306            schema: Some("default@1.0.0".parse().unwrap()),
2307            storage: MountStorage::Folder {
2308                path: mem_dir.clone(),
2309            },
2310            capability: MountCapability::Write,
2311            lifecycle: MountLifecycle::Eager,
2312            cross_linkable: false,
2313            migration_target: None,
2314        };
2315        crate::FileWorkspaceStore::new()
2316            .save_state(
2317                root,
2318                &Workspace {
2319                    mounts: vec![mount],
2320                    settings: WorkspaceSettings::default(),
2321                },
2322            )
2323            .unwrap();
2324
2325        // Git source tree at head A: src/present.rs committed.
2326        let git = |args: &[&str]| {
2327            let out = std::process::Command::new("git")
2328                .args(args)
2329                .current_dir(root)
2330                .env("GIT_AUTHOR_NAME", "t")
2331                .env("GIT_AUTHOR_EMAIL", "t@t")
2332                .env("GIT_COMMITTER_NAME", "t")
2333                .env("GIT_COMMITTER_EMAIL", "t@t")
2334                .output()
2335                .unwrap();
2336            assert!(
2337                out.status.success(),
2338                "git {args:?}: {}",
2339                String::from_utf8_lossy(&out.stderr)
2340            );
2341        };
2342        git(&["init", "-q"]);
2343        std::fs::create_dir_all(root.join("src")).unwrap();
2344        std::fs::write(root.join("src").join("present.rs"), "fn a() {}\n").unwrap();
2345        git(&["add", "-A"]);
2346        git(&["commit", "-qm", "head-a"]);
2347
2348        // Anchors: `informed-by` on the present file (clean, non-hash — no
2349        // finding) and on the ABSENT src/gone.rs (orphaned → the finding).
2350        let mk = |artifact: &str| Anchor {
2351            artifact: artifact.to_string(),
2352            grain: AnchorGrain::File,
2353            class: AnchorProvenanceClass::InformedBy,
2354            at_version: None,
2355            hash: None,
2356            hash_stability: AnchorHashStability::Stable,
2357            derived_from: Vec::new(),
2358            binding: None,
2359            source: None,
2360        };
2361        let mut sidecar = AnchorSidecar::default();
2362        sidecar.set("engine--e", vec![mk("src/present.rs"), mk("src/gone.rs")]);
2363        std::fs::write(
2364            mem_dir.join(crate::anchor::ANCHOR_SIDECAR_PATH),
2365            sidecar.to_bytes(),
2366        )
2367        .unwrap();
2368
2369        write_binding(
2370            root,
2371            "engine",
2372            "graph",
2373            &Binding {
2374                version: BINDING_VERSION,
2375                intent: None,
2376                sources: vec![crate::pipeline::Source {
2377                    name: "graph".to_string(),
2378                    medium_type: MediumType::Codebase,
2379                    pointer: String::new(),
2380                    change_detection: Some("git".to_string()),
2381                    scope: vec![PatternEntry {
2382                        path: "src/**/*.rs".to_string(),
2383                        mode: PatternMode::Allow,
2384                    }],
2385                    engagement: None,
2386                    preparation: None,
2387                }],
2388                reference_mems: Vec::new(),
2389                destination_mem: "engine".to_string(),
2390                deny_paths: Vec::new(),
2391                coverage_semantics: None,
2392                rules: None,
2393                prune: None,
2394                operations: Operations {
2395                    build: None,
2396                    sync: Some(crate::binding::SyncOperation {
2397                        trigger: IngestTrigger::Manual,
2398                        batch_size: 20,
2399                    }),
2400                    verify: Some(VerifyOperation {
2401                        trigger: IngestTrigger::Manual,
2402                        batch_size: 20,
2403                        adjudication_cap: DEFAULT_ADJUDICATION_CAP,
2404                        full_resync_every: DEFAULT_FULL_RESYNC_EVERY,
2405                    }),
2406                },
2407            },
2408        )
2409        .unwrap();
2410
2411        // Verify at head A — records the orphaned-anchor finding.
2412        let configs = load_pipeline_configs(root).unwrap();
2413        let binding = &configs.bindings[0].config;
2414        let resolved = resolve_binding_run("engine/graph", binding).unwrap();
2415        let head_a_outcome = {
2416            let engine = Engine::from_workspace_root(root).unwrap();
2417            verify_binding(&engine, root, binding, &resolved).unwrap()
2418        };
2419        assert!(
2420            head_a_outcome.key.source_head.contains("graph="),
2421            "the run observed a facet head"
2422        );
2423
2424        // The source moves to head B.
2425        std::fs::write(root.join("src").join("present.rs"), "fn a() {} // more\n").unwrap();
2426        git(&["add", "-A"]);
2427        git(&["commit", "-qm", "head-b"]);
2428
2429        // A fresh process at head B: the finding recorded at head A is still
2430        // presented — by the brief's read AND in the rendered sync brief.
2431        {
2432            let engine = Engine::from_workspace_root(root).unwrap();
2433            let (key_b, findings) = current_findings(&engine, root, binding, &resolved).unwrap();
2434            assert_ne!(
2435                key_b.source_head, head_a_outcome.key.source_head,
2436                "the head really moved"
2437            );
2438            assert_eq!(findings.len(), 1);
2439            assert_eq!(findings[0].class, FindingClass::UnresolvableAnchor);
2440            assert_eq!(
2441                findings[0].key.source_head, head_a_outcome.key.source_head,
2442                "the finding still records the head it was observed at"
2443            );
2444
2445            let brief = render_sync_brief_for(&engine, root, "engine/graph").unwrap();
2446            assert!(brief.contains("## Open findings to repair"));
2447            assert!(brief.contains("src/gone.rs"));
2448        }
2449
2450        // The repair lands: src/gone.rs exists again (head C). A verify
2451        // observes the anchor clean → the finding closes…
2452        std::fs::write(root.join("src").join("gone.rs"), "fn g() {}\n").unwrap();
2453        git(&["add", "-A"]);
2454        git(&["commit", "-qm", "head-c"]);
2455        {
2456            let engine = Engine::from_workspace_root(root).unwrap();
2457            verify_binding(&engine, root, binding, &resolved).unwrap();
2458        }
2459        // …and never re-presents (REFUSAL: resolved findings stay resolved).
2460        {
2461            let engine = Engine::from_workspace_root(root).unwrap();
2462            let (_key, findings) = current_findings(&engine, root, binding, &resolved).unwrap();
2463            assert!(
2464                findings
2465                    .iter()
2466                    .all(|f| f.class != FindingClass::UnresolvableAnchor),
2467                "the resolved orphan finding must not re-present: {findings:?}"
2468            );
2469        }
2470    }
2471
2472    /// Prepared-hash backfill + deterministic drift, end-to-end over real git
2473    /// heads and fresh engines:
2474    ///
2475    /// 1. a hash-less `anchored`/`derived` anchor on a resolvable artifact is
2476    ///    backfilled by the first verify (once — a re-verify observes an empty
2477    ///    worklist and the recorded hash is never overwritten);
2478    /// 2. after a source change, a subsequent verify adjudicates `drifted`
2479    ///    deterministically — no LLM sampling, no queued deferral;
2480    /// 3. the tier-3 recheck queue for such anchors drains: post-backfill
2481    ///    clean passes queue nothing, instead of re-queueing forever.
2482    ///
2483    /// REFUSAL half: `authored` / `informed-by` anchors never gain hashes and
2484    /// never adjudicate `drifted`; an `unstable` hash-stability medium
2485    /// resolves `recheck` (queued), never `drifted`.
2486    #[test]
2487    fn hashless_anchor_backfills_once_then_drift_adjudicates_deterministically() {
2488        let tmp = tempfile::tempdir().unwrap();
2489        let root = tmp.path();
2490        let mem_dir = root.join("mem");
2491        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
2492        std::fs::write(
2493            mem_dir.join(".memstead").join("config.json"),
2494            r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
2495        )
2496        .unwrap();
2497        std::fs::create_dir_all(root.join(".memstead")).unwrap();
2498        std::fs::write(
2499            root.join(".memstead").join("workspace.toml"),
2500            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2501        )
2502        .unwrap();
2503        let mount = Mount {
2504            mem: "engine".to_string(),
2505            schema: Some("default@1.0.0".parse().unwrap()),
2506            storage: MountStorage::Folder {
2507                path: mem_dir.clone(),
2508            },
2509            capability: MountCapability::Write,
2510            lifecycle: MountLifecycle::Eager,
2511            cross_linkable: false,
2512            migration_target: None,
2513        };
2514        crate::FileWorkspaceStore::new()
2515            .save_state(
2516                root,
2517                &Workspace {
2518                    mounts: vec![mount],
2519                    settings: WorkspaceSettings::default(),
2520                },
2521            )
2522            .unwrap();
2523
2524        // Git source tree at head A: two committed source files.
2525        let git = |args: &[&str]| {
2526            let out = std::process::Command::new("git")
2527                .args(args)
2528                .current_dir(root)
2529                .env("GIT_AUTHOR_NAME", "t")
2530                .env("GIT_AUTHOR_EMAIL", "t@t")
2531                .env("GIT_COMMITTER_NAME", "t")
2532                .env("GIT_COMMITTER_EMAIL", "t@t")
2533                .output()
2534                .unwrap();
2535            assert!(
2536                out.status.success(),
2537                "git {args:?}: {}",
2538                String::from_utf8_lossy(&out.stderr)
2539            );
2540        };
2541        git(&["init", "-q"]);
2542        std::fs::create_dir_all(root.join("src")).unwrap();
2543        std::fs::write(root.join("src").join("present.rs"), "fn a() {}\n").unwrap();
2544        std::fs::write(root.join("src").join("other.rs"), "fn o() {}\n").unwrap();
2545        git(&["add", "-A"]);
2546        git(&["commit", "-qm", "head-a"]);
2547
2548        // Anchors, all HASH-LESS: `anchored` (stable) + `derived` (stable) on
2549        // present.rs, `anchored` but UNSTABLE on other.rs, and the two
2550        // non-hash classes that must never gain a hash.
2551        let mk = |artifact: &str, class: AnchorProvenanceClass, stab: AnchorHashStability| Anchor {
2552            artifact: artifact.to_string(),
2553            grain: AnchorGrain::File,
2554            class,
2555            at_version: None,
2556            hash: None,
2557            hash_stability: stab,
2558            derived_from: if class == AnchorProvenanceClass::Derived {
2559                vec!["src/present.rs".to_string()]
2560            } else {
2561                Vec::new()
2562            },
2563            binding: None,
2564            source: None,
2565        };
2566        use AnchorHashStability::{Stable, Unstable};
2567        let mut sidecar = AnchorSidecar::default();
2568        sidecar.set(
2569            "engine--e",
2570            vec![
2571                mk("src/present.rs", AnchorProvenanceClass::Anchored, Stable),
2572                mk("src/present.rs", AnchorProvenanceClass::Derived, Stable),
2573                mk("src/other.rs", AnchorProvenanceClass::Anchored, Unstable),
2574                mk("src/present.rs", AnchorProvenanceClass::Authored, Stable),
2575                mk("src/present.rs", AnchorProvenanceClass::InformedBy, Stable),
2576            ],
2577        );
2578        std::fs::write(
2579            mem_dir.join(crate::anchor::ANCHOR_SIDECAR_PATH),
2580            sidecar.to_bytes(),
2581        )
2582        .unwrap();
2583
2584        write_binding(
2585            root,
2586            "engine",
2587            "graph",
2588            &Binding {
2589                version: BINDING_VERSION,
2590                intent: None,
2591                sources: vec![crate::pipeline::Source {
2592                    name: "graph".to_string(),
2593                    medium_type: MediumType::Codebase,
2594                    pointer: String::new(),
2595                    change_detection: Some("git".to_string()),
2596                    scope: vec![PatternEntry {
2597                        path: "src/**/*.rs".to_string(),
2598                        mode: PatternMode::Allow,
2599                    }],
2600                    engagement: None,
2601                    preparation: None,
2602                }],
2603                reference_mems: Vec::new(),
2604                destination_mem: "engine".to_string(),
2605                deny_paths: Vec::new(),
2606                coverage_semantics: None,
2607                rules: None,
2608                prune: None,
2609                operations: Operations {
2610                    build: None,
2611                    sync: None,
2612                    verify: Some(VerifyOperation {
2613                        trigger: IngestTrigger::Manual,
2614                        batch_size: 20,
2615                        adjudication_cap: DEFAULT_ADJUDICATION_CAP,
2616                        full_resync_every: DEFAULT_FULL_RESYNC_EVERY,
2617                    }),
2618                },
2619            },
2620        )
2621        .unwrap();
2622
2623        let configs = load_pipeline_configs(root).unwrap();
2624        let binding = &configs.bindings[0].config;
2625        let resolved = resolve_binding_run("engine/graph", binding).unwrap();
2626
2627        // --- Pass 1: first observation backfills, once. ---
2628        {
2629            let mut engine = Engine::from_workspace_root(root).unwrap();
2630            let outcome = verify_binding(&engine, root, binding, &resolved).unwrap();
2631            // Every hash-less hash-bearing anchor is on the worklist —
2632            // including the unstable one; the non-hash classes are not.
2633            let mut backfilled: Vec<(&str, &str)> = outcome
2634                .hash_backfill
2635                .iter()
2636                .map(|b| (b.entity.as_str(), b.artifact.as_str()))
2637                .collect();
2638            backfilled.sort();
2639            backfilled.dedup();
2640            assert_eq!(
2641                backfilled,
2642                vec![
2643                    ("engine--e", "src/other.rs"),
2644                    ("engine--e", "src/present.rs"),
2645                ],
2646                "hash-bearing anchors backfill; authored/informed-by never appear"
2647            );
2648            // Backfill candidates are clean-by-construction this pass —
2649            // nothing queued, nothing drifted (the recheck queue drains).
2650            assert_eq!(
2651                outcome.backlog, 0,
2652                "no recheck queue for backfilled anchors"
2653            );
2654            let store = read_findings_store(root, "engine", "graph")
2655                .unwrap()
2656                .unwrap();
2657            assert!(
2658                store
2659                    .current(&outcome.key)
2660                    .iter()
2661                    .all(|f| !matches!(f.target, FindingTarget::Anchor { .. })),
2662                "no anchor finding on the backfill pass: {:?}",
2663                store.current(&outcome.key)
2664            );
2665
2666            // The sanctioned post-run write records the hashes.
2667            let written =
2668                record_anchor_hash_backfill(&mut engine, "engine", &outcome, None).unwrap();
2669            assert_eq!(
2670                written, 3,
2671                "anchored + derived + unstable-anchored gain hashes"
2672            );
2673        }
2674
2675        // The sidecar now carries the observed prepared-form hashes — and the
2676        // non-hash classes still carry none (class semantics preserved).
2677        let expected_present = crate::anchor::prepared_content_hash(
2678            &std::fs::read(root.join("src").join("present.rs")).unwrap(),
2679        );
2680        {
2681            let sc = AnchorSidecar::from_bytes(
2682                &std::fs::read(mem_dir.join(crate::anchor::ANCHOR_SIDECAR_PATH)).unwrap(),
2683            )
2684            .unwrap();
2685            for a in sc.get("engine--e") {
2686                if a.class.is_hash_bearing() {
2687                    assert!(a.hash.is_some(), "hash-bearing anchor backfilled: {a:?}");
2688                } else {
2689                    assert!(a.hash.is_none(), "non-hash class never gains a hash: {a:?}");
2690                }
2691                if a.artifact == "src/present.rs" && a.class.is_hash_bearing() {
2692                    assert_eq!(a.hash.as_deref(), Some(expected_present.as_str()));
2693                }
2694            }
2695        }
2696
2697        // --- Pass 2 (fresh engine): idempotent — nothing to backfill, clean. ---
2698        {
2699            let mut engine = Engine::from_workspace_root(root).unwrap();
2700            let outcome = verify_binding(&engine, root, binding, &resolved).unwrap();
2701            assert!(
2702                outcome.hash_backfill.is_empty(),
2703                "backfill happens once — a re-verify observes an empty worklist"
2704            );
2705            assert_eq!(outcome.backlog, 0, "steady state: nothing re-queues");
2706            let store = read_findings_store(root, "engine", "graph")
2707                .unwrap()
2708                .unwrap();
2709            assert!(
2710                store
2711                    .current(&outcome.key)
2712                    .iter()
2713                    .all(|f| !matches!(f.target, FindingTarget::Anchor { .. })),
2714                "recorded hashes match the source — no anchor finding"
2715            );
2716            let written =
2717                record_anchor_hash_backfill(&mut engine, "engine", &outcome, None).unwrap();
2718            assert_eq!(written, 0, "no write, no commit on the idempotent pass");
2719        }
2720
2721        // --- Source change: both anchored artifacts move (head B). ---
2722        std::fs::write(
2723            root.join("src").join("present.rs"),
2724            "fn a() { /* changed */ }\n",
2725        )
2726        .unwrap();
2727        std::fs::write(
2728            root.join("src").join("other.rs"),
2729            "fn o() { /* changed */ }\n",
2730        )
2731        .unwrap();
2732        git(&["add", "-A"]);
2733        git(&["commit", "-qm", "head-b"]);
2734
2735        // --- Pass 3: deterministic adjudication — stable drifts, unstable
2736        //     rechecks, non-hash classes stay silent. ---
2737        {
2738            let engine = Engine::from_workspace_root(root).unwrap();
2739            let outcome = verify_binding(&engine, root, binding, &resolved).unwrap();
2740            assert!(
2741                outcome.hash_backfill.is_empty(),
2742                "recorded hashes are never overwritten by observation"
2743            );
2744            let store = read_findings_store(root, "engine", "graph")
2745                .unwrap()
2746                .unwrap();
2747            let current = store.current(&outcome.key);
2748            let drifted: Vec<&Finding> = current
2749                .iter()
2750                .filter(|f| f.class == FindingClass::Drifted)
2751                .collect();
2752            // The stable `anchored` + `derived` anchors on present.rs drift —
2753            // deterministically, from the hash comparison alone.
2754            assert_eq!(
2755                drifted.len(),
2756                2,
2757                "stable-medium mismatch → drifted: {current:?}"
2758            );
2759            assert!(drifted.iter().all(|f| matches!(
2760                &f.target,
2761                FindingTarget::Anchor { artifact, .. } if artifact == "src/present.rs"
2762            )));
2763            // REFUSAL: the unstable anchor on other.rs resolves recheck →
2764            // queued, never drifted.
2765            assert!(
2766                current
2767                    .iter()
2768                    .any(|f| f.class == FindingClass::QueuedForAdjudication
2769                        && matches!(
2770                            &f.target,
2771                            FindingTarget::Anchor { artifact, .. } if artifact == "src/other.rs"
2772                        )),
2773                "unstable medium resolves recheck (queued), not drifted: {current:?}"
2774            );
2775            assert!(
2776                !current.iter().any(|f| f.class == FindingClass::Drifted
2777                    && matches!(
2778                        &f.target,
2779                        FindingTarget::Anchor { artifact, .. } if artifact == "src/other.rs"
2780                    )),
2781                "an unstable hash break must never assert drift"
2782            );
2783        }
2784    }
2785
2786    /// The engine's backfill writer enforces the class guard at the write
2787    /// seam: an `authored` / `informed-by` anchor never gains a hash even if
2788    /// a (buggy or malicious) caller hands one in, and a recorded hash is
2789    /// never overwritten.
2790    #[test]
2791    fn backfill_writer_refuses_non_hash_classes_and_never_overwrites() {
2792        let tmp = tempfile::tempdir().unwrap();
2793        let root = tmp.path();
2794        let mem_dir = root.join("mem");
2795        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
2796        std::fs::write(
2797            mem_dir.join(".memstead").join("config.json"),
2798            r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
2799        )
2800        .unwrap();
2801        std::fs::create_dir_all(root.join(".memstead")).unwrap();
2802        std::fs::write(
2803            root.join(".memstead").join("workspace.toml"),
2804            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2805        )
2806        .unwrap();
2807        crate::FileWorkspaceStore::new()
2808            .save_state(
2809                root,
2810                &Workspace {
2811                    mounts: vec![Mount {
2812                        mem: "engine".to_string(),
2813                        schema: Some("default@1.0.0".parse().unwrap()),
2814                        storage: MountStorage::Folder {
2815                            path: mem_dir.clone(),
2816                        },
2817                        capability: MountCapability::Write,
2818                        lifecycle: MountLifecycle::Eager,
2819                        cross_linkable: false,
2820                        migration_target: None,
2821                    }],
2822                    settings: WorkspaceSettings::default(),
2823                },
2824            )
2825            .unwrap();
2826
2827        let anchor = |class: AnchorProvenanceClass, hash: Option<&str>| Anchor {
2828            artifact: "src/a.rs".to_string(),
2829            grain: AnchorGrain::File,
2830            class,
2831            at_version: None,
2832            hash: hash.map(str::to_string),
2833            hash_stability: AnchorHashStability::Stable,
2834            derived_from: Vec::new(),
2835            binding: None,
2836            source: None,
2837        };
2838        let mut sidecar = AnchorSidecar::default();
2839        sidecar.set(
2840            "engine--e",
2841            vec![
2842                anchor(AnchorProvenanceClass::Authored, None),
2843                anchor(AnchorProvenanceClass::InformedBy, None),
2844                anchor(AnchorProvenanceClass::Anchored, Some("recorded")),
2845            ],
2846        );
2847        std::fs::write(
2848            mem_dir.join(crate::anchor::ANCHOR_SIDECAR_PATH),
2849            sidecar.to_bytes(),
2850        )
2851        .unwrap();
2852
2853        let mut engine = Engine::from_workspace_root(root).unwrap();
2854        let written = engine
2855            .record_anchor_observed_hashes(
2856                "engine",
2857                &[crate::anchor::ObservedArtifactHash {
2858                    entity: "engine--e".to_string(),
2859                    artifact: "src/a.rs".to_string(),
2860                    hash: "observed".to_string(),
2861                }],
2862                None,
2863            )
2864            .unwrap();
2865        assert_eq!(
2866            written, 0,
2867            "non-hash classes refuse the hash; a recorded hash is never overwritten"
2868        );
2869        let sc = AnchorSidecar::from_bytes(
2870            &std::fs::read(mem_dir.join(crate::anchor::ANCHOR_SIDECAR_PATH)).unwrap(),
2871        )
2872        .unwrap();
2873        for a in sc.get("engine--e") {
2874            match a.class {
2875                AnchorProvenanceClass::Anchored => {
2876                    assert_eq!(a.hash.as_deref(), Some("recorded"), "baseline stands")
2877                }
2878                _ => assert!(a.hash.is_none(), "non-hash class stays hash-less: {a:?}"),
2879            }
2880        }
2881    }
2882
2883    /// The completed-run `#verified` writer (backlog 2026-07-11): a verify
2884    /// pass surfaces its observed facet heads on the outcome (the per-facet
2885    /// decomposition of `key.source_head`), and [`record_verified_baseline`]
2886    /// records them as `<binding>/<facet>#verified` through the engine's
2887    /// sync-state writer — durable on disk, visible to the same config read
2888    /// `report`/`status` consume. A failed pass returns
2889    /// `Err` before any caller reaches the writer, so the token never
2890    /// advances on an aborted run.
2891    /// A vanished source directory must refuse verify with the typed
2892    /// `SourceUnreachable` error instead of degrading to an empty
2893    /// enumeration: pre-fix, the missing tree produced an empty stat map
2894    /// whose aggregate (the digest of nothing) completed the run and let
2895    /// the caller overwrite a genuine `#verified` baseline with fake
2896    /// state. The engine mem itself stays loadable — only the binding's
2897    /// source is gone.
2898    #[test]
2899    fn verify_refuses_unreachable_source_with_typed_error() {
2900        let tmp = tempfile::tempdir().unwrap();
2901        let root = tmp.path();
2902        let mem_dir = root.join("mem");
2903        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
2904        std::fs::write(
2905            mem_dir.join(".memstead").join("config.json"),
2906            r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
2907        )
2908        .unwrap();
2909        std::fs::create_dir_all(root.join(".memstead")).unwrap();
2910        std::fs::write(
2911            root.join(".memstead").join("workspace.toml"),
2912            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2913        )
2914        .unwrap();
2915        let mount = Mount {
2916            mem: "engine".to_string(),
2917            schema: Some("default@1.0.0".parse().unwrap()),
2918            storage: MountStorage::Folder {
2919                path: mem_dir.clone(),
2920            },
2921            capability: MountCapability::Write,
2922            lifecycle: MountLifecycle::Eager,
2923            cross_linkable: false,
2924            migration_target: None,
2925        };
2926        crate::FileWorkspaceStore::new()
2927            .save_state(
2928                root,
2929                &Workspace {
2930                    mounts: vec![mount],
2931                    settings: WorkspaceSettings::default(),
2932                },
2933            )
2934            .unwrap();
2935
2936        // The medium points at a subdirectory that does NOT exist — the
2937        // vanished-source case (`git` declared, so pre-fix the strategy
2938        // layer silently degraded instead of refusing).
2939        write_binding(
2940            root,
2941            "engine",
2942            "gone",
2943            &Binding {
2944                version: BINDING_VERSION,
2945                intent: None,
2946                sources: vec![crate::pipeline::Source {
2947                    name: "gone".to_string(),
2948                    medium_type: MediumType::Codebase,
2949                    pointer: "vanished-src".to_string(),
2950                    change_detection: Some("git".to_string()),
2951                    scope: vec![PatternEntry {
2952                        path: "**/*.rs".to_string(),
2953                        mode: PatternMode::Allow,
2954                    }],
2955                    engagement: None,
2956                    preparation: None,
2957                }],
2958                reference_mems: Vec::new(),
2959                destination_mem: "engine".to_string(),
2960                deny_paths: Vec::new(),
2961                coverage_semantics: None,
2962                rules: None,
2963                prune: None,
2964                operations: Operations {
2965                    build: None,
2966                    sync: None,
2967                    verify: Some(VerifyOperation {
2968                        trigger: IngestTrigger::Manual,
2969                        batch_size: 20,
2970                        adjudication_cap: DEFAULT_ADJUDICATION_CAP,
2971                        full_resync_every: DEFAULT_FULL_RESYNC_EVERY,
2972                    }),
2973                },
2974            },
2975        )
2976        .unwrap();
2977
2978        let engine = Engine::from_workspace_root(root).unwrap();
2979        let configs = load_pipeline_configs(root).unwrap();
2980        let binding = &configs.bindings[0].config;
2981        let resolved = resolve_binding_run("engine/gone", binding).unwrap();
2982
2983        match verify_binding(&engine, root, binding, &resolved) {
2984            Err(FindingsError::SourceUnreachable { source_name, path }) => {
2985                assert_eq!(source_name, "gone");
2986                assert!(
2987                    path.ends_with("vanished-src"),
2988                    "refusal must name the resolved missing path, got `{path}`",
2989                );
2990            }
2991            other => panic!("expected SourceUnreachable refusal, got {other:?}"),
2992        }
2993
2994        // Nothing was observed → no `#verified` token exists (the caller
2995        // never reaches its baseline write on an Err).
2996        assert!(
2997            !engine
2998                .mem_config_for("engine")
2999                .unwrap()
3000                .sync_state
3001                .keys()
3002                .any(|k| k.ends_with("#verified")),
3003            "a refused verify must not leave any #verified token",
3004        );
3005    }
3006
3007    #[test]
3008    fn completed_verify_records_the_verified_baseline() {
3009        let tmp = tempfile::tempdir().unwrap();
3010        let root = tmp.path();
3011        let mem_dir = root.join("mem");
3012        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
3013        std::fs::write(
3014            mem_dir.join(".memstead").join("config.json"),
3015            r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
3016        )
3017        .unwrap();
3018        std::fs::create_dir_all(root.join(".memstead")).unwrap();
3019        std::fs::write(
3020            root.join(".memstead").join("workspace.toml"),
3021            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
3022        )
3023        .unwrap();
3024        let mount = Mount {
3025            mem: "engine".to_string(),
3026            schema: Some("default@1.0.0".parse().unwrap()),
3027            storage: MountStorage::Folder {
3028                path: mem_dir.clone(),
3029            },
3030            capability: MountCapability::Write,
3031            lifecycle: MountLifecycle::Eager,
3032            cross_linkable: false,
3033            migration_target: None,
3034        };
3035        crate::FileWorkspaceStore::new()
3036            .save_state(
3037                root,
3038                &Workspace {
3039                    mounts: vec![mount],
3040                    settings: WorkspaceSettings::default(),
3041                },
3042            )
3043            .unwrap();
3044        let out = std::process::Command::new("git")
3045            .args(["init", "-q"])
3046            .current_dir(root)
3047            .output()
3048            .unwrap();
3049        assert!(out.status.success());
3050
3051        write_binding(
3052            root,
3053            "engine",
3054            "graph",
3055            &Binding {
3056                version: BINDING_VERSION,
3057                intent: None,
3058                sources: vec![crate::pipeline::Source {
3059                    name: "graph".to_string(),
3060                    medium_type: MediumType::Codebase,
3061                    pointer: String::new(),
3062                    change_detection: Some("git".to_string()),
3063                    scope: vec![PatternEntry {
3064                        path: "src/**/*.rs".to_string(),
3065                        mode: PatternMode::Allow,
3066                    }],
3067                    engagement: None,
3068                    preparation: None,
3069                }],
3070                reference_mems: Vec::new(),
3071                destination_mem: "engine".to_string(),
3072                deny_paths: Vec::new(),
3073                coverage_semantics: None,
3074                rules: None,
3075                prune: None,
3076                operations: Operations {
3077                    build: Some(BuildOperation {
3078                        mode: BuildMode::Discovery,
3079                        trigger: IngestTrigger::Loop,
3080                        batch_size: 20,
3081                        post_actions: None,
3082                    }),
3083                    sync: None,
3084                    verify: Some(VerifyOperation {
3085                        trigger: IngestTrigger::Manual,
3086                        batch_size: 20,
3087                        adjudication_cap: DEFAULT_ADJUDICATION_CAP,
3088                        full_resync_every: DEFAULT_FULL_RESYNC_EVERY,
3089                    }),
3090                },
3091            },
3092        )
3093        .unwrap();
3094
3095        let mut engine = Engine::from_workspace_root(root).unwrap();
3096        // A recorded `#synced` baseline is this facet's current head (the git
3097        // work tree has no commits, so the cursor contributes no newer token).
3098        engine
3099            .set_mem_sync_state("engine", "engine/graph/graph#synced", "deadbeef", None)
3100            .unwrap();
3101
3102        let configs = load_pipeline_configs(root).unwrap();
3103        let binding = &configs.bindings[0].config;
3104        let resolved = resolve_binding_run("engine/graph", binding).unwrap();
3105
3106        let outcome = verify_binding(&engine, root, binding, &resolved).unwrap();
3107        // The outcome decomposes its own key: joined facet heads == source_head.
3108        assert_eq!(
3109            outcome.facet_heads.get("graph").map(String::as_str),
3110            Some("deadbeef")
3111        );
3112        assert_eq!(outcome.key.source_head, "graph=deadbeef");
3113        assert_eq!(
3114            join_facet_heads(&outcome.facet_heads),
3115            outcome.key.source_head
3116        );
3117
3118        // No `#verified` token exists before the writer runs.
3119        assert!(
3120            !engine
3121                .mem_config_for("engine")
3122                .unwrap()
3123                .sync_state
3124                .contains_key("engine/graph/graph#verified")
3125        );
3126
3127        let written = record_verified_baseline(&mut engine, "engine", &outcome, None).unwrap();
3128        assert_eq!(written, vec!["engine/graph/graph#verified".to_string()]);
3129
3130        // Visible to the engine's config read (the app's sync_state source)…
3131        assert_eq!(
3132            engine
3133                .mem_config_for("engine")
3134                .unwrap()
3135                .sync_state
3136                .get("engine/graph/graph#verified")
3137                .map(String::as_str),
3138            Some("deadbeef")
3139        );
3140        // …and durable on disk (what a fresh CLI process reads).
3141        let disk: serde_json::Value = serde_json::from_slice(
3142            &std::fs::read(mem_dir.join(".memstead").join("config.json")).unwrap(),
3143        )
3144        .unwrap();
3145        assert_eq!(
3146            disk["syncState"]["engine/graph/graph#verified"],
3147            serde_json::json!("deadbeef")
3148        );
3149    }
3150
3151    // ---- D1: per-run adjudication cap -----------------------------------
3152
3153    /// D1 — the per-run cap queues the remainder. A rotation window covering
3154    /// only a subset of drift candidates adjudicates the in-window ones and
3155    /// QUEUES every out-of-window candidate as `queued-for-adjudication` (the
3156    /// tier-3 backlog). Uncapped (`window = None`) adjudicates every candidate.
3157    #[test]
3158    fn adjudication_cap_queues_the_remainder() {
3159        let k = key("h", "s");
3160        let mk = |art: &str| {
3161            let mut a = anchor(AnchorProvenanceClass::Anchored);
3162            a.artifact = art.to_string();
3163            a
3164        };
3165        let candidates = vec![
3166            (
3167                "engine--a".to_string(),
3168                mk("src/a.rs"),
3169                AnchorState::Drifted,
3170            ),
3171            (
3172                "engine--b".to_string(),
3173                mk("src/b.rs"),
3174                AnchorState::Drifted,
3175            ),
3176            (
3177                "engine--c".to_string(),
3178                mk("src/c.rs"),
3179                AnchorState::Drifted,
3180            ),
3181        ];
3182        // A cap-1 window selects only src/a.rs.
3183        let window: BTreeSet<String> = [candidate_key("engine--a", &mk("src/a.rs"))]
3184            .into_iter()
3185            .collect();
3186        let out = adjudicate_candidates(&k, "f", &candidates, Some(&window), "1");
3187        let drifted = out
3188            .iter()
3189            .filter(|f| f.class == FindingClass::Drifted)
3190            .count();
3191        let queued = out
3192            .iter()
3193            .filter(|f| f.class == FindingClass::QueuedForAdjudication)
3194            .count();
3195        assert_eq!(drifted, 1, "only the in-window candidate is adjudicated");
3196        assert_eq!(queued, 2, "the remainder is queued as the tier-3 backlog");
3197        // A queued remainder finding carries the queued detail, not a drift claim.
3198        assert!(
3199            out.iter()
3200                .any(|f| f.class == FindingClass::QueuedForAdjudication
3201                    && f.detail.contains("cap reached")),
3202            "capped remainder states it was deferred by the cap"
3203        );
3204
3205        // Uncapped: every candidate adjudicated, none queued.
3206        let uncapped = adjudicate_candidates(&k, "f", &candidates, None, "1");
3207        assert_eq!(
3208            uncapped
3209                .iter()
3210                .filter(|f| f.class == FindingClass::Drifted)
3211                .count(),
3212            3,
3213            "uncapped adjudicates every candidate"
3214        );
3215        assert_eq!(
3216            uncapped
3217                .iter()
3218                .filter(|f| f.class == FindingClass::QueuedForAdjudication)
3219                .count(),
3220            0
3221        );
3222    }
3223
3224    // ---- D3: full_resync scheduling + non-enumerable refusal ------------
3225
3226    /// D3 — `schedule_full_resync`: disabled at cadence 0; not-due off-cadence
3227    /// (with a countdown); due on-cadence for an enumerable facet (walked, no
3228    /// refusal).
3229    #[test]
3230    fn full_resync_schedule_disabled_notdue_due() {
3231        let codebase = FacetEnumerability {
3232            facet: "src".to_string(),
3233            medium_type: "codebase".to_string(),
3234            enumerable: true,
3235        };
3236        assert_eq!(
3237            schedule_full_resync(0, 5, std::slice::from_ref(&codebase)),
3238            FullResyncDecision::Disabled
3239        );
3240        match schedule_full_resync(3, 2, std::slice::from_ref(&codebase)) {
3241            FullResyncDecision::NotDue { runs_until_due, .. } => assert_eq!(runs_until_due, 1),
3242            other => panic!("expected NotDue, got {other:?}"),
3243        }
3244        match schedule_full_resync(3, 3, std::slice::from_ref(&codebase)) {
3245            FullResyncDecision::Due {
3246                walked_facets,
3247                refused,
3248                ..
3249            } => {
3250                assert_eq!(walked_facets, vec!["src".to_string()]);
3251                assert!(refused.is_empty(), "enumerable facet is not refused");
3252            }
3253            other => panic!("expected Due, got {other:?}"),
3254        }
3255    }
3256
3257    /// D3 REFUSAL — a scheduled full walk over a NON-enumerable medium refuses
3258    /// with a typed signal: it never claims coverage and is never a silent skip.
3259    #[test]
3260    fn full_resync_refuses_non_enumerable_medium() {
3261        let web = FacetEnumerability {
3262            facet: "manual".to_string(),
3263            medium_type: "web".to_string(),
3264            enumerable: false,
3265        };
3266        let d = schedule_full_resync(1, 1, &[web]);
3267        assert!(
3268            d.is_full_walk(),
3269            "a due sweep is a full walk even when refused"
3270        );
3271        match d {
3272            FullResyncDecision::Due {
3273                walked_facets,
3274                refused,
3275                ..
3276            } => {
3277                assert!(walked_facets.is_empty(), "nothing enumerable to walk");
3278                assert_eq!(refused.len(), 1, "the non-enumerable facet is refused");
3279                assert_eq!(refused[0].facet, "manual");
3280                assert_eq!(refused[0].medium_type, "web");
3281                assert!(
3282                    refused[0].reason.contains("non-enumerable"),
3283                    "the refusal is typed and states why"
3284                );
3285            }
3286            other => panic!("expected Due with a refusal, got {other:?}"),
3287        }
3288    }
3289
3290    /// D3 — a scheduled full walk fires the WHOLE-source enumeration this run:
3291    /// with `full_resync_every = 1` (due every run) and a sample `batch_size` of
3292    /// 1, all three uncovered source files are flagged, not just one — the full
3293    /// walk overrides the bounded rotating sample for an enumerable medium.
3294    #[test]
3295    fn full_resync_full_walk_covers_whole_source() {
3296        let tmp = tempfile::tempdir().unwrap();
3297        let root = tmp.path();
3298        let mem_dir = root.join("mem");
3299        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
3300        std::fs::write(
3301            mem_dir.join(".memstead").join("config.json"),
3302            r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
3303        )
3304        .unwrap();
3305        std::fs::create_dir_all(root.join(".memstead")).unwrap();
3306        std::fs::write(
3307            root.join(".memstead").join("workspace.toml"),
3308            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
3309        )
3310        .unwrap();
3311        let mount = Mount {
3312            mem: "engine".to_string(),
3313            schema: Some("default@1.0.0".parse().unwrap()),
3314            storage: MountStorage::Folder {
3315                path: mem_dir.clone(),
3316            },
3317            capability: MountCapability::Write,
3318            lifecycle: MountLifecycle::Eager,
3319            cross_linkable: false,
3320            migration_target: None,
3321        };
3322        crate::FileWorkspaceStore::new()
3323            .save_state(
3324                root,
3325                &Workspace {
3326                    mounts: vec![mount],
3327                    settings: WorkspaceSettings::default(),
3328                },
3329            )
3330            .unwrap();
3331        let out = std::process::Command::new("git")
3332            .args(["init", "-q"])
3333            .current_dir(root)
3334            .output()
3335            .unwrap();
3336        assert!(out.status.success());
3337        std::fs::create_dir_all(root.join("src")).unwrap();
3338        for f in ["a.rs", "b.rs", "c.rs"] {
3339            std::fs::write(root.join("src").join(f), "fn x() {}\n").unwrap();
3340        }
3341
3342        write_binding(
3343            root,
3344            "engine",
3345            "graph",
3346            &Binding {
3347                version: BINDING_VERSION,
3348                intent: None,
3349                sources: vec![crate::pipeline::Source {
3350                    name: "graph".to_string(),
3351                    medium_type: MediumType::Codebase,
3352                    pointer: String::new(),
3353                    change_detection: Some("git".to_string()),
3354                    scope: vec![PatternEntry {
3355                        path: "src/**/*.rs".to_string(),
3356                        mode: PatternMode::Allow,
3357                    }],
3358                    engagement: None,
3359                    preparation: None,
3360                }],
3361                reference_mems: Vec::new(),
3362                destination_mem: "engine".to_string(),
3363                deny_paths: Vec::new(),
3364                coverage_semantics: None,
3365                rules: None,
3366                prune: None,
3367                operations: Operations {
3368                    build: Some(BuildOperation {
3369                        mode: BuildMode::Discovery,
3370                        trigger: IngestTrigger::Loop,
3371                        batch_size: 20,
3372                        post_actions: None,
3373                    }),
3374                    sync: None,
3375                    verify: Some(VerifyOperation {
3376                        trigger: IngestTrigger::Manual,
3377                        batch_size: 1, // a tiny rotating sample …
3378                        adjudication_cap: DEFAULT_ADJUDICATION_CAP,
3379                        full_resync_every: 1, // … but a full walk fires EVERY run
3380                    }),
3381                },
3382            },
3383        )
3384        .unwrap();
3385
3386        let engine = Engine::from_workspace_root(root).unwrap();
3387        let configs = load_pipeline_configs(root).unwrap();
3388        let binding = &configs.bindings[0].config;
3389        let resolved = resolve_binding_run("engine/graph", binding).unwrap();
3390
3391        let outcome = verify_binding(&engine, root, binding, &resolved).unwrap();
3392        // The full walk is due on run 1 and covers the enumerable facet.
3393        match &outcome.full_resync {
3394            FullResyncDecision::Due {
3395                walked_facets,
3396                refused,
3397                run_count,
3398                ..
3399            } => {
3400                assert_eq!(*run_count, 1);
3401                assert_eq!(walked_facets, &vec!["graph".to_string()]);
3402                assert!(refused.is_empty());
3403            }
3404            other => panic!("expected a due full walk, got {other:?}"),
3405        }
3406        // All three uncovered files flagged despite the batch_size-1 sample.
3407        let store = read_findings_store(root, "engine", "graph")
3408            .unwrap()
3409            .unwrap();
3410        let uncovered = store
3411            .current(&outcome.key)
3412            .iter()
3413            .filter(|f| f.class == FindingClass::Uncovered)
3414            .count();
3415        assert_eq!(
3416            uncovered, 3,
3417            "the scheduled full walk covers the whole source, not a batch of one"
3418        );
3419    }
3420
3421    // ---- explicit full measurement (`verify_binding_full`) ----------------
3422
3423    /// An explicit full measurement walks the whole `S(D)` and treats the
3424    /// adjudication cap as unlimited — every drift candidate adjudicates and
3425    /// every uncovered artifact is flagged in ONE run, with nothing deferred
3426    /// to a cap or a rotating sample, and the decision reports `Forced`.
3427    /// REFUSAL half (byte-compat): a no-flag run over the same binding keeps
3428    /// today's capped/sampled behavior exactly — cap-1 adjudicates one
3429    /// candidate and queues the remainder with the cap-reached detail, and
3430    /// the batch-1 sample flags at most one uncovered file.
3431    #[test]
3432    fn full_verify_uncaps_adjudication_and_walks_whole_source() {
3433        let tmp = tempfile::tempdir().unwrap();
3434        let root = tmp.path();
3435        let mem_dir = root.join("mem");
3436        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
3437        std::fs::write(
3438            mem_dir.join(".memstead").join("config.json"),
3439            r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
3440        )
3441        .unwrap();
3442        std::fs::create_dir_all(root.join(".memstead")).unwrap();
3443        std::fs::write(
3444            root.join(".memstead").join("workspace.toml"),
3445            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
3446        )
3447        .unwrap();
3448        crate::FileWorkspaceStore::new()
3449            .save_state(
3450                root,
3451                &Workspace {
3452                    mounts: vec![Mount {
3453                        mem: "engine".to_string(),
3454                        schema: Some("default@1.0.0".parse().unwrap()),
3455                        storage: MountStorage::Folder {
3456                            path: mem_dir.clone(),
3457                        },
3458                        capability: MountCapability::Write,
3459                        lifecycle: MountLifecycle::Eager,
3460                        cross_linkable: false,
3461                        migration_target: None,
3462                    }],
3463                    settings: WorkspaceSettings::default(),
3464                },
3465            )
3466            .unwrap();
3467        let out = std::process::Command::new("git")
3468            .args(["init", "-q"])
3469            .current_dir(root)
3470            .output()
3471            .unwrap();
3472        assert!(out.status.success());
3473        std::fs::create_dir_all(root.join("src")).unwrap();
3474        // Three anchored (drift-candidate) files + three uncovered files.
3475        for f in ["a.rs", "b.rs", "c.rs", "d.rs", "e.rs", "f.rs"] {
3476            std::fs::write(root.join("src").join(f), "fn x() {}\n").unwrap();
3477        }
3478        let mk = |art: &str| Anchor {
3479            artifact: art.to_string(),
3480            grain: AnchorGrain::File,
3481            class: AnchorProvenanceClass::Anchored,
3482            at_version: None,
3483            hash: Some("stale-recorded-hash".to_string()), // mismatches → drift candidate
3484            hash_stability: AnchorHashStability::Stable,
3485            derived_from: Vec::new(),
3486            binding: None,
3487            source: None,
3488        };
3489        let mut sidecar = AnchorSidecar::default();
3490        sidecar.set(
3491            "engine--e",
3492            vec![mk("src/a.rs"), mk("src/b.rs"), mk("src/c.rs")],
3493        );
3494        std::fs::write(
3495            mem_dir.join(crate::anchor::ANCHOR_SIDECAR_PATH),
3496            sidecar.to_bytes(),
3497        )
3498        .unwrap();
3499
3500        write_binding(
3501            root,
3502            "engine",
3503            "graph",
3504            &Binding {
3505                version: BINDING_VERSION,
3506                intent: None,
3507                sources: vec![crate::pipeline::Source {
3508                    name: "graph".to_string(),
3509                    medium_type: MediumType::Codebase,
3510                    pointer: String::new(),
3511                    change_detection: Some("git".to_string()),
3512                    scope: vec![PatternEntry {
3513                        path: "src/**/*.rs".to_string(),
3514                        mode: PatternMode::Allow,
3515                    }],
3516                    engagement: None,
3517                    preparation: None,
3518                }],
3519                reference_mems: Vec::new(),
3520                destination_mem: "engine".to_string(),
3521                deny_paths: Vec::new(),
3522                coverage_semantics: None,
3523                rules: None,
3524                prune: None,
3525                operations: Operations {
3526                    build: None,
3527                    sync: None,
3528                    verify: Some(VerifyOperation {
3529                        trigger: IngestTrigger::Manual,
3530                        batch_size: 1,        // tiny rotating sample …
3531                        adjudication_cap: 1,  // … and a tiny cap
3532                        full_resync_every: 0, // scheduled walks disabled
3533                    }),
3534                },
3535            },
3536        )
3537        .unwrap();
3538
3539        let engine = Engine::from_workspace_root(root).unwrap();
3540        let configs = load_pipeline_configs(root).unwrap();
3541        let binding = &configs.bindings[0].config;
3542        let resolved = resolve_binding_run("engine/graph", binding).unwrap();
3543
3544        // Byte-compat leg — the no-flag run keeps today's capped/sampled
3545        // economics: one candidate adjudicated, two queued by the cap, at
3546        // most one uncovered file from the batch-1 sample, no full walk.
3547        let sampled = verify_binding(&engine, root, binding, &resolved).unwrap();
3548        assert_eq!(sampled.full_resync, FullResyncDecision::Disabled);
3549        let store = read_findings_store(root, "engine", "graph")
3550            .unwrap()
3551            .unwrap();
3552        let current = store.current(&sampled.key);
3553        let count = |c: FindingClass| current.iter().filter(|f| f.class == c).count();
3554        assert_eq!(count(FindingClass::Drifted), 1, "cap-1 adjudicates one");
3555        assert_eq!(
3556            count(FindingClass::QueuedForAdjudication),
3557            2,
3558            "the remainder queues"
3559        );
3560        assert!(
3561            current
3562                .iter()
3563                .any(|f| f.class == FindingClass::QueuedForAdjudication
3564                    && f.detail.contains("cap reached")),
3565            "the sampled deferral states the cap"
3566        );
3567        assert!(
3568            count(FindingClass::Uncovered) <= 1,
3569            "batch-1 sample looks at one artifact"
3570        );
3571
3572        // Full measurement: everything adjudicates, everything is walked,
3573        // nothing deferred — no sampling/truncation residue anywhere.
3574        let full = verify_binding_full(&engine, root, binding, &resolved).unwrap();
3575        assert_eq!(
3576            full.full_resync,
3577            FullResyncDecision::Forced {
3578                walked_facets: vec!["graph".to_string()]
3579            }
3580        );
3581        assert_eq!(full.backlog, 0, "cap treated as unlimited — no backlog");
3582        let store = read_findings_store(root, "engine", "graph")
3583            .unwrap()
3584            .unwrap();
3585        let current = store.current(&full.key);
3586        let count = |c: FindingClass| current.iter().filter(|f| f.class == c).count();
3587        assert_eq!(
3588            count(FindingClass::Drifted),
3589            3,
3590            "every candidate adjudicated"
3591        );
3592        assert_eq!(count(FindingClass::QueuedForAdjudication), 0);
3593        assert_eq!(
3594            count(FindingClass::Uncovered),
3595            3,
3596            "the whole S(D) walked — every uncovered file flagged"
3597        );
3598        assert!(
3599            current.iter().all(|f| !f.detail.contains("cap reached")),
3600            "a full run's findings carry no cap-deferral caveat"
3601        );
3602    }
3603
3604    /// REFUSAL — an explicit full measurement over a non-enumerable medium
3605    /// refuses the whole run with the typed capability error (nothing
3606    /// observed, nothing recorded — never a fabricated-complete report),
3607    /// while the no-flag sampled verify over the same binding still runs.
3608    #[test]
3609    fn full_verify_refuses_non_enumerable_medium_typed() {
3610        let tmp = tempfile::tempdir().unwrap();
3611        let root = tmp.path();
3612        let mem_dir = root.join("mem");
3613        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
3614        std::fs::write(
3615            mem_dir.join(".memstead").join("config.json"),
3616            r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
3617        )
3618        .unwrap();
3619        std::fs::create_dir_all(root.join(".memstead")).unwrap();
3620        std::fs::write(
3621            root.join(".memstead").join("workspace.toml"),
3622            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
3623        )
3624        .unwrap();
3625        crate::FileWorkspaceStore::new()
3626            .save_state(
3627                root,
3628                &Workspace {
3629                    mounts: vec![Mount {
3630                        mem: "engine".to_string(),
3631                        schema: Some("default@1.0.0".parse().unwrap()),
3632                        storage: MountStorage::Folder {
3633                            path: mem_dir.clone(),
3634                        },
3635                        capability: MountCapability::Write,
3636                        lifecycle: MountLifecycle::Eager,
3637                        cross_linkable: false,
3638                        migration_target: None,
3639                    }],
3640                    settings: WorkspaceSettings::default(),
3641                },
3642            )
3643            .unwrap();
3644
3645        // A web medium — the capability matrix marks it non-enumerable.
3646        write_binding(
3647            root,
3648            "engine",
3649            "manual",
3650            &Binding {
3651                version: BINDING_VERSION,
3652                intent: None,
3653                sources: vec![crate::pipeline::Source {
3654                    name: "manual".to_string(),
3655                    medium_type: MediumType::Web,
3656                    pointer: "https://example.com/docs".to_string(),
3657                    change_detection: None,
3658                    scope: Vec::new(),
3659                    engagement: None,
3660                    preparation: None,
3661                }],
3662                reference_mems: Vec::new(),
3663                destination_mem: "engine".to_string(),
3664                deny_paths: Vec::new(),
3665                coverage_semantics: Some(CoverageSemantics::Curated),
3666                rules: None,
3667                prune: None,
3668                operations: Operations {
3669                    build: None,
3670                    sync: None,
3671                    verify: Some(VerifyOperation {
3672                        trigger: IngestTrigger::Manual,
3673                        batch_size: 20,
3674                        adjudication_cap: DEFAULT_ADJUDICATION_CAP,
3675                        full_resync_every: DEFAULT_FULL_RESYNC_EVERY,
3676                    }),
3677                },
3678            },
3679        )
3680        .unwrap();
3681
3682        let engine = Engine::from_workspace_root(root).unwrap();
3683        let configs = load_pipeline_configs(root).unwrap();
3684        let binding = &configs.bindings[0].config;
3685        let resolved = resolve_binding_run("engine/manual", binding).unwrap();
3686
3687        // Full: typed refusal naming the facet and medium type; nothing recorded.
3688        let err = verify_binding_full(&engine, root, binding, &resolved).unwrap_err();
3689        match &err {
3690            FindingsError::FullWalkNonEnumerable(refusal) => {
3691                assert_eq!(refusal.facet, "manual");
3692                assert_eq!(refusal.medium_type, "web");
3693                assert!(refusal.reason.contains("non-enumerable"));
3694            }
3695            other => panic!("expected FullWalkNonEnumerable, got {other:?}"),
3696        }
3697        assert!(
3698            read_findings_store(root, "engine", "manual")
3699                .unwrap()
3700                .is_none(),
3701            "a refused full run records nothing"
3702        );
3703
3704        // No-flag: the sampled verify over the same binding still runs.
3705        let sampled = verify_binding(&engine, root, binding, &resolved).unwrap();
3706        assert_eq!(sampled.binding, "engine/manual");
3707    }
3708}