Skip to main content

memstead_base/ingest/
findings.rs

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