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