Skip to main content

mati_core/store/
repair.rs

1//! Gotcha index reconciliation engine.
2//!
3//! # Consistency model
4//!
5//! Gotcha mutations write to three locations:
6//!
7//! | Location | Role | Example key |
8//! |----------|------|-------------|
9//! | `gotcha:*` record | **Canonical truth** | `gotcha:never-unwrap` |
10//! | `file:*` payload `.gotcha_keys` | Derived index | `file:src/main.rs` |
11//! | `graph:edge:file:…:has_gotcha:gotcha:…` | Derived index | `graph:edge:file:src/main.rs:has_gotcha:gotcha:never-unwrap` |
12//!
13//! The canonical gotcha record is always written first and fails hard. The
14//! derived indexes (file links and graph edges) are best-effort: if they fail,
15//! the gotcha record still persists and a dirty marker is set so the drift is
16//! visible and repairable.
17//!
18//! This means **links and edges are never authoritative**. They are
19//! materialized views that can be rebuilt entirely from `gotcha:*` records.
20//!
21//! # Unnormalized paths
22//!
23//! Rebuilding from the canonical record only works if the record's
24//! `affected_files` are spelled the way the read gate spells them. Writes
25//! before `gotcha_ops` normalized them could store `./src/a.rs` or an absolute
26//! path, which joins to no `file:*` record at all — the gotcha is silently
27//! inert. This is drift in the canonical record itself, not in a derived index,
28//! so it gets its own class ([`RepairReport::unnormalized_paths`]) and is
29//! repaired first, before any index reconciliation.
30//!
31//! # Orphaned file records
32//!
33//! A `file:*` record whose path is no longer in the repo is not drift — the
34//! derived indexes agree with the canonical records, the file is simply gone.
35//! It still inflates every coverage denominator, so [`find_orphaned_files`]
36//! reports it, split into [`OrphanKind::OutsideRepo`] (another tree's paths)
37//! and [`OrphanKind::DeletedInRepo`] (this project's own history).
38//! [`purge_orphaned_files`] tombstones them behind `--purge-orphans`, and
39//! refuses above [`ORPHAN_PURGE_MAX_SHARE`].
40//!
41//! # Dirty markers
42//!
43//! When a best-effort secondary write fails in [`super::gotcha_ops`], the
44//! affected gotcha key is enqueued in a dirty marker record at
45//! `analytics:integrity:gotcha_links`. This marker is:
46//! - read by `mati status` to surface "index drift detected" warnings
47//! - drained by `mati repair --fast` for targeted reconciliation
48//! - cleared by `mati repair` after full reconciliation + verification
49//!
50//! # Repair modes
51//!
52//! - **Full** (`mati repair`): scans all gotcha and file records, diffs
53//!   against desired state, applies repairs, then verifies by re-running the
54//!   diff. Clears the dirty marker only after verification passes. This is the
55//!   only mode that provides a complete integrity guarantee.
56//!
57//! - **Fast** (`mati repair --fast`): drains the dirty-marker queue only.
58//!   Repairs the specific gotcha keys that were flagged. This is an
59//!   optimization, not an integrity proof — it cannot detect drift that wasn't
60//!   caused by a tracked failure (e.g., manual store edits, bugs in other
61//!   write paths).
62//!
63//! - **Check** (`mati repair --check`): read-only diff, no writes. Exits
64//!   non-zero if drift exists. CI-ready.
65//!
66//! # Usage
67//!
68//! ```text
69//! mati repair                  # full reconcile + verify
70//! mati repair --check          # detect drift, exit 1 if found (CI)
71//! mati repair --fast           # drain dirty queue only (opportunistic)
72//! mati repair --purge-orphans  # also tombstone file records whose path is gone
73//! mati repair --json           # machine-readable output
74//! ```
75
76use std::collections::{BTreeSet, HashMap, HashSet};
77use std::path::{Component, Path};
78use std::time::{SystemTime, UNIX_EPOCH};
79
80use anyhow::Result;
81use serde::{Deserialize, Serialize};
82
83use crate::graph::edges::{Edge, EdgeKind};
84use crate::store::db::Store;
85use crate::store::record::{
86    Category, GotchaRecord, Priority, Record, RecordLifecycle, RecordSource, RecordVersion,
87    StalenessScore, TombstoneReason,
88};
89
90/// Read-only store access used by the integrity *check* paths, so they can run
91/// against either a direct [`Store`] or a daemon-routed proxy (e.g. the CLI's
92/// `StoreProxy`, which routes reads through the daemon socket). Only the three
93/// read primitives the checks need — all writes stay on `&Store`.
94#[allow(async_fn_in_trait)]
95pub trait RepairReader {
96    async fn get(&self, key: &str) -> Result<Option<Record>>;
97    async fn scan_prefix(&self, prefix: &str) -> Result<Vec<Record>>;
98    async fn scan_keys(&self, prefix: &str) -> Result<Vec<String>>;
99}
100
101impl RepairReader for Store {
102    async fn get(&self, key: &str) -> Result<Option<Record>> {
103        Store::get(self, key).await
104    }
105    async fn scan_prefix(&self, prefix: &str) -> Result<Vec<Record>> {
106        Store::scan_prefix(self, prefix).await
107    }
108    async fn scan_keys(&self, prefix: &str) -> Result<Vec<String>> {
109        Store::scan_keys(self, prefix).await
110    }
111}
112
113fn now_secs() -> u64 {
114    SystemTime::now()
115        .duration_since(UNIX_EPOCH)
116        .unwrap_or_default()
117        .as_secs()
118}
119
120/// Dirty marker key — written when a best-effort secondary write fails.
121pub const DIRTY_MARKER_KEY: &str = "analytics:integrity:gotcha_links";
122
123// ── Report ───────────────────────────────────────────────────────────────────
124
125/// Result of a check or repair operation.
126#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct RepairReport {
128    pub scanned_gotchas: usize,
129    pub scanned_files: usize,
130    pub missing_file_links: Vec<DriftEntry>,
131    pub stale_file_links: Vec<DriftEntry>,
132    pub missing_edges: Vec<DriftEntry>,
133    pub stale_edges: Vec<DriftEntry>,
134    /// `affected_files` entries stored in a spelling the read gate never
135    /// produces (`./src/a.rs`, an absolute path, `src//a.rs`). `file_path`
136    /// holds the offending entry as stored. `#[serde(default)]` so reports
137    /// serialized before this field existed still deserialize.
138    #[serde(default)]
139    pub unnormalized_paths: Vec<DriftEntry>,
140    /// `file:*` records whose path no longer resolves under the repo root.
141    /// Not drift: the derived indexes agree with the canonical records, the
142    /// records are just about files that are gone. Only `mati repair`'s full
143    /// scan and `--check` fill this in — both have a repo root to resolve
144    /// against. `#[serde(default)]` for reports written before it existed.
145    #[serde(default)]
146    pub orphaned_files: Vec<OrphanEntry>,
147    pub repaired_count: usize,
148    pub verification_passed: bool,
149    pub dirty_marker_cleared: bool,
150}
151
152impl RepairReport {
153    pub fn has_drift(&self) -> bool {
154        !self.missing_file_links.is_empty()
155            || !self.stale_file_links.is_empty()
156            || !self.missing_edges.is_empty()
157            || !self.stale_edges.is_empty()
158            || !self.unnormalized_paths.is_empty()
159    }
160
161    pub fn total_drift(&self) -> usize {
162        self.missing_file_links.len()
163            + self.stale_file_links.len()
164            + self.missing_edges.len()
165            + self.stale_edges.len()
166            + self.unnormalized_paths.len()
167    }
168}
169
170/// A single drift item — identifies what's wrong and where.
171#[derive(Debug, Clone, Serialize, Deserialize)]
172pub struct DriftEntry {
173    pub gotcha_key: String,
174    pub file_path: String,
175}
176
177/// Why a `file:*` record's path does not resolve in the repo. The two mean
178/// different things to whoever is deciding whether to purge.
179#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
180#[serde(rename_all = "snake_case")]
181pub enum OrphanKind {
182    /// Absolute, or under a top-level directory this repo does not have —
183    /// another checkout's tree, indexed by a benchmark or a stray `mati init`.
184    OutsideRepo,
185    /// Repo-relative and plausible, but gone from the working tree. Real
186    /// project history: a file a refactor removed.
187    DeletedInRepo,
188}
189
190/// A `file:*` record naming a path that is not in the repo any more.
191#[derive(Debug, Clone, Serialize, Deserialize)]
192pub struct OrphanEntry {
193    pub key: String,
194    pub kind: OrphanKind,
195}
196
197/// Dirty marker payload — persisted at `DIRTY_MARKER_KEY`.
198#[derive(Debug, Clone, Serialize, Deserialize)]
199pub struct DirtyMarker {
200    pub dirty: bool,
201    pub dirty_since: u64,
202    pub cause: String,
203    pub affected_keys: Vec<String>,
204    pub last_checked_at: u64,
205    pub last_repaired_at: u64,
206}
207
208impl DirtyMarker {
209    pub fn clean() -> Self {
210        Self {
211            dirty: false,
212            dirty_since: 0,
213            cause: String::new(),
214            affected_keys: vec![],
215            last_checked_at: 0,
216            last_repaired_at: 0,
217        }
218    }
219}
220
221// ── Dirty marker operations ──────────────────────────────────────────────────
222
223/// Mark the gotcha index as dirty after a partial-write failure.
224pub async fn mark_dirty(store: &Store, gotcha_key: &str, cause: &str) {
225    let now = now_secs();
226
227    // Try to read existing marker to preserve history
228    let mut marker = read_dirty_marker(store)
229        .await
230        .unwrap_or_else(DirtyMarker::clean);
231    marker.dirty = true;
232    if marker.dirty_since == 0 {
233        marker.dirty_since = now;
234    }
235    marker.cause = cause.to_string();
236    if !marker.affected_keys.contains(&gotcha_key.to_string()) {
237        marker.affected_keys.push(gotcha_key.to_string());
238    }
239
240    let record = Record {
241        key: DIRTY_MARKER_KEY.to_string(),
242        value: cause.to_string(),
243        payload: serde_json::to_value(&marker).ok(),
244        category: Category::Analytics,
245        priority: Priority::Normal,
246        tags: vec![],
247        created_at: now,
248        updated_at: now,
249        ref_url: None,
250        staleness: StalenessScore::fresh(),
251        lifecycle: RecordLifecycle::Active,
252        version: RecordVersion {
253            device_id: crate::store::stable_device_id(),
254            logical_clock: 1,
255            wall_clock: now,
256        },
257        quality: crate::store::record::QualityScore::layer0_default(),
258        access_count: 0,
259        last_accessed: 0,
260        source: RecordSource::StaticAnalysis,
261        confidence: crate::store::record::ConfidenceScore::for_new_record(
262            &RecordSource::StaticAnalysis,
263        ),
264        gap_analysis_score: 0.0,
265    };
266
267    // Best-effort — don't fail the caller if marker write fails
268    let _ = store.put(DIRTY_MARKER_KEY, &record).await;
269}
270
271/// Read the current dirty marker, if any.
272pub async fn read_dirty_marker<R: RepairReader>(reader: &R) -> Option<DirtyMarker> {
273    reader
274        .get(DIRTY_MARKER_KEY)
275        .await
276        .ok()
277        .flatten()
278        .and_then(|r| r.payload_as::<DirtyMarker>())
279}
280
281/// Check whether the gotcha index is currently marked dirty.
282pub async fn is_dirty<R: RepairReader>(reader: &R) -> bool {
283    read_dirty_marker(reader)
284        .await
285        .map(|m| m.dirty)
286        .unwrap_or(false)
287}
288
289// ── Check ────────────────────────────────────────────────────────────────────
290
291/// Compute the diff between canonical gotcha state and derived indexes.
292/// Does not write anything.
293///
294/// `repo_root` must be [`crate::store::slug_root`] — the root an absolute
295/// `affected_files` entry has to be stripped of before it can be recognized as
296/// unnormalized.
297pub async fn check_gotcha_indexes<R: RepairReader>(
298    reader: &R,
299    repo_root: &Path,
300) -> Result<RepairReport> {
301    // Phase 1: derive desired state from canonical gotcha records
302    let mut desired = derive_desired_state(reader, repo_root).await?;
303
304    // Phase 2: diff against actual state
305    let actual = read_actual_file_links(reader).await?;
306    let actual_edges = read_actual_edges(reader).await?;
307    desired.drop_tombstoned(&actual.tombstoned);
308
309    let (missing_file_links, stale_file_links) =
310        diff_file_links(&desired.file_links, &actual.links);
311    let (missing_edges, stale_edges) = diff_edges(&desired.edges, &actual_edges);
312
313    Ok(RepairReport {
314        scanned_gotchas: desired.scanned,
315        scanned_files: actual.scanned,
316        missing_file_links,
317        stale_file_links,
318        missing_edges,
319        stale_edges,
320        unnormalized_paths: desired.unnormalized,
321        // Needs a repo root, which a `RepairReader` does not carry — the CLI
322        // fills this in from `slug_root`.
323        orphaned_files: vec![],
324        repaired_count: 0,
325        verification_passed: true, // check-only: no repair to verify
326        dirty_marker_cleared: false,
327    })
328}
329
330// ── Repair ───────────────────────────────────────────────────────────────────
331
332/// Repair mode controls what gets fixed.
333#[derive(Debug, Clone, Copy, PartialEq, Eq)]
334pub enum RepairMode {
335    /// Full scan and reconcile.
336    Full,
337    /// Only drain queued dirty items (fast path).
338    Fast,
339}
340
341/// Reconcile derived indexes to match canonical gotcha state.
342///
343/// `repo_root` must be [`crate::store::slug_root`], for the same reason
344/// [`check_gotcha_indexes`] needs it. Fast mode never reads it — it drains
345/// the dirty queue against paths the canonical records already carry.
346pub async fn repair_gotcha_indexes(
347    store: &Store,
348    repo_root: &Path,
349    mode: RepairMode,
350) -> Result<RepairReport> {
351    let now = now_secs();
352
353    // For fast mode, only repair keys from the dirty marker queue
354    if mode == RepairMode::Fast {
355        return repair_fast(store, now).await;
356    }
357
358    // Phase 0: re-key gotchas whose affected_files were stored in a spelling
359    // the read gate never produces. This has to run BEFORE the desired state is
360    // derived — desired state comes from the canonical paths, so reconciling
361    // first would rebuild links for the wrong spelling and undo itself.
362    let (unnormalized_paths, path_repairs) =
363        repair_unnormalized_paths(store, repo_root, now).await?;
364
365    // Phase 1: derive desired state
366    let mut desired = derive_desired_state(store, repo_root).await?;
367    let scanned_gotchas = desired.scanned;
368
369    // Phase 2: diff
370    let actual = read_actual_file_links(store).await?;
371    let actual_edges = read_actual_edges(store).await?;
372    desired.drop_tombstoned(&actual.tombstoned);
373    let scanned_files = actual.scanned;
374
375    let (missing_file_links, stale_file_links) =
376        diff_file_links(&desired.file_links, &actual.links);
377    let (missing_edges, stale_edges) = diff_edges(&desired.edges, &actual_edges);
378
379    let total_drift =
380        missing_file_links.len() + stale_file_links.len() + missing_edges.len() + stale_edges.len();
381
382    if total_drift == 0 {
383        // Already clean — clear dirty marker if set
384        clear_dirty_marker(store, now).await;
385        return Ok(RepairReport {
386            scanned_gotchas,
387            scanned_files,
388            missing_file_links: vec![],
389            stale_file_links: vec![],
390            missing_edges: vec![],
391            stale_edges: vec![],
392            unnormalized_paths,
393            orphaned_files: vec![],
394            repaired_count: path_repairs,
395            verification_passed: true,
396            dirty_marker_cleared: true,
397        });
398    }
399
400    // Phase 3: apply repairs
401
402    // 3a. Rebuild file-record gotcha_keys from desired state
403    let mut repaired = path_repairs;
404    for (file_path, desired_keys) in &desired.file_links {
405        let file_key = format!("file:{file_path}");
406        if let Ok(Some(mut record)) = store.get(&file_key).await {
407            let current_keys = extract_gotcha_keys(&record);
408            let desired_sorted: Vec<&String> = desired_keys.iter().collect();
409            let current_sorted: Vec<&String> = current_keys.iter().collect();
410
411            if desired_sorted != current_sorted {
412                set_gotcha_keys(&mut record, desired_keys.iter().cloned().collect());
413                record.updated_at = now;
414                record.version.logical_clock += 1;
415                record.version.wall_clock = now;
416                if store.put(&file_key, &record).await.is_ok() {
417                    repaired += 1;
418                }
419            }
420        }
421    }
422
423    // Also clear gotcha_keys from files that should have none
424    let actual_file_links_2 = read_actual_file_links(store).await?.links;
425    for (file_path, actual_keys) in &actual_file_links_2 {
426        if !desired.file_links.contains_key(file_path.as_str()) && !actual_keys.is_empty() {
427            let file_key = format!("file:{file_path}");
428            if let Ok(Some(mut record)) = store.get(&file_key).await {
429                set_gotcha_keys(&mut record, vec![]);
430                record.updated_at = now;
431                record.version.logical_clock += 1;
432                record.version.wall_clock = now;
433                if store.put(&file_key, &record).await.is_ok() {
434                    repaired += 1;
435                }
436            }
437        }
438    }
439
440    // 3b. Rebuild graph edges
441    let ts = now.to_le_bytes();
442    for entry in &missing_edges {
443        let file_key = format!("file:{}", entry.file_path);
444        let edge_key = Edge::new(&file_key, EdgeKind::HasGotcha, &entry.gotcha_key).to_key();
445        if store.put_raw(&edge_key, &ts).await.is_ok() {
446            repaired += 1;
447        }
448    }
449    for entry in &stale_edges {
450        let file_key = format!("file:{}", entry.file_path);
451        let edge_key = Edge::new(&file_key, EdgeKind::HasGotcha, &entry.gotcha_key).to_key();
452        if store.delete(&edge_key).await.is_ok() {
453            repaired += 1;
454        }
455    }
456
457    // Phase 4: verify by recomputing diff
458    let verify = check_gotcha_indexes(store, repo_root).await?;
459    let verification_passed = !verify.has_drift();
460
461    if verification_passed {
462        clear_dirty_marker(store, now).await;
463    }
464
465    Ok(RepairReport {
466        scanned_gotchas,
467        scanned_files,
468        missing_file_links,
469        stale_file_links,
470        missing_edges,
471        stale_edges,
472        unnormalized_paths,
473        orphaned_files: vec![],
474        repaired_count: repaired,
475        verification_passed,
476        dirty_marker_cleared: verification_passed,
477    })
478}
479
480// ── Path normalization repair ────────────────────────────────────────────────
481
482/// Rewrite every active gotcha whose `affected_files` are not in normalized
483/// form, and re-point its file links at the corrected paths.
484///
485/// Detection and repair share one scan. Link maintenance goes through
486/// [`crate::store::gotcha_ops::sync_gotcha_file_links`], which diffs old
487/// against new, handles the MVCC retry, and creates a Layer 0 `file:*` stub
488/// when the corrected path has no record yet — so the gate starts enforcing
489/// without waiting for a re-index. Graph edges are left to the main diff phase,
490/// which runs afterwards against the rewritten canonical records.
491///
492/// Returns the drift that was found (for the report) and the number of records
493/// rewritten. The record write fails hard, matching the canonical-record rule
494/// in the module docs; the link sync is best-effort.
495async fn repair_unnormalized_paths(
496    store: &Store,
497    repo_root: &Path,
498    now: u64,
499) -> Result<(Vec<DriftEntry>, usize)> {
500    let mut drift: Vec<DriftEntry> = Vec::new();
501    let mut repaired = 0usize;
502
503    for mut record in store.scan_prefix("gotcha:").await? {
504        if !matches!(record.lifecycle, RecordLifecycle::Active) {
505            continue;
506        }
507        let Some(gotcha) = record.payload_as::<GotchaRecord>() else {
508            continue;
509        };
510
511        let normalized =
512            crate::store::gotcha_ops::normalize_affected_files(&gotcha.affected_files, repo_root);
513        if normalized == gotcha.affected_files {
514            continue;
515        }
516        drift.extend(unnormalized_entries(
517            &record.key,
518            &gotcha.affected_files,
519            repo_root,
520        ));
521
522        // Patch the array in place rather than re-serializing a GotchaRecord:
523        // MCP `mem_set` merges caller payloads, so a gotcha payload can carry
524        // fields beyond the struct and re-serializing would drop them.
525        let Some(obj) = record.payload.as_mut().and_then(|p| p.as_object_mut()) else {
526            continue;
527        };
528        obj.insert(
529            "affected_files".into(),
530            serde_json::Value::Array(
531                normalized
532                    .iter()
533                    .cloned()
534                    .map(serde_json::Value::String)
535                    .collect(),
536            ),
537        );
538        record.updated_at = now;
539        record.version.logical_clock += 1;
540        record.version.wall_clock = now;
541        store.put(&record.key, &record).await?;
542        repaired += 1;
543
544        if let Err(e) = crate::store::gotcha_ops::sync_gotcha_file_links(
545            store,
546            &record.key,
547            &gotcha.affected_files,
548            &normalized,
549        )
550        .await
551        {
552            tracing::warn!("repair: file link re-point failed for {}: {e}", record.key);
553        }
554    }
555
556    Ok((drift, repaired))
557}
558
559// ── Orphaned file records ────────────────────────────────────────────────────
560
561/// Refuse to purge when orphans are this share of the active `file:*` records.
562///
563/// The failure this guards against is a root that names the wrong tree, which
564/// fails *every* path — its signature is ~100%. The worst rate measured on a
565/// real store is 48% (a benchmark that indexed a second repo into it). 90%
566/// sits between the two.
567pub const ORPHAN_PURGE_MAX_SHARE: f64 = 0.9;
568
569/// One orphan scan: what was found, and what it was found among.
570pub struct OrphanScan {
571    pub orphans: Vec<OrphanEntry>,
572    /// Active `file:*` records scanned — the denominator the purge guard uses.
573    pub active_files: usize,
574}
575
576/// Result of [`purge_orphaned_files`].
577#[derive(Debug, Clone, Copy, PartialEq, Eq)]
578pub enum PurgeOutcome {
579    Purged {
580        tombstoned: usize,
581        unlinked: usize,
582    },
583    /// Too many orphans to be believable — see [`ORPHAN_PURGE_MAX_SHARE`].
584    Refused {
585        orphans: usize,
586        active_files: usize,
587    },
588}
589
590/// Find active `file:*` records whose path is no longer in the repo.
591///
592/// `root` must be [`crate::store::slug_root`] — the working tree the store's
593/// slug was keyed on, which is what record paths are relative to. The process
594/// cwd is not it (a command runs from subdirectories) and neither is git's
595/// discovery from an arbitrary path (a gitlink child resolves to its parent).
596pub async fn find_orphaned_files<R: RepairReader>(reader: &R, root: &Path) -> Result<OrphanScan> {
597    let mut orphans = Vec::new();
598    let mut active_files = 0usize;
599
600    for record in reader.scan_prefix("file:").await? {
601        if !matches!(record.lifecycle, RecordLifecycle::Active) {
602            continue;
603        }
604        active_files += 1;
605
606        let path = record.key.strip_prefix("file:").unwrap_or_default();
607        if path.is_empty() || root.join(path).exists() {
608            continue;
609        }
610        orphans.push(OrphanEntry {
611            key: record.key.clone(),
612            kind: classify_orphan(root, path),
613        });
614    }
615
616    Ok(OrphanScan {
617        orphans,
618        active_files,
619    })
620}
621
622/// A path that leaves the repo, or sits under a top-level directory the repo
623/// does not have, came from another tree. Anything else is plausibly ours.
624///
625/// A missing file directly at the repo root has no directory to judge it by,
626/// so it reads as deleted-in-repo — the conservative side of the split.
627fn classify_orphan(root: &Path, path: &str) -> OrphanKind {
628    let path = Path::new(path);
629    if path.is_absolute() {
630        return OrphanKind::OutsideRepo;
631    }
632    let mut components = path.components();
633    match (components.next(), components.next()) {
634        (Some(Component::ParentDir), _) => OrphanKind::OutsideRepo,
635        (Some(Component::Normal(top)), Some(_)) if !root.join(top).is_dir() => {
636            OrphanKind::OutsideRepo
637        }
638        _ => OrphanKind::DeletedInRepo,
639    }
640}
641
642/// Tombstone every orphan in `scan` and drop the derived links pointing at it.
643///
644/// Soft delete, the way gotcha deletion works: the record stays readable by
645/// key and the gotchas that named it are untouched — only the record's
646/// `gotcha_keys` entries and its `HasGotcha` edges go. `derive_desired_state`
647/// stops wanting links to a tombstoned file record, so the run verifies clean.
648pub async fn purge_orphaned_files(store: &Store, scan: &OrphanScan) -> Result<PurgeOutcome> {
649    if scan.orphans.len() as f64 > scan.active_files as f64 * ORPHAN_PURGE_MAX_SHARE {
650        return Ok(PurgeOutcome::Refused {
651            orphans: scan.orphans.len(),
652            active_files: scan.active_files,
653        });
654    }
655
656    let now = now_secs();
657    let mut tombstoned = 0usize;
658    let mut unlinked = 0usize;
659
660    for orphan in &scan.orphans {
661        let Some(mut record) = store.get(&orphan.key).await? else {
662            continue;
663        };
664        for gotcha_key in extract_gotcha_keys(&record) {
665            let edge = Edge::new(&record.key, EdgeKind::HasGotcha, &gotcha_key).to_key();
666            let _ = store.delete(&edge).await;
667            unlinked += 1;
668        }
669        set_gotcha_keys(&mut record, vec![]);
670        let dead =
671            crate::store::gotcha_ops::tombstoned_copy(&record, TombstoneReason::FileDeleted, now);
672        store.put(&dead.key, &dead).await?;
673        tombstoned += 1;
674    }
675
676    Ok(PurgeOutcome::Purged {
677        tombstoned,
678        unlinked,
679    })
680}
681
682// ── Fast repair ──────────────────────────────────────────────────────────────
683
684async fn repair_fast(store: &Store, now: u64) -> Result<RepairReport> {
685    let marker = match read_dirty_marker(store).await {
686        Some(m) if m.dirty => m,
687        _ => {
688            return Ok(RepairReport {
689                scanned_gotchas: 0,
690                scanned_files: 0,
691                missing_file_links: vec![],
692                stale_file_links: vec![],
693                missing_edges: vec![],
694                stale_edges: vec![],
695                unnormalized_paths: vec![],
696                orphaned_files: vec![],
697                repaired_count: 0,
698                verification_passed: true,
699                dirty_marker_cleared: false,
700            });
701        }
702    };
703
704    let mut repaired = 0usize;
705    let ts = now.to_le_bytes();
706
707    for gotcha_key in &marker.affected_keys {
708        // Read canonical state for this gotcha
709        let desired_files: Vec<String> = match store.get(gotcha_key).await? {
710            Some(record) if matches!(record.lifecycle, RecordLifecycle::Active) => record
711                .payload_as::<GotchaRecord>()
712                .map(|g| g.affected_files)
713                .unwrap_or_default(),
714            // Tombstoned or missing — desired state is empty
715            _ => vec![],
716        };
717
718        // Repair file links
719        for file_path in &desired_files {
720            let file_key = format!("file:{file_path}");
721            if let Ok(Some(mut record)) = store.get(&file_key).await {
722                let keys = extract_gotcha_keys(&record);
723                if !keys.contains(gotcha_key) {
724                    let mut new_keys = keys;
725                    new_keys.push(gotcha_key.clone());
726                    set_gotcha_keys(&mut record, new_keys);
727                    record.updated_at = now;
728                    record.version.logical_clock += 1;
729                    record.version.wall_clock = now;
730                    if store.put(&file_key, &record).await.is_ok() {
731                        repaired += 1;
732                    }
733                }
734            }
735
736            // Repair edge
737            let file_key = format!("file:{file_path}");
738            let edge_key = Edge::new(&file_key, EdgeKind::HasGotcha, gotcha_key.as_str()).to_key();
739            if store.put_raw(&edge_key, &ts).await.is_ok() {
740                repaired += 1;
741            }
742        }
743
744        // Remove stale links from files that reference this gotcha but are NOT
745        // in the current desired_files. This handles both:
746        // - tombstoned/missing gotchas (desired_files is empty → all refs removed)
747        // - moved gotchas (e.g. affected_files changed from [A,B] to [B,C] → A cleaned)
748        //
749        // Previously, this scan only ran for the tombstoned case, leaving stale
750        // links behind when a gotcha's affected_files changed.
751        {
752            let desired_set: std::collections::HashSet<&str> =
753                desired_files.iter().map(String::as_str).collect();
754            let files = store.scan_prefix("file:").await?;
755            for mut file_record in files {
756                let file_path = file_record
757                    .key
758                    .strip_prefix("file:")
759                    .unwrap_or(&file_record.key);
760                // Skip files that are correctly in desired_files
761                if desired_set.contains(file_path) {
762                    continue;
763                }
764                let keys = extract_gotcha_keys(&file_record);
765                if keys.contains(gotcha_key) {
766                    let new_keys: Vec<String> =
767                        keys.into_iter().filter(|k| k != gotcha_key).collect();
768                    set_gotcha_keys(&mut file_record, new_keys);
769                    file_record.updated_at = now;
770                    file_record.version.logical_clock += 1;
771                    file_record.version.wall_clock = now;
772                    if store.put(&file_record.key, &file_record).await.is_ok() {
773                        repaired += 1;
774                    }
775                }
776                // Also remove stale HasGotcha edge
777                let edge_key =
778                    Edge::new(&file_record.key, EdgeKind::HasGotcha, gotcha_key.as_str()).to_key();
779                let _ = store.delete(&edge_key).await;
780            }
781        }
782    }
783
784    if repaired > 0 {
785        clear_dirty_marker(store, now).await;
786    }
787
788    Ok(RepairReport {
789        scanned_gotchas: marker.affected_keys.len(),
790        scanned_files: 0,
791        missing_file_links: vec![],
792        stale_file_links: vec![],
793        missing_edges: vec![],
794        stale_edges: vec![],
795        // Fast mode never scans for unnormalized paths — that needs the full
796        // `gotcha:*` walk, which is exactly what this mode exists to skip.
797        unnormalized_paths: vec![],
798        orphaned_files: vec![],
799        repaired_count: repaired,
800        verification_passed: true,
801        dirty_marker_cleared: repaired > 0,
802    })
803}
804
805// ── Internal helpers ─────────────────────────────────────────────────────────
806
807/// Phase 1 output: what the derived indexes should contain, plus the canonical
808/// records that are themselves malformed.
809struct DesiredState {
810    file_links: HashMap<String, BTreeSet<String>>,
811    edges: BTreeSet<(String, String)>,
812    scanned: usize,
813    /// `affected_files` entries that are not in normalized form. Reported as
814    /// drift in its own right — the desired state derived above still uses the
815    /// stored spelling, since that is what the indexes were built from.
816    unnormalized: Vec<DriftEntry>,
817}
818
819impl DesiredState {
820    /// A tombstoned `file:*` record is not a link target, the same way a
821    /// tombstoned gotcha is not a link source. Without this, purging an orphan
822    /// that carried gotcha links would read as missing-link drift forever.
823    fn drop_tombstoned(&mut self, tombstoned: &HashSet<String>) {
824        self.file_links.retain(|path, _| !tombstoned.contains(path));
825        self.edges.retain(|(path, _)| !tombstoned.contains(path));
826    }
827}
828
829/// Phase 1: build desired state from canonical gotcha records.
830async fn derive_desired_state<R: RepairReader>(
831    reader: &R,
832    repo_root: &Path,
833) -> Result<DesiredState> {
834    let gotchas = reader.scan_prefix("gotcha:").await?;
835    let scanned = gotchas.len();
836
837    let mut file_links: HashMap<String, BTreeSet<String>> = HashMap::new();
838    let mut edges: BTreeSet<(String, String)> = BTreeSet::new();
839    let mut unnormalized: Vec<DriftEntry> = Vec::new();
840
841    for record in &gotchas {
842        if !matches!(record.lifecycle, RecordLifecycle::Active) {
843            continue;
844        }
845        let Some(gotcha) = record.payload_as::<GotchaRecord>() else {
846            continue;
847        };
848
849        unnormalized.extend(unnormalized_entries(
850            &record.key,
851            &gotcha.affected_files,
852            repo_root,
853        ));
854
855        for file_path in &gotcha.affected_files {
856            file_links
857                .entry(file_path.clone())
858                .or_default()
859                .insert(record.key.clone());
860            edges.insert((file_path.clone(), record.key.clone()));
861        }
862    }
863
864    Ok(DesiredState {
865        file_links,
866        edges,
867        scanned,
868        unnormalized,
869    })
870}
871
872/// Which of `affected_files` are not in the form the read gate produces.
873///
874/// An entry is drift when it does not survive
875/// [`crate::store::gotcha_ops::normalize_affected_files`] verbatim — either it
876/// normalized to something else (`./src/a.rs` → `src/a.rs`) or it collapsed
877/// into a duplicate of another entry.
878fn unnormalized_entries(
879    gotcha_key: &str,
880    affected_files: &[String],
881    repo_root: &Path,
882) -> Vec<DriftEntry> {
883    let normalized = crate::store::gotcha_ops::normalize_affected_files(affected_files, repo_root);
884    if normalized == affected_files {
885        return Vec::new();
886    }
887    affected_files
888        .iter()
889        .filter(|path| !normalized.contains(*path))
890        .map(|path| DriftEntry {
891            gotcha_key: gotcha_key.to_string(),
892            file_path: path.clone(),
893        })
894        .collect()
895}
896
897/// Actual state of the file-link index: the `gotcha_keys` each file record
898/// carries, how many records were scanned, and which of their paths belong to
899/// a tombstoned record.
900struct ActualFileLinks {
901    links: HashMap<String, Vec<String>>,
902    scanned: usize,
903    tombstoned: HashSet<String>,
904}
905
906/// Read actual gotcha_keys from all file records.
907async fn read_actual_file_links<R: RepairReader>(reader: &R) -> Result<ActualFileLinks> {
908    let files = reader.scan_prefix("file:").await?;
909    let scanned = files.len();
910    let mut links: HashMap<String, Vec<String>> = HashMap::new();
911    let mut tombstoned: HashSet<String> = HashSet::new();
912
913    for record in &files {
914        let path = record
915            .key
916            .strip_prefix("file:")
917            .unwrap_or(&record.key)
918            .to_string();
919        if !matches!(record.lifecycle, RecordLifecycle::Active) {
920            tombstoned.insert(path.clone());
921        }
922        let keys = extract_gotcha_keys(record);
923        if !keys.is_empty() {
924            links.insert(path, keys);
925        }
926    }
927
928    Ok(ActualFileLinks {
929        links,
930        scanned,
931        tombstoned,
932    })
933}
934
935/// Read actual HasGotcha edges from the graph edge store.
936async fn read_actual_edges<R: RepairReader>(reader: &R) -> Result<BTreeSet<(String, String)>> {
937    let edge_keys = reader.scan_keys("graph:edge:").await?;
938    let mut actual = BTreeSet::new();
939
940    for key in &edge_keys {
941        if let Some(edge) = Edge::from_key(key) {
942            if edge.kind == EdgeKind::HasGotcha {
943                let file_path = edge
944                    .from
945                    .strip_prefix("file:")
946                    .unwrap_or(&edge.from)
947                    .to_string();
948                actual.insert((file_path, edge.to));
949            }
950        }
951    }
952
953    Ok(actual)
954}
955
956/// Diff file links: compare desired vs actual.
957fn diff_file_links(
958    desired: &HashMap<String, BTreeSet<String>>,
959    actual: &HashMap<String, Vec<String>>,
960) -> (Vec<DriftEntry>, Vec<DriftEntry>) {
961    let mut missing = Vec::new();
962    let mut stale = Vec::new();
963
964    // Find missing links (in desired but not in actual)
965    for (file_path, desired_keys) in desired {
966        let actual_keys: BTreeSet<String> = actual
967            .get(file_path)
968            .map(|v| v.iter().cloned().collect())
969            .unwrap_or_default();
970
971        for key in desired_keys {
972            if !actual_keys.contains(key) {
973                missing.push(DriftEntry {
974                    gotcha_key: key.clone(),
975                    file_path: file_path.clone(),
976                });
977            }
978        }
979    }
980
981    // Find stale links (in actual but not in desired)
982    for (file_path, actual_keys) in actual {
983        let desired_keys = desired.get(file_path);
984        for key in actual_keys {
985            let is_desired = desired_keys.map(|d| d.contains(key)).unwrap_or(false);
986            if !is_desired {
987                stale.push(DriftEntry {
988                    gotcha_key: key.clone(),
989                    file_path: file_path.clone(),
990                });
991            }
992        }
993    }
994
995    (missing, stale)
996}
997
998/// Diff edges: compare desired vs actual.
999fn diff_edges(
1000    desired: &BTreeSet<(String, String)>,
1001    actual: &BTreeSet<(String, String)>,
1002) -> (Vec<DriftEntry>, Vec<DriftEntry>) {
1003    let missing: Vec<DriftEntry> = desired
1004        .difference(actual)
1005        .map(|(file_path, gotcha_key)| DriftEntry {
1006            gotcha_key: gotcha_key.clone(),
1007            file_path: file_path.clone(),
1008        })
1009        .collect();
1010
1011    let stale: Vec<DriftEntry> = actual
1012        .difference(desired)
1013        .map(|(file_path, gotcha_key)| DriftEntry {
1014            gotcha_key: gotcha_key.clone(),
1015            file_path: file_path.clone(),
1016        })
1017        .collect();
1018
1019    (missing, stale)
1020}
1021
1022fn extract_gotcha_keys(record: &Record) -> Vec<String> {
1023    record
1024        .payload
1025        .as_ref()
1026        .and_then(|p| p.get("gotcha_keys"))
1027        .and_then(|v| v.as_array())
1028        .map(|arr| {
1029            arr.iter()
1030                .filter_map(|v| v.as_str().map(String::from))
1031                .collect()
1032        })
1033        .unwrap_or_default()
1034}
1035
1036fn set_gotcha_keys(record: &mut Record, keys: Vec<String>) {
1037    if let Some(payload) = record.payload.as_mut() {
1038        if let Some(obj) = payload.as_object_mut() {
1039            obj.insert(
1040                "gotcha_keys".into(),
1041                serde_json::Value::Array(keys.into_iter().map(serde_json::Value::String).collect()),
1042            );
1043        }
1044    }
1045}
1046
1047/// Remove a single gotcha key from the dirty marker if it is the only key
1048/// currently flagged.
1049///
1050/// Used by [`super::gotcha_ops`] as the "disarm" half of its cancellation
1051/// guard: after a successful all-secondary-writes path, the caller pre-armed
1052/// `mark_dirty(key)` upfront and now wants to release it. If another caller
1053/// has flagged a different key concurrently (or a previous failure left a
1054/// key behind), we leave the marker alone — `repair_fast` on the next boot
1055/// will reconcile both. This is best-effort and never blocks the caller.
1056pub async fn clear_dirty_key_if_solo(store: &Store, gotcha_key: &str) {
1057    let Some(mut marker) = read_dirty_marker(store).await else {
1058        return;
1059    };
1060    if !marker.dirty {
1061        return;
1062    }
1063    // Only clear if our key is the *only* dirty one. If other keys are
1064    // present, leaving the marker intact is the safe choice — repair will
1065    // reconcile our successfully-written derived state as a no-op.
1066    let only_ours = marker.affected_keys.len() == 1 && marker.affected_keys[0] == gotcha_key;
1067    if !only_ours {
1068        return;
1069    }
1070
1071    let now = now_secs();
1072    marker.dirty = false;
1073    marker.affected_keys.clear();
1074    marker.last_repaired_at = now;
1075
1076    let record = Record {
1077        key: DIRTY_MARKER_KEY.to_string(),
1078        value: String::new(),
1079        payload: serde_json::to_value(&marker).ok(),
1080        category: Category::Analytics,
1081        priority: Priority::Normal,
1082        tags: vec![],
1083        created_at: now,
1084        updated_at: now,
1085        ref_url: None,
1086        staleness: StalenessScore::fresh(),
1087        lifecycle: RecordLifecycle::Active,
1088        version: RecordVersion {
1089            device_id: crate::store::stable_device_id(),
1090            logical_clock: 1,
1091            wall_clock: now,
1092        },
1093        quality: crate::store::record::QualityScore::layer0_default(),
1094        access_count: 0,
1095        last_accessed: 0,
1096        source: RecordSource::StaticAnalysis,
1097        confidence: crate::store::record::ConfidenceScore::for_new_record(
1098            &RecordSource::StaticAnalysis,
1099        ),
1100        gap_analysis_score: 0.0,
1101    };
1102    let _ = store.put(DIRTY_MARKER_KEY, &record).await;
1103}
1104
1105async fn clear_dirty_marker(store: &Store, now: u64) {
1106    if let Some(mut marker) = read_dirty_marker(store).await {
1107        marker.dirty = false;
1108        marker.affected_keys.clear();
1109        marker.last_repaired_at = now;
1110
1111        let record = Record {
1112            key: DIRTY_MARKER_KEY.to_string(),
1113            value: String::new(),
1114            payload: serde_json::to_value(&marker).ok(),
1115            category: Category::Analytics,
1116            priority: Priority::Normal,
1117            tags: vec![],
1118            created_at: now,
1119            updated_at: now,
1120            ref_url: None,
1121            staleness: StalenessScore::fresh(),
1122            lifecycle: RecordLifecycle::Active,
1123            version: RecordVersion {
1124                device_id: crate::store::stable_device_id(),
1125                logical_clock: 1,
1126                wall_clock: now,
1127            },
1128            quality: crate::store::record::QualityScore::layer0_default(),
1129            access_count: 0,
1130            last_accessed: 0,
1131            source: RecordSource::StaticAnalysis,
1132            confidence: crate::store::record::ConfidenceScore::for_new_record(
1133                &RecordSource::StaticAnalysis,
1134            ),
1135            gap_analysis_score: 0.0,
1136        };
1137        let _ = store.put(DIRTY_MARKER_KEY, &record).await;
1138    }
1139}
1140
1141// ── Tests ────────────────────────────────────────────────────────────────────
1142
1143#[cfg(test)]
1144mod tests {
1145    use super::*;
1146    use crate::store::record::FileRecord;
1147
1148    fn make_gotcha(key: &str, files: &[&str]) -> Record {
1149        let gotcha = GotchaRecord {
1150            rule: "test".into(),
1151            reason: "test".into(),
1152            severity: Priority::High,
1153            affected_files: files.iter().map(|s| s.to_string()).collect(),
1154            ref_url: None,
1155            discovered_session: 1_000_000,
1156            confirmed: true,
1157            confirmed_content: Default::default(),
1158        };
1159        Record {
1160            key: key.to_string(),
1161            value: "test".into(),
1162            payload: serde_json::to_value(&gotcha).ok(),
1163            category: Category::Gotcha,
1164            priority: Priority::High,
1165            tags: vec![],
1166            created_at: 1_000_000,
1167            updated_at: 1_000_000,
1168            ref_url: None,
1169            staleness: StalenessScore::fresh(),
1170            lifecycle: RecordLifecycle::Active,
1171            version: RecordVersion {
1172                device_id: uuid::Uuid::new_v4(),
1173                logical_clock: 1,
1174                wall_clock: 1_000_000,
1175            },
1176            quality: crate::store::record::QualityScore::layer0_default(),
1177            access_count: 0,
1178            last_accessed: 0,
1179            source: RecordSource::DeveloperManual,
1180            confidence: crate::store::record::ConfidenceScore::for_new_record(
1181                &RecordSource::DeveloperManual,
1182            ),
1183            gap_analysis_score: 0.0,
1184        }
1185    }
1186
1187    fn make_file(path: &str, gotcha_keys: &[&str]) -> Record {
1188        let file = FileRecord {
1189            path: path.to_string(),
1190            purpose: String::new(),
1191            entry_points: vec![],
1192            imports: vec![],
1193            gotcha_keys: gotcha_keys.iter().map(|s| s.to_string()).collect(),
1194            decision_keys: vec![],
1195            todos: vec![],
1196            unsafe_count: 0,
1197            unwrap_count: 0,
1198            change_frequency: 0,
1199            last_author: None,
1200            is_hotspot: false,
1201            token_cost_estimate: 0,
1202            last_modified_session: 0,
1203            content_hash: None,
1204            line_count: 0,
1205            blast_radius: None,
1206            propagated_staleness: None,
1207        };
1208        Record {
1209            key: format!("file:{path}"),
1210            value: String::new(),
1211            payload: serde_json::to_value(&file).ok(),
1212            category: Category::File,
1213            priority: Priority::Normal,
1214            tags: vec![],
1215            created_at: 1_000_000,
1216            updated_at: 1_000_000,
1217            ref_url: None,
1218            staleness: StalenessScore::fresh(),
1219            lifecycle: RecordLifecycle::Active,
1220            version: RecordVersion {
1221                device_id: uuid::Uuid::new_v4(),
1222                logical_clock: 1,
1223                wall_clock: 1_000_000,
1224            },
1225            quality: crate::store::record::QualityScore::layer0_default(),
1226            access_count: 0,
1227            last_accessed: 0,
1228            source: RecordSource::StaticAnalysis,
1229            confidence: crate::store::record::ConfidenceScore::for_new_record(
1230                &RecordSource::StaticAnalysis,
1231            ),
1232            gap_analysis_score: 0.0,
1233        }
1234    }
1235
1236    #[tokio::test]
1237    async fn check_detects_no_drift_when_consistent() {
1238        let dir = tempfile::TempDir::new().unwrap();
1239        let store = Store::open(dir.path()).await.unwrap();
1240
1241        store
1242            .put("gotcha:g1", &make_gotcha("gotcha:g1", &["src/a.rs"]))
1243            .await
1244            .unwrap();
1245        store
1246            .put("file:src/a.rs", &make_file("src/a.rs", &["gotcha:g1"]))
1247            .await
1248            .unwrap();
1249
1250        let edge = Edge::new("file:src/a.rs", EdgeKind::HasGotcha, "gotcha:g1");
1251        store
1252            .put_raw(&edge.to_key(), &now_secs().to_le_bytes())
1253            .await
1254            .unwrap();
1255
1256        let report = check_gotcha_indexes(&store, dir.path()).await.unwrap();
1257        assert!(!report.has_drift());
1258        assert_eq!(report.scanned_gotchas, 1);
1259        assert_eq!(report.scanned_files, 1);
1260
1261        store.close().await.unwrap();
1262    }
1263
1264    #[tokio::test]
1265    async fn codeowners_candidate_write_has_no_index_drift() {
1266        let dir = tempfile::TempDir::new().unwrap();
1267        let store = Store::open(dir.path()).await.unwrap();
1268        for path in ["src/payments/card.rs", "src/payments/wallet.rs"] {
1269            store
1270                .put(&format!("file:{path}"), &make_file(path, &[]))
1271                .await
1272                .unwrap();
1273        }
1274
1275        let rules = crate::analysis::onboarding::parse_codeowners("src/payments/** @team\n");
1276        let repo_files = vec![
1277            "src/payments/card.rs".to_string(),
1278            "src/payments/wallet.rs".to_string(),
1279        ];
1280        let candidate = crate::analysis::onboarding::codeowners_candidates(
1281            &rules,
1282            &repo_files,
1283            uuid::Uuid::nil(),
1284            1,
1285            1_000_000,
1286        )
1287        .pop()
1288        .unwrap();
1289        let gotcha = candidate
1290            .payload_as::<GotchaRecord>()
1291            .expect("candidate must carry a GotchaRecord");
1292
1293        crate::store::gotcha_ops::apply_gotcha_write(
1294            &store,
1295            dir.path(),
1296            &candidate,
1297            &[],
1298            &gotcha.affected_files,
1299            true,
1300        )
1301        .await
1302        .unwrap();
1303
1304        let report = check_gotcha_indexes(&store, dir.path()).await.unwrap();
1305        assert!(!report.has_drift());
1306        store.close().await.unwrap();
1307    }
1308
1309    #[tokio::test]
1310    async fn check_detects_missing_file_link() {
1311        let dir = tempfile::TempDir::new().unwrap();
1312        let store = Store::open(dir.path()).await.unwrap();
1313
1314        store
1315            .put("gotcha:g1", &make_gotcha("gotcha:g1", &["src/a.rs"]))
1316            .await
1317            .unwrap();
1318        // File exists but has no gotcha_keys
1319        store
1320            .put("file:src/a.rs", &make_file("src/a.rs", &[]))
1321            .await
1322            .unwrap();
1323
1324        let report = check_gotcha_indexes(&store, dir.path()).await.unwrap();
1325        assert!(report.has_drift());
1326        assert_eq!(report.missing_file_links.len(), 1);
1327        assert_eq!(report.missing_file_links[0].gotcha_key, "gotcha:g1");
1328        assert_eq!(report.missing_file_links[0].file_path, "src/a.rs");
1329
1330        store.close().await.unwrap();
1331    }
1332
1333    #[tokio::test]
1334    async fn check_detects_stale_file_link() {
1335        let dir = tempfile::TempDir::new().unwrap();
1336        let store = Store::open(dir.path()).await.unwrap();
1337
1338        // No active gotcha, but file still references one
1339        store
1340            .put("file:src/a.rs", &make_file("src/a.rs", &["gotcha:deleted"]))
1341            .await
1342            .unwrap();
1343
1344        let report = check_gotcha_indexes(&store, dir.path()).await.unwrap();
1345        assert!(report.has_drift());
1346        assert_eq!(report.stale_file_links.len(), 1);
1347        assert_eq!(report.stale_file_links[0].gotcha_key, "gotcha:deleted");
1348
1349        store.close().await.unwrap();
1350    }
1351
1352    #[tokio::test]
1353    async fn repair_fixes_missing_links_and_verifies() {
1354        let dir = tempfile::TempDir::new().unwrap();
1355        let store = Store::open(dir.path()).await.unwrap();
1356
1357        store
1358            .put(
1359                "gotcha:g1",
1360                &make_gotcha("gotcha:g1", &["src/a.rs", "src/b.rs"]),
1361            )
1362            .await
1363            .unwrap();
1364        store
1365            .put("file:src/a.rs", &make_file("src/a.rs", &[]))
1366            .await
1367            .unwrap();
1368        store
1369            .put("file:src/b.rs", &make_file("src/b.rs", &[]))
1370            .await
1371            .unwrap();
1372
1373        let report = repair_gotcha_indexes(&store, dir.path(), RepairMode::Full)
1374            .await
1375            .unwrap();
1376        assert!(report.verification_passed);
1377        assert!(report.repaired_count > 0);
1378        assert!(report.dirty_marker_cleared);
1379
1380        // Verify file records now have the right keys
1381        let a = store.get("file:src/a.rs").await.unwrap().unwrap();
1382        let b = store.get("file:src/b.rs").await.unwrap().unwrap();
1383        assert!(extract_gotcha_keys(&a).contains(&"gotcha:g1".to_string()));
1384        assert!(extract_gotcha_keys(&b).contains(&"gotcha:g1".to_string()));
1385
1386        // Verify edges exist
1387        let edges = store.scan_keys("graph:edge:").await.unwrap();
1388        let edge_a = Edge::new("file:src/a.rs", EdgeKind::HasGotcha, "gotcha:g1").to_key();
1389        let edge_b = Edge::new("file:src/b.rs", EdgeKind::HasGotcha, "gotcha:g1").to_key();
1390        assert!(edges.contains(&edge_a));
1391        assert!(edges.contains(&edge_b));
1392
1393        store.close().await.unwrap();
1394    }
1395
1396    #[tokio::test]
1397    async fn repair_removes_stale_links() {
1398        let dir = tempfile::TempDir::new().unwrap();
1399        let store = Store::open(dir.path()).await.unwrap();
1400
1401        // File references a gotcha that doesn't exist
1402        store
1403            .put("file:src/a.rs", &make_file("src/a.rs", &["gotcha:ghost"]))
1404            .await
1405            .unwrap();
1406
1407        let report = repair_gotcha_indexes(&store, dir.path(), RepairMode::Full)
1408            .await
1409            .unwrap();
1410        assert!(report.verification_passed);
1411
1412        let a = store.get("file:src/a.rs").await.unwrap().unwrap();
1413        assert!(extract_gotcha_keys(&a).is_empty());
1414
1415        store.close().await.unwrap();
1416    }
1417
1418    // ── Unnormalized affected_files ──────────────────────────────────────
1419    //
1420    // Records written before `gotcha_ops` normalized paths carry spellings the
1421    // read gate never produces, so they join to no `file:*` record and the
1422    // gotcha is silently inert. `--check` has to surface that (it gates CI) and
1423    // a normal run has to fix it.
1424
1425    #[tokio::test]
1426    async fn check_detects_unnormalized_affected_files() {
1427        let dir = tempfile::TempDir::new().unwrap();
1428        let store = Store::open(dir.path()).await.unwrap();
1429
1430        store
1431            .put("gotcha:g1", &make_gotcha("gotcha:g1", &["./src/a.rs"]))
1432            .await
1433            .unwrap();
1434
1435        let report = check_gotcha_indexes(&store, dir.path()).await.unwrap();
1436        assert!(report.has_drift(), "--check must exit non-zero on this");
1437        assert_eq!(report.unnormalized_paths.len(), 1);
1438        assert_eq!(report.unnormalized_paths[0].gotcha_key, "gotcha:g1");
1439        assert_eq!(report.unnormalized_paths[0].file_path, "./src/a.rs");
1440        // Check is read-only: nothing was rewritten.
1441        let stored = store.get("gotcha:g1").await.unwrap().unwrap();
1442        let gotcha = stored.payload_as::<GotchaRecord>().unwrap();
1443        assert_eq!(gotcha.affected_files, vec!["./src/a.rs".to_string()]);
1444
1445        store.close().await.unwrap();
1446    }
1447
1448    #[tokio::test]
1449    async fn check_reports_no_unnormalized_drift_for_clean_paths_and_globs() {
1450        let dir = tempfile::TempDir::new().unwrap();
1451        let store = Store::open(dir.path()).await.unwrap();
1452
1453        store
1454            .put(
1455                "gotcha:g1",
1456                &make_gotcha("gotcha:g1", &["src/a.rs", "src/payments/**"]),
1457            )
1458            .await
1459            .unwrap();
1460        store
1461            .put("file:src/a.rs", &make_file("src/a.rs", &["gotcha:g1"]))
1462            .await
1463            .unwrap();
1464
1465        let report = check_gotcha_indexes(&store, dir.path()).await.unwrap();
1466        assert!(report.unnormalized_paths.is_empty());
1467
1468        store.close().await.unwrap();
1469    }
1470
1471    /// Full repair re-keys the record, links the corrected path (creating a
1472    /// Layer 0 stub because `mati init` never indexed it), and drops the link
1473    /// at the raw spelling. Verification must pass on the same run.
1474    #[tokio::test]
1475    async fn repair_rewrites_unnormalized_paths_and_repoints_links() {
1476        let dir = tempfile::TempDir::new().unwrap();
1477        let store = Store::open(dir.path()).await.unwrap();
1478
1479        store
1480            .put("gotcha:g1", &make_gotcha("gotcha:g1", &["./src/a.rs"]))
1481            .await
1482            .unwrap();
1483        // A file record left over at the raw spelling, as a pre-fix write to an
1484        // existing `file:./src/a.rs` stub would have produced.
1485        store
1486            .put("file:./src/a.rs", &make_file("./src/a.rs", &["gotcha:g1"]))
1487            .await
1488            .unwrap();
1489
1490        let report = repair_gotcha_indexes(&store, dir.path(), RepairMode::Full)
1491            .await
1492            .unwrap();
1493        assert_eq!(report.unnormalized_paths.len(), 1);
1494        assert!(report.repaired_count > 0);
1495        assert!(report.verification_passed);
1496
1497        let stored = store.get("gotcha:g1").await.unwrap().unwrap();
1498        let gotcha = stored.payload_as::<GotchaRecord>().unwrap();
1499        assert_eq!(gotcha.affected_files, vec!["src/a.rs".to_string()]);
1500
1501        let good = store
1502            .get("file:src/a.rs")
1503            .await
1504            .unwrap()
1505            .expect("corrected path must be linked, stubbed if it had no record");
1506        assert_eq!(extract_gotcha_keys(&good), vec!["gotcha:g1".to_string()]);
1507
1508        let raw = store.get("file:./src/a.rs").await.unwrap().unwrap();
1509        assert!(
1510            extract_gotcha_keys(&raw).is_empty(),
1511            "link at the raw spelling must be dropped"
1512        );
1513
1514        // Idempotent: a second run finds nothing left to do.
1515        let again = check_gotcha_indexes(&store, dir.path()).await.unwrap();
1516        assert!(!again.has_drift());
1517
1518        store.close().await.unwrap();
1519    }
1520
1521    /// Tombstoned records are excluded from every other drift class; the path
1522    /// check must not resurrect them into the report either.
1523    #[tokio::test]
1524    async fn repair_skips_unnormalized_paths_on_tombstoned_gotchas() {
1525        let dir = tempfile::TempDir::new().unwrap();
1526        let store = Store::open(dir.path()).await.unwrap();
1527
1528        let mut record = make_gotcha("gotcha:dead", &["./src/a.rs"]);
1529        record.lifecycle = RecordLifecycle::Tombstoned {
1530            reason: crate::store::record::TombstoneReason::ManualDeletion,
1531            at: 1_000_000,
1532        };
1533        store.put("gotcha:dead", &record).await.unwrap();
1534
1535        let report = check_gotcha_indexes(&store, dir.path()).await.unwrap();
1536        assert!(report.unnormalized_paths.is_empty());
1537
1538        store.close().await.unwrap();
1539    }
1540
1541    // ── Orphaned file records ────────────────────────────────────────────
1542
1543    /// A repo root holding one real file under `src/`, so the classifier has
1544    /// a top-level directory that exists and one that does not.
1545    fn seed_repo(dir: &Path) {
1546        std::fs::create_dir_all(dir.join("src")).unwrap();
1547        std::fs::write(dir.join("src/a.rs"), "fn main() {}").unwrap();
1548    }
1549
1550    #[tokio::test]
1551    async fn orphan_scan_separates_outside_repo_from_deleted_in_repo() {
1552        let dir = tempfile::TempDir::new().unwrap();
1553        seed_repo(dir.path());
1554        let store = Store::open(dir.path()).await.unwrap();
1555
1556        for path in [
1557            "src/a.rs",
1558            "src/gone.rs",
1559            "hclsyntax/parser.go",
1560            "/tmp/scratchpad/notes.md",
1561        ] {
1562            store
1563                .put(&format!("file:{path}"), &make_file(path, &[]))
1564                .await
1565                .unwrap();
1566        }
1567
1568        let scan = find_orphaned_files(&store, dir.path()).await.unwrap();
1569        assert_eq!(scan.active_files, 4);
1570
1571        let kind = |key: &str| scan.orphans.iter().find(|o| o.key == key).map(|o| o.kind);
1572        assert_eq!(kind("file:src/a.rs"), None, "the file is right there");
1573        assert_eq!(kind("file:src/gone.rs"), Some(OrphanKind::DeletedInRepo));
1574        assert_eq!(
1575            kind("file:hclsyntax/parser.go"),
1576            Some(OrphanKind::OutsideRepo),
1577            "a top-level directory this repo does not have"
1578        );
1579        assert_eq!(
1580            kind("file:/tmp/scratchpad/notes.md"),
1581            Some(OrphanKind::OutsideRepo)
1582        );
1583
1584        // The scan is read-only.
1585        let gone = store.get("file:src/gone.rs").await.unwrap().unwrap();
1586        assert!(matches!(gone.lifecycle, RecordLifecycle::Active));
1587
1588        store.close().await.unwrap();
1589    }
1590
1591    #[tokio::test]
1592    async fn purge_tombstones_orphans_and_unlinks_their_gotchas() {
1593        let dir = tempfile::TempDir::new().unwrap();
1594        seed_repo(dir.path());
1595        let store = Store::open(dir.path()).await.unwrap();
1596
1597        store
1598            .put(
1599                "gotcha:g1",
1600                &make_gotcha("gotcha:g1", &["src/a.rs", "src/gone.rs"]),
1601            )
1602            .await
1603            .unwrap();
1604        store
1605            .put("file:src/a.rs", &make_file("src/a.rs", &["gotcha:g1"]))
1606            .await
1607            .unwrap();
1608        store
1609            .put(
1610                "file:src/gone.rs",
1611                &make_file("src/gone.rs", &["gotcha:g1"]),
1612            )
1613            .await
1614            .unwrap();
1615        for path in ["file:src/a.rs", "file:src/gone.rs"] {
1616            let edge = Edge::new(path, EdgeKind::HasGotcha, "gotcha:g1");
1617            store
1618                .put_raw(&edge.to_key(), &now_secs().to_le_bytes())
1619                .await
1620                .unwrap();
1621        }
1622
1623        let scan = find_orphaned_files(&store, dir.path()).await.unwrap();
1624        let outcome = purge_orphaned_files(&store, &scan).await.unwrap();
1625        assert_eq!(
1626            outcome,
1627            PurgeOutcome::Purged {
1628                tombstoned: 1,
1629                unlinked: 1
1630            }
1631        );
1632
1633        let gone = store.get("file:src/gone.rs").await.unwrap().unwrap();
1634        assert!(matches!(gone.lifecycle, RecordLifecycle::Tombstoned { .. }));
1635        assert!(extract_gotcha_keys(&gone).is_empty());
1636
1637        let edges = store.scan_keys("graph:edge:").await.unwrap();
1638        let dead_edge = Edge::new("file:src/gone.rs", EdgeKind::HasGotcha, "gotcha:g1").to_key();
1639        assert!(!edges.contains(&dead_edge));
1640
1641        // The gotcha still names the purged path, and the surviving file keeps
1642        // its link. The purge must not read as drift on the next check.
1643        let live = store.get("file:src/a.rs").await.unwrap().unwrap();
1644        assert_eq!(extract_gotcha_keys(&live), vec!["gotcha:g1".to_string()]);
1645        let report = check_gotcha_indexes(&store, dir.path()).await.unwrap();
1646        assert!(
1647            !report.has_drift(),
1648            "purged orphans must not read as missing links: missing_file={:?}, missing_edges={:?}",
1649            report.missing_file_links.len(),
1650            report.missing_edges.len(),
1651        );
1652
1653        store.close().await.unwrap();
1654    }
1655
1656    #[tokio::test]
1657    async fn purge_refuses_when_orphans_are_most_of_the_store() {
1658        let dir = tempfile::TempDir::new().unwrap();
1659        seed_repo(dir.path());
1660        let store = Store::open(dir.path()).await.unwrap();
1661
1662        // 1 real file, 19 orphans — 95%, above ORPHAN_PURGE_MAX_SHARE.
1663        store
1664            .put("file:src/a.rs", &make_file("src/a.rs", &[]))
1665            .await
1666            .unwrap();
1667        for i in 0..19 {
1668            let path = format!("src/gone{i}.rs");
1669            store
1670                .put(&format!("file:{path}"), &make_file(&path, &[]))
1671                .await
1672                .unwrap();
1673        }
1674
1675        let scan = find_orphaned_files(&store, dir.path()).await.unwrap();
1676        assert_eq!(
1677            purge_orphaned_files(&store, &scan).await.unwrap(),
1678            PurgeOutcome::Refused {
1679                orphans: 19,
1680                active_files: 20
1681            }
1682        );
1683
1684        let untouched = store.get("file:src/gone0.rs").await.unwrap().unwrap();
1685        assert!(matches!(untouched.lifecycle, RecordLifecycle::Active));
1686
1687        store.close().await.unwrap();
1688    }
1689
1690    #[tokio::test]
1691    async fn dirty_marker_lifecycle() {
1692        let dir = tempfile::TempDir::new().unwrap();
1693        let store = Store::open(dir.path()).await.unwrap();
1694
1695        assert!(!is_dirty(&store).await);
1696
1697        mark_dirty(&store, "gotcha:test", "link sync failed").await;
1698        assert!(is_dirty(&store).await);
1699
1700        let marker = read_dirty_marker(&store).await.unwrap();
1701        assert!(marker.dirty);
1702        assert_eq!(marker.affected_keys, vec!["gotcha:test"]);
1703
1704        clear_dirty_marker(&store, now_secs()).await;
1705        assert!(!is_dirty(&store).await);
1706
1707        store.close().await.unwrap();
1708    }
1709
1710    /// Simulates a partial-write failure and verifies the full recovery contract:
1711    /// 1. Canonical gotcha record persists
1712    /// 2. File links are missing (secondary write "failed")
1713    /// 3. Dirty marker is set
1714    /// 4. Repair restores derived state from canonical truth
1715    /// 5. Dirty marker is cleared after verified repair
1716    #[tokio::test]
1717    async fn partial_failure_recovery_contract() {
1718        let dir = tempfile::TempDir::new().unwrap();
1719        let store = Store::open(dir.path()).await.unwrap();
1720
1721        // Seed file records
1722        store
1723            .put("file:src/a.rs", &make_file("src/a.rs", &[]))
1724            .await
1725            .unwrap();
1726        store
1727            .put("file:src/b.rs", &make_file("src/b.rs", &[]))
1728            .await
1729            .unwrap();
1730
1731        // Simulate step 2 succeeding: write the canonical gotcha record directly
1732        let gotcha = make_gotcha("gotcha:partial", &["src/a.rs", "src/b.rs"]);
1733        store.put("gotcha:partial", &gotcha).await.unwrap();
1734
1735        // Simulate step 3 failing: do NOT write file links or edges
1736        // (this is what happens when sync_gotcha_file_links errors out)
1737
1738        // Simulate the failure handler: set dirty marker
1739        mark_dirty(&store, "gotcha:partial", "link sync failed").await;
1740
1741        // ── Verify partial-failure state ──────────────────────────────────
1742
1743        // Canonical record exists
1744        let canonical = store.get("gotcha:partial").await.unwrap();
1745        assert!(canonical.is_some(), "canonical gotcha record must persist");
1746
1747        // File links are missing
1748        let a = store.get("file:src/a.rs").await.unwrap().unwrap();
1749        let b = store.get("file:src/b.rs").await.unwrap().unwrap();
1750        assert!(
1751            extract_gotcha_keys(&a).is_empty(),
1752            "file link should be missing (secondary write failed)"
1753        );
1754        assert!(
1755            extract_gotcha_keys(&b).is_empty(),
1756            "file link should be missing (secondary write failed)"
1757        );
1758
1759        // Dirty marker is set
1760        assert!(is_dirty(&store).await, "dirty marker must be set");
1761        let marker = read_dirty_marker(&store).await.unwrap();
1762        assert!(marker.affected_keys.contains(&"gotcha:partial".to_string()));
1763
1764        // Check detects the drift
1765        let pre = check_gotcha_indexes(&store, dir.path()).await.unwrap();
1766        assert!(pre.has_drift());
1767        assert_eq!(pre.missing_file_links.len(), 2);
1768        assert_eq!(pre.missing_edges.len(), 2);
1769
1770        // ── Repair restores consistency ───────────────────────────────────
1771
1772        let report = repair_gotcha_indexes(&store, dir.path(), RepairMode::Full)
1773            .await
1774            .unwrap();
1775        assert!(report.repaired_count > 0, "repair should fix something");
1776        assert!(
1777            report.verification_passed,
1778            "post-repair verification must pass"
1779        );
1780        assert!(
1781            report.dirty_marker_cleared,
1782            "dirty marker must be cleared after verified repair"
1783        );
1784
1785        // File links now correct
1786        let a2 = store.get("file:src/a.rs").await.unwrap().unwrap();
1787        let b2 = store.get("file:src/b.rs").await.unwrap().unwrap();
1788        assert!(extract_gotcha_keys(&a2).contains(&"gotcha:partial".to_string()));
1789        assert!(extract_gotcha_keys(&b2).contains(&"gotcha:partial".to_string()));
1790
1791        // Edges now exist
1792        let edges = store.scan_keys("graph:edge:").await.unwrap();
1793        let edge_a = Edge::new("file:src/a.rs", EdgeKind::HasGotcha, "gotcha:partial").to_key();
1794        let edge_b = Edge::new("file:src/b.rs", EdgeKind::HasGotcha, "gotcha:partial").to_key();
1795        assert!(edges.contains(&edge_a));
1796        assert!(edges.contains(&edge_b));
1797
1798        // Dirty marker cleared
1799        assert!(!is_dirty(&store).await);
1800
1801        // Re-check confirms no drift remains
1802        let post = check_gotcha_indexes(&store, dir.path()).await.unwrap();
1803        assert!(!post.has_drift());
1804
1805        store.close().await.unwrap();
1806    }
1807
1808    /// Verifies that repair_fast removes stale file links when a gotcha's
1809    /// affected_files changed (e.g. from [A,B] to [B,C]). Previously,
1810    /// repair_fast only cleaned stale links for tombstoned/missing gotchas,
1811    /// leaving file A with a stale reference after a move.
1812    #[tokio::test]
1813    async fn fast_repair_removes_stale_links_on_move() {
1814        let dir = tempfile::TempDir::new().unwrap();
1815        let store = Store::open(dir.path()).await.unwrap();
1816
1817        // Seed file records for A, B, and C
1818        store
1819            .put("file:src/a.rs", &make_file("src/a.rs", &["gotcha:moved"]))
1820            .await
1821            .unwrap();
1822        store
1823            .put("file:src/b.rs", &make_file("src/b.rs", &["gotcha:moved"]))
1824            .await
1825            .unwrap();
1826        store
1827            .put("file:src/c.rs", &make_file("src/c.rs", &[]))
1828            .await
1829            .unwrap();
1830
1831        // Gotcha now targets [B, C] — A is stale
1832        store
1833            .put(
1834                "gotcha:moved",
1835                &make_gotcha("gotcha:moved", &["src/b.rs", "src/c.rs"]),
1836            )
1837            .await
1838            .unwrap();
1839
1840        // Also add a stale edge for A
1841        let stale_edge = Edge::new("file:src/a.rs", EdgeKind::HasGotcha, "gotcha:moved");
1842        store
1843            .put_raw(&stale_edge.to_key(), &now_secs().to_le_bytes())
1844            .await
1845            .unwrap();
1846
1847        // Mark dirty so repair_fast picks it up
1848        mark_dirty(&store, "gotcha:moved", "affected_files changed").await;
1849
1850        // Run fast repair
1851        let report = repair_fast(&store, now_secs()).await.unwrap();
1852        assert!(
1853            report.repaired_count > 0,
1854            "fast repair should fix something"
1855        );
1856        assert!(report.dirty_marker_cleared);
1857
1858        // A should no longer reference the gotcha
1859        let a = store.get("file:src/a.rs").await.unwrap().unwrap();
1860        assert!(
1861            !extract_gotcha_keys(&a).contains(&"gotcha:moved".to_string()),
1862            "stale link on file A should be removed"
1863        );
1864
1865        // B should still reference the gotcha
1866        let b = store.get("file:src/b.rs").await.unwrap().unwrap();
1867        assert!(extract_gotcha_keys(&b).contains(&"gotcha:moved".to_string()));
1868
1869        // C should now reference the gotcha
1870        let c = store.get("file:src/c.rs").await.unwrap().unwrap();
1871        assert!(extract_gotcha_keys(&c).contains(&"gotcha:moved".to_string()));
1872
1873        // Full check should confirm consistency
1874        let check = check_gotcha_indexes(&store, dir.path()).await.unwrap();
1875        assert!(
1876            !check.has_drift(),
1877            "no drift should remain after fast repair: missing_file_links={}, stale_file_links={}, missing_edges={}, stale_edges={}",
1878            check.missing_file_links.len(),
1879            check.stale_file_links.len(),
1880            check.missing_edges.len(),
1881            check.stale_edges.len(),
1882        );
1883
1884        store.close().await.unwrap();
1885    }
1886
1887    /// Fault-injection test for the `mati serve` boot-time auto-drain.
1888    ///
1889    /// Simulates an unclean shutdown that left real drift AND a dirty marker.
1890    /// On reopen, the same `is_dirty + repair_gotcha_indexes(Fast)` sequence
1891    /// that `mcp::server::serve()` runs must clear both. Locks down the
1892    /// contract for the boot-time recovery added alongside the panic hook
1893    /// and explicit shutdown flush.
1894    #[tokio::test]
1895    async fn auto_drain_on_reopen_clears_dirty_marker_and_drift() {
1896        let dir = tempfile::TempDir::new().unwrap();
1897
1898        // Session 1: introduce drift (gotcha now targets [B,C], but file A still
1899        // references it from before, file C has not yet been linked, plus a
1900        // stale edge to A). Mark dirty as if a partial-write recorded the
1901        // failure. Close to simulate the daemon process exiting.
1902        {
1903            let store = Store::open(dir.path()).await.unwrap();
1904            store
1905                .put("file:src/a.rs", &make_file("src/a.rs", &["gotcha:moved"]))
1906                .await
1907                .unwrap();
1908            store
1909                .put("file:src/b.rs", &make_file("src/b.rs", &["gotcha:moved"]))
1910                .await
1911                .unwrap();
1912            store
1913                .put("file:src/c.rs", &make_file("src/c.rs", &[]))
1914                .await
1915                .unwrap();
1916            store
1917                .put(
1918                    "gotcha:moved",
1919                    &make_gotcha("gotcha:moved", &["src/b.rs", "src/c.rs"]),
1920                )
1921                .await
1922                .unwrap();
1923            let stale_edge = Edge::new("file:src/a.rs", EdgeKind::HasGotcha, "gotcha:moved");
1924            store
1925                .put_raw(&stale_edge.to_key(), &now_secs().to_le_bytes())
1926                .await
1927                .unwrap();
1928            mark_dirty(&store, "gotcha:moved", "simulated partial-write").await;
1929
1930            // Sanity: pre-shutdown state really is broken.
1931            let pre = check_gotcha_indexes(&store, dir.path()).await.unwrap();
1932            assert!(pre.has_drift(), "drift must exist before shutdown");
1933            assert!(is_dirty(&store).await, "marker must be set before shutdown");
1934
1935            store.close().await.unwrap();
1936        }
1937
1938        // Session 2: reopen and run the exact sequence `serve()` runs at
1939        // startup. The dirty marker must survive the reopen (it's persisted
1940        // in the knowledge tree), and the Fast drain must clear both the
1941        // marker and the drift.
1942        {
1943            let store = Store::open(dir.path()).await.unwrap();
1944            assert!(
1945                is_dirty(&store).await,
1946                "dirty marker should survive reopen across sessions"
1947            );
1948
1949            let report = repair_gotcha_indexes(&store, dir.path(), RepairMode::Fast)
1950                .await
1951                .unwrap();
1952            assert!(report.repaired_count > 0, "Fast drain must apply repairs");
1953            assert!(
1954                report.dirty_marker_cleared,
1955                "Fast drain must clear the dirty marker on success"
1956            );
1957
1958            assert!(
1959                !is_dirty(&store).await,
1960                "auto-drain should leave no dirty marker behind"
1961            );
1962
1963            let post = check_gotcha_indexes(&store, dir.path()).await.unwrap();
1964            assert!(
1965                !post.has_drift(),
1966                "no drift after auto-drain: missing_file={}, stale_file={}, missing_edge={}, stale_edge={}",
1967                post.missing_file_links.len(),
1968                post.stale_file_links.len(),
1969                post.missing_edges.len(),
1970                post.stale_edges.len(),
1971            );
1972
1973            store.close().await.unwrap();
1974        }
1975    }
1976}