Skip to main content

scc_store/
lib.rs

1//! SQLite persistence for the System Context Compiler.
2//!
3#![allow(clippy::type_complexity)]
4
5//! Layer mapping (docs/DATA_STRATEGY.md):
6//! - L0 repository snapshot: `repositories`, `snapshots`, `files`
7//! - L1 evidence: `evidence`
8//! - L2 reality graph: `entities`, `relationships`, `symbols`
9//! - L3 system IR: `components`, `flows`, `invariants`, `tests`
10//! - L4 context indexes: FTS5 (`symbols_fts`, `entities_fts`), `context_cache`
11//! - L5 history: `intent_claims`, `drift_findings`
12
13pub use rusqlite;
14
15pub mod history;
16pub mod snapshot;
17// trace:exempt reason=module-facade  # pub mod declaration only; behavior traced per item in system.rs
18pub mod system;
19
20use rusqlite::{params, Connection, OptionalExtension};
21use scc_core::{
22    Entity, Evidence, Flow, Invariant, Provenance, Relationship, Repository, Severity, Snapshot,
23};
24use std::collections::{HashMap, HashSet};
25use std::io::Read;
26use std::path::{Path, PathBuf};
27use thiserror::Error;
28
29// trace:exempt reason=internal-detail
30pub const SCHEMA_VERSION: u32 = 10;
31// trace:exempt reason=internal-detail
32pub const FTS_ESCAPE: &str = "\"";
33// trace:exempt reason=internal-detail
34const MIGRATIONS: &[&str] = &[
35    MIGRATION_1,
36    MIGRATION_2,
37    MIGRATION_3,
38    MIGRATION_4,
39    MIGRATION_5,
40    MIGRATION_6,
41    MIGRATION_7,
42    MIGRATION_8,
43    MIGRATION_9,
44    MIGRATION_10,
45];
46
47/// v4: model epoch. `context_cache.revision` becomes `epoch` — the cache is
48/// keyed on the composite model state (source/semantic/evidence/intent/
49/// runtime/derived generations), not the git revision alone, so any change
50/// to system truth invalidates stale packs (docs/SYSTEM_DESIGN.md §5).
51// trace:exempt reason=internal-detail
52const MIGRATION_4: &str = r#"
53ALTER TABLE context_cache RENAME COLUMN revision TO epoch;
54"#;
55
56/// v5: canonical causal flow graphs (Wave 3) — the behavioral truth from
57/// which the `flows` projections are derived.
58// trace:exempt reason=internal-detail
59const MIGRATION_5: &str = r#"
60CREATE TABLE IF NOT EXISTS flow_graphs (
61  id TEXT PRIMARY KEY,
62  kind TEXT NOT NULL,
63  name TEXT NOT NULL,
64  trigger TEXT,
65  graph TEXT NOT NULL
66);
67"#;
68
69/// v7: versioned reality graph (§XIX) — durable revision log plus full
70/// member row sets per revision for introduction/removal history and
71/// transactional historical views. Complements the model epoch (active
72/// compiled view), it does not replace it.
73// trace:exempt reason=internal-detail
74const MIGRATION_7: &str = r#"
75CREATE TABLE IF NOT EXISTS graph_revisions (
76  rev INTEGER PRIMARY KEY,
77  base_rev INTEGER NOT NULL DEFAULT 0,
78  created_at TEXT NOT NULL,
79  source_hash TEXT NOT NULL DEFAULT '',
80  extractor_version TEXT NOT NULL DEFAULT '',
81  entity_count INTEGER NOT NULL DEFAULT 0,
82  rel_count INTEGER NOT NULL DEFAULT 0,
83  file_count INTEGER NOT NULL DEFAULT 0
84);
85CREATE TABLE IF NOT EXISTS revision_members (
86  rev INTEGER NOT NULL,
87  kind TEXT NOT NULL,
88  id TEXT NOT NULL,
89  row_json TEXT NOT NULL DEFAULT '{}',
90  PRIMARY KEY (rev, kind, id)
91);
92CREATE INDEX IF NOT EXISTS idx_revision_members_rev ON revision_members(rev);
93CREATE TABLE IF NOT EXISTS context_snapshots (
94  id TEXT PRIMARY KEY,
95  created_at TEXT NOT NULL,
96  task TEXT NOT NULL DEFAULT '',
97  epoch TEXT NOT NULL DEFAULT '',
98  revision INTEGER NOT NULL DEFAULT 0,
99  artifact TEXT NOT NULL DEFAULT '',
100  entity_ids TEXT NOT NULL DEFAULT '[]',
101  budget INTEGER NOT NULL DEFAULT 0,
102  warnings TEXT NOT NULL DEFAULT '[]'
103);
104"#;
105
106/// v8: versioned-graph V2 + snapshot V2 (§XIX/§XXII hardening). Revisions
107/// gain the semantic-config and graph-content hashes so extractor, config,
108/// confidence, or provenance changes produce a revision even when source
109/// files did not move. Snapshots gain entity/relationship/contract/state
110/// fingerprints plus the artifact hash so diffs detect modification, not
111/// just presence.
112/// Version-gated (runs once per database), so plain ADD COLUMN is safe.
113// trace:exempt reason=internal-detail
114const MIGRATION_8: &str = r#"
115ALTER TABLE graph_revisions ADD COLUMN semantic_config_hash TEXT NOT NULL DEFAULT '';
116ALTER TABLE graph_revisions ADD COLUMN graph_content_hash TEXT NOT NULL DEFAULT '';
117ALTER TABLE context_snapshots ADD COLUMN entity_fp TEXT NOT NULL DEFAULT '{}';
118ALTER TABLE context_snapshots ADD COLUMN rel_fp TEXT NOT NULL DEFAULT '{}';
119ALTER TABLE context_snapshots ADD COLUMN contract_fp TEXT NOT NULL DEFAULT '{}';
120ALTER TABLE context_snapshots ADD COLUMN state_fp TEXT NOT NULL DEFAULT '{}';
121ALTER TABLE context_snapshots ADD COLUMN flow_names TEXT NOT NULL DEFAULT '[]';
122ALTER TABLE context_snapshots ADD COLUMN artifact_hash TEXT NOT NULL DEFAULT '';
123"#;
124
125/// v9: namespaced plugin state (§23) — key/value pairs scoped to
126/// `(plugin_id, key)`. Plugins never create their own tables; the host
127/// owns storage, the plugin id owns the namespace.
128// trace:exempt reason=internal-detail
129const MIGRATION_9: &str = r#"
130CREATE TABLE IF NOT EXISTS plugin_state (
131  plugin_id TEXT NOT NULL,
132  key TEXT NOT NULL,
133  value TEXT NOT NULL DEFAULT '{}',
134  updated_at TEXT NOT NULL DEFAULT '',
135  PRIMARY KEY (plugin_id, key)
136);
137CREATE INDEX IF NOT EXISTS idx_plugin_state_plugin ON plugin_state(plugin_id);
138"#;
139
140/// v10: namespaced sidecar facts (§124 item 35) — raw analyzer facts
141/// (CPG nodes, findings, slices) scoped to `(plugin_id, graph, key)`.
142/// Queryable + versioned + explicitly namespaced; NOT authoritative by
143/// default. Promotion into the canonical graph goes through
144/// `plugins.promote` (normal contribution path), never by reading here.
145// trace:exempt reason=internal-detail
146const MIGRATION_10: &str = r#"
147CREATE TABLE IF NOT EXISTS sidecar_facts (
148  plugin_id TEXT NOT NULL,
149  graph TEXT NOT NULL DEFAULT 'default',
150  key TEXT NOT NULL,
151  value TEXT NOT NULL DEFAULT '{}',
152  updated_at TEXT NOT NULL DEFAULT '',
153  PRIMARY KEY (plugin_id, graph, key)
154);
155CREATE INDEX IF NOT EXISTS idx_sidecar_plugin_graph ON sidecar_facts(plugin_id, graph);
156"#;
157
158/// v6: observed trace-path signatures (Wave 6) — canonical root-to-leaf
159/// service paths per trace, aggregated across ingests. `count` is additive
160/// per trace occurrence, `latency_ms` a count-weighted running average,
161/// `errors` additive.
162// trace:exempt reason=internal-detail
163const MIGRATION_6: &str = r#"
164CREATE TABLE IF NOT EXISTS trace_signatures (
165  signature TEXT PRIMARY KEY,
166  count INTEGER NOT NULL DEFAULT 1,
167  latency_ms REAL NOT NULL DEFAULT 0,
168  errors INTEGER NOT NULL DEFAULT 0,
169  last_observed TEXT NOT NULL
170);
171"#;
172
173/// v3: entity embeddings (f32 vector blobs) for the optional semantic ranker.
174// trace:exempt reason=internal-detail
175const MIGRATION_3: &str = r#"
176CREATE TABLE IF NOT EXISTS embeddings (
177  entity_id TEXT PRIMARY KEY,
178  vector BLOB NOT NULL,
179  model TEXT NOT NULL,
180  updated_at TEXT NOT NULL
181);
182"#;
183
184/// v2: runtime edge aggregation columns (latency/error aggregates).
185// trace:exempt reason=internal-detail
186const MIGRATION_2: &str = r#"
187ALTER TABLE runtime_edges ADD COLUMN latency_ms REAL NOT NULL DEFAULT 0;
188ALTER TABLE runtime_edges ADD COLUMN errors INTEGER NOT NULL DEFAULT 0;
189"#;
190
191// trace:exempt reason=internal-detail
192const MIGRATION_1: &str = r#"
193CREATE TABLE IF NOT EXISTS meta (
194  key TEXT PRIMARY KEY,
195  value TEXT NOT NULL
196);
197
198CREATE TABLE IF NOT EXISTS repositories (
199  id TEXT PRIMARY KEY,
200  name TEXT NOT NULL,
201  url TEXT,
202  root TEXT NOT NULL,
203  indexed_at TEXT
204);
205
206CREATE TABLE IF NOT EXISTS snapshots (
207  id INTEGER PRIMARY KEY AUTOINCREMENT,
208  revision TEXT NOT NULL,
209  branch TEXT,
210  indexed_at TEXT NOT NULL,
211  status TEXT NOT NULL DEFAULT 'active',
212  file_count INTEGER NOT NULL DEFAULT 0
213);
214
215CREATE TABLE IF NOT EXISTS files (
216  path TEXT PRIMARY KEY,
217  hash TEXT NOT NULL,
218  language TEXT NOT NULL DEFAULT 'unknown',
219  kind TEXT NOT NULL DEFAULT 'other',
220  size INTEGER NOT NULL DEFAULT 0,
221  indexed_at TEXT
222);
223
224CREATE TABLE IF NOT EXISTS symbols (
225  id INTEGER PRIMARY KEY AUTOINCREMENT,
226  file TEXT NOT NULL,
227  name TEXT NOT NULL,
228  symbol_kind TEXT NOT NULL,
229  signature TEXT,
230  start_line INTEGER NOT NULL,
231  end_line INTEGER NOT NULL,
232  exported INTEGER NOT NULL DEFAULT 0,
233  docstring TEXT
234);
235CREATE INDEX IF NOT EXISTS idx_symbols_file ON symbols(file);
236CREATE INDEX IF NOT EXISTS idx_symbols_name ON symbols(name);
237
238CREATE TABLE IF NOT EXISTS imports (
239  id INTEGER PRIMARY KEY AUTOINCREMENT,
240  file TEXT NOT NULL,
241  module TEXT NOT NULL,
242  names TEXT NOT NULL DEFAULT '[]',
243  line INTEGER NOT NULL DEFAULT 0,
244  type TEXT NOT NULL DEFAULT 'member'
245);
246CREATE INDEX IF NOT EXISTS idx_imports_file ON imports(file);
247
248CREATE TABLE IF NOT EXISTS entities (
249  id TEXT PRIMARY KEY,
250  kind TEXT NOT NULL,
251  name TEXT NOT NULL,
252  attributes TEXT NOT NULL DEFAULT '{}',
253  evidence TEXT NOT NULL DEFAULT '[]',
254  sources TEXT NOT NULL DEFAULT '[]'
255);
256CREATE INDEX IF NOT EXISTS idx_entities_kind ON entities(kind);
257
258CREATE TABLE IF NOT EXISTS relationships (
259  id TEXT PRIMARY KEY,
260  subject TEXT NOT NULL,
261  predicate TEXT NOT NULL,
262  object TEXT NOT NULL,
263  provenance TEXT NOT NULL,
264  confidence REAL NOT NULL,
265  evidence TEXT NOT NULL DEFAULT '[]',
266  verified_at TEXT NOT NULL DEFAULT '',
267  source_path TEXT NOT NULL DEFAULT ''
268);
269CREATE INDEX IF NOT EXISTS idx_rel_subject ON relationships(subject);
270CREATE INDEX IF NOT EXISTS idx_rel_object ON relationships(object);
271CREATE INDEX IF NOT EXISTS idx_rel_predicate ON relationships(predicate);
272CREATE INDEX IF NOT EXISTS idx_rel_source ON relationships(source_path);
273
274CREATE TABLE IF NOT EXISTS evidence (
275  id TEXT PRIMARY KEY,
276  type TEXT NOT NULL,
277  path TEXT,
278  symbol TEXT,
279  start_line INTEGER,
280  end_line INTEGER,
281  revision TEXT,
282  content_hash TEXT,
283  extractor TEXT,
284  extractor_version TEXT
285);
286CREATE INDEX IF NOT EXISTS idx_evidence_path ON evidence(path);
287
288CREATE TABLE IF NOT EXISTS components (
289  id TEXT PRIMARY KEY,
290  name TEXT NOT NULL,
291  kind TEXT NOT NULL,
292  responsibility TEXT NOT NULL DEFAULT '[]',
293  implementation TEXT NOT NULL DEFAULT '[]',
294  evidence TEXT NOT NULL DEFAULT '[]',
295  attributes TEXT NOT NULL DEFAULT '{}'
296);
297
298CREATE TABLE IF NOT EXISTS flows (
299  id TEXT PRIMARY KEY,
300  kind TEXT NOT NULL,
301  name TEXT NOT NULL,
302  trigger TEXT,
303  steps TEXT NOT NULL DEFAULT '[]',
304  attributes TEXT NOT NULL DEFAULT '{}'
305);
306
307CREATE TABLE IF NOT EXISTS invariants (
308  id TEXT PRIMARY KEY,
309  statement TEXT NOT NULL,
310  severity TEXT NOT NULL,
311  scope TEXT NOT NULL DEFAULT '[]',
312  enforced_by TEXT NOT NULL DEFAULT '[]',
313  provenance TEXT NOT NULL DEFAULT 'DECLARED',
314  evidence TEXT NOT NULL DEFAULT '[]'
315);
316
317CREATE TABLE IF NOT EXISTS tests (
318  id TEXT PRIMARY KEY,
319  name TEXT NOT NULL,
320  file TEXT NOT NULL,
321  kind TEXT NOT NULL DEFAULT 'unit',
322  symbol TEXT
323);
324CREATE INDEX IF NOT EXISTS idx_tests_file ON tests(file);
325
326CREATE TABLE IF NOT EXISTS context_cache (
327  key TEXT PRIMARY KEY,
328  pack TEXT NOT NULL,
329  revision TEXT NOT NULL,
330  created_at TEXT NOT NULL
331);
332
333CREATE TABLE IF NOT EXISTS intent_claims (
334  id INTEGER PRIMARY KEY AUTOINCREMENT,
335  source TEXT NOT NULL,
336  claim TEXT NOT NULL,
337  created_at TEXT NOT NULL
338);
339
340CREATE TABLE IF NOT EXISTS runtime_edges (
341  source TEXT NOT NULL,
342  target TEXT NOT NULL,
343  count INTEGER NOT NULL DEFAULT 1,
344  last_observed TEXT NOT NULL,
345  PRIMARY KEY (source, target)
346);
347
348CREATE TABLE IF NOT EXISTS drift_findings (
349  id INTEGER PRIMARY KEY AUTOINCREMENT,
350  kind TEXT NOT NULL,
351  severity TEXT NOT NULL,
352  message TEXT NOT NULL,
353  created_at TEXT NOT NULL,
354  resolved INTEGER NOT NULL DEFAULT 0
355);
356
357CREATE VIRTUAL TABLE IF NOT EXISTS symbols_fts USING fts5(
358  name, signature, symbol_kind UNINDEXED, file UNINDEXED
359);
360
361CREATE VIRTUAL TABLE IF NOT EXISTS entities_fts USING fts5(
362  id UNINDEXED, kind UNINDEXED, name, attributes
363);
364"#;
365
366#[derive(Debug, Error)]
367// trace:exempt reason=internal-detail
368pub enum StoreError {
369    #[error("sqlite: {0}")]
370    Sqlite(#[from] rusqlite::Error),
371    #[error("json: {0}")]
372    Json(#[from] serde_json::Error),
373    #[error("repository not initialized: {0}")]
374    NotInitialized(String),
375    #[error("index cache is corrupt (refusing to fabricate an empty index): {0}")]
376    Corrupt(String),
377}
378
379// trace:exempt reason=internal-detail
380pub type Result<T> = std::result::Result<T, StoreError>;
381
382#[derive(Debug, Clone, serde::Serialize)]
383// trace:exempt reason=internal-detail
384pub struct RuntimeEdgeRow {
385    pub source: String,
386    pub target: String,
387    pub count: u64,
388    pub latency_ms: f64,
389    pub errors: u64,
390    pub last_observed: String,
391}
392
393// trace:exempt reason=internal-detail
394
395/// The independent truth sources whose generations compose the model epoch.
396/// Any change to one of them invalidates previously cached context packs.
397#[derive(Debug, Clone, Copy, PartialEq, Eq)]
398// trace:exempt reason=internal-detail
399pub enum ModelEpochKind {
400    /// Indexed file contents (snapshot completion).
401    Source,
402    /// Semantic resolver promotions (LSP/SCIP edge upgrades).
403    Semantic,
404    /// Imported external evidence (SCIP/CCG/GitNexus/Beads/CBM/Hindsight).
405    Evidence,
406    /// Declared intent (`.scc/intent.yaml` claims).
407    Intent,
408    /// Runtime trace ingestion.
409    Runtime,
410    /// Derived compilation (components/flows/invariants/drift/boundaries).
411    Derived,
412}
413
414// trace:exempt reason=internal-detail
415impl ModelEpochKind {
416    // trace:exempt reason=internal-detail
417    pub fn meta_key(&self) -> &'static str {
418        match self {
419            ModelEpochKind::Source => "source_generation",
420            ModelEpochKind::Semantic => "semantic_generation",
421            ModelEpochKind::Evidence => "evidence_generation",
422            ModelEpochKind::Intent => "intent_generation",
423            ModelEpochKind::Runtime => "runtime_generation",
424            ModelEpochKind::Derived => "derived_generation",
425        }
426    }
427}
428
429// trace:exempt reason=internal-detail
430
431/// Deterministic composite fingerprint of the model state. `composite()` is
432/// the canonical cache-epoch string: it changes whenever any source of
433/// system truth changes, so a previously fresh context pack can never be
434/// served after its evidence is stale.
435#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
436// trace:exempt reason=internal-detail
437pub struct ModelEpoch {
438    pub source: u64,
439    pub semantic: u64,
440    pub evidence: u64,
441    pub intent: u64,
442    pub runtime: u64,
443    pub derived: u64,
444}
445
446// trace:exempt reason=internal-detail
447impl ModelEpoch {
448    // trace:exempt reason=internal-detail
449    pub fn zero() -> ModelEpoch {
450        ModelEpoch {
451            source: 0,
452            semantic: 0,
453            evidence: 0,
454            intent: 0,
455            runtime: 0,
456            derived: 0,
457        }
458    }
459
460    /// Composite hash over every generation. Prefix identifies the scheme so
461    /// a future epoch-shape change cannot collide with old keys.
462    // trace:exempt reason=internal-detail
463    pub fn composite(&self, revision: &str) -> String {
464        let mut h = blake3::Hasher::new();
465        h.update(b"scc-model-epoch-v1");
466        h.update(self.source.to_le_bytes().as_slice());
467        h.update(self.semantic.to_le_bytes().as_slice());
468        h.update(self.evidence.to_le_bytes().as_slice());
469        h.update(self.intent.to_le_bytes().as_slice());
470        h.update(self.runtime.to_le_bytes().as_slice());
471        h.update(self.derived.to_le_bytes().as_slice());
472        h.update(revision.as_bytes());
473        format!("epoch:{}", &h.finalize().to_hex()[..24])
474    }
475}
476// trace:v1 id=impl.scc.store work=WORK-SCC-001 satisfies=REQ-SCC-DATA implements=PLAN-SCC-001
477
478#[derive(Debug)]
479// trace:exempt reason=internal-detail
480pub struct Store {
481    pub conn: Connection,
482    pub root: PathBuf,
483    /// Repository id (repo:// id component).
484    pub repo_id: String,
485    pub repo_name: String,
486    batch_depth: std::sync::atomic::AtomicUsize,
487}
488
489const SQLITE_MAGIC: &[u8] = b"SQLite format 3\0";
490
491/// Refuse a non-empty existing file that is not a SQLite database. Empty
492/// files are a fresh index. Truncation after the header is caught by
493/// `probe_existing_schema` after open (not a full `PRAGMA quick_check`).
494// trace:v1 id=impl.scc.store.refuse-corrupt work=WORK-phase-12-of-scc-x-ripwire-lessons-java-unprefixed-field-as-receiver-typ satisfies=REQ-implement-phase-12-of-scc-x-ripwire-lessons-java-unprefixed-field-as,REQ-implement-fix-pr-review-comments-without-collapsing-scc-type-script-no implements=PLAN-phase-12-of-scc-x-ripwire-lessons-java-unprefixed-field-as-receiver-typ
495fn refuse_corrupt_existing_db(path: &Path) -> Result<()> {
496    let Ok(meta) = std::fs::metadata(path) else {
497        return Ok(());
498    };
499    if !meta.is_file() || meta.len() == 0 {
500        return Ok(());
501    }
502    if meta.len() < 100 {
503        return Err(StoreError::Corrupt("truncated sqlite header".into()));
504    }
505    let mut f = std::fs::File::open(path).map_err(|e| StoreError::Corrupt(e.to_string()))?;
506    let mut magic = [0u8; 16];
507    let n = f.read(&mut magic).map_err(|e| StoreError::Corrupt(e.to_string()))?;
508    if n < SQLITE_MAGIC.len() || magic.as_slice() != SQLITE_MAGIC {
509        return Err(StoreError::Corrupt(
510            "file is not a SQLite database".into(),
511        ));
512    }
513    Ok(())
514}
515
516/// Cheap existing-DB probe: the SCC `entities` table must already exist.
517/// Missing schema on a nonempty file is corrupt — do not migrate it into
518/// an empty index. Not a full integrity walk.
519// trace:exempt reason=internal-detail
520fn probe_existing_schema(conn: &Connection) -> Result<()> {
521    match conn.query_row(
522        "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'entities' LIMIT 1",
523        [],
524        |_| Ok(()),
525    ) {
526        Ok(()) => Ok(()),
527        Err(rusqlite::Error::QueryReturnedNoRows) => Err(StoreError::Corrupt(
528            "existing database is missing SCC schema".into(),
529        )),
530        Err(e) => Err(StoreError::Corrupt(e.to_string())),
531    }
532}
533
534// trace:exempt reason=internal-detail
535struct NestGuard<'c> {
536    conn: &'c Connection,
537    committed: bool,
538}
539
540// trace:exempt reason=internal-detail
541impl NestGuard<'_> {
542    // trace:exempt reason=internal-detail
543    fn commit(mut self) -> Result<()> {
544        self.committed = true;
545        self.conn.execute_batch("RELEASE scc_nest")?;
546        Ok(())
547    }
548}
549
550// trace:exempt reason=internal-detail
551impl Drop for NestGuard<'_> {
552    // trace:exempt reason=internal-detail
553    fn drop(&mut self) {
554        if !self.committed {
555            let _ = self
556                .conn
557                .execute_batch("ROLLBACK TO scc_nest; RELEASE scc_nest;");
558        }
559    }
560}
561
562// trace:exempt reason=internal-detail
563impl Store {
564    /// Open (creating if needed) the SCC database at `path` for repository
565    /// rooted at `root`. `root` must exist. A truncated or garbage existing
566    /// file is refused rather than migrated into a fake empty index.
567    // trace:exempt reason=internal-detail
568    /// Open, quarantining a corrupt database aside and starting fresh.
569    /// ONLY for the index write path: a malformed file is renamed to
570    /// `<name>.corrupt-<epoch>` (evidence preserved, WAL sidecars moved
571    /// with it) and indexing rebuilds from the working tree. Readers keep
572    /// using [`Store::open`] and fail loudly — a freshly rebuilt index is
573    /// temporarily incomplete and must never be silently presented as
574    /// truth on a read path. Returns the quarantine path when recovery ran.
575    // trace:v1 id=impl.scc.store.open-recovering work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
576    pub fn open_recovering(path: &Path, root: &Path) -> Result<(Store, Option<PathBuf>)> {
577        match Self::open(path, root) {
578            Ok(store) => Ok((store, None)),
579            Err(e) if Self::is_corruption(&e) => {
580                let quarantine = Self::quarantine_db(path)?;
581                let store = Self::open(path, root)?;
582                Ok((store, Some(quarantine)))
583            }
584            Err(e) => Err(e),
585        }
586    }
587
588    /// Move a corrupt database (plus WAL sidecars) aside as
589    /// `<name>.db.corrupt-<epoch>`. Evidence preserved; the path is free
590    /// for a fresh rebuild.
591    // trace:exempt reason=internal-detail
592    pub fn quarantine_db(path: &Path) -> Result<PathBuf> {
593        let stamp = std::time::SystemTime::now()
594            .duration_since(std::time::UNIX_EPOCH)
595            .map(|d| d.as_secs())
596            .unwrap_or(0);
597        let quarantine = path.with_extension(format!("db.corrupt-{stamp}"));
598        let _ = std::fs::rename(path, &quarantine);
599        for suffix in ["-wal", "-shm", "-journal"] {
600            let sidecar = PathBuf::from(format!("{}{suffix}", path.display()));
601            if sidecar.is_file() {
602                let dest = PathBuf::from(format!("{}{suffix}", quarantine.display()));
603                let _ = std::fs::rename(&sidecar, &dest);
604            }
605        }
606        Ok(quarantine)
607    }
608
609    /// True for corruption (as opposed to e.g. missing files or permission
610    /// errors): the explicit Corrupt refusal plus SQLite's own malformed /
611    /// not-a-database errors, which surface lazily on first query despite a
612    /// valid-looking header.
613    // trace:exempt reason=internal-detail
614    pub fn is_corruption(e: &StoreError) -> bool {
615        match e {
616            StoreError::Corrupt(_) => true,
617            StoreError::Sqlite(inner) => {
618                let m = inner.to_string();
619                m.contains("malformed")
620                    || m.contains("not a database")
621                    || m.contains("database disk image")
622            }
623            _ => false,
624        }
625    }
626
627    /// Begin a write batch: the outermost begin issues BEGIN IMMEDIATE,
628    /// nested begins are depth-counted no-ops. Pair with [`Store::batch_end`]
629    /// (commit at depth zero) or [`Store::batch_abort`] (full rollback).
630    /// Lets one commit cover a whole file's facts instead of one fsync per
631    /// row — the dominant index-time cost on fsync-bound disks.
632    // trace:exempt reason=internal-detail
633    pub fn batch_begin(&self) -> Result<()> {
634        use std::sync::atomic::Ordering;
635        if self.batch_depth.fetch_add(1, Ordering::SeqCst) == 0 {
636            self.conn.execute_batch("BEGIN IMMEDIATE")?;
637        }
638        Ok(())
639    }
640
641    // trace:exempt reason=internal-detail
642    pub fn batch_end(&self) -> Result<()> {
643        use std::sync::atomic::Ordering;
644        if self.batch_depth.fetch_sub(1, Ordering::SeqCst) == 1 {
645            self.conn.execute_batch("COMMIT")?;
646        }
647        Ok(())
648    }
649
650    // trace:exempt reason=internal-detail
651    pub fn batch_abort(&self) {
652        use std::sync::atomic::Ordering;
653        if self.batch_depth.swap(0, Ordering::SeqCst) > 0 {
654            let _ = self.conn.execute_batch("ROLLBACK");
655        }
656    }
657
658    /// Run `f` inside a batch: commit on success, full rollback on error.
659    /// Per-file callers use this so a failed file leaves no partial facts.
660    // trace:v1 id=impl.scc.store.batch-writes work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-NX53P4B7
661    pub fn batch_write<T, E>(&self, f: impl FnOnce() -> std::result::Result<T, E>) -> std::result::Result<T, E>
662    where
663        E: From<StoreError>,
664    {
665        self.batch_begin()?;
666        match f() {
667            Ok(v) => self.batch_end().map_err(E::from).map(|_| v),
668            Err(e) => {
669                self.batch_abort();
670                Err(e)
671            }
672        }
673    }
674
675    /// Nest-safe unit of work inside a batch: a savepoint when a batch is
676    /// open (plain statements join the batch directly), else a standalone
677    /// transaction exactly as before. Replaces every `unchecked_transaction`
678    /// so batched and unbatched callers share the code path.
679    // trace:exempt reason=internal-detail
680    fn nest(&self) -> Result<NestGuard<'_>> {
681        self.conn.execute_batch("SAVEPOINT scc_nest")?;
682        Ok(NestGuard { conn: &self.conn, committed: false })
683    }
684
685        // trace:v1 id=impl.scc.store.stable-identity work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
686    pub fn open(path: &Path, root: &Path) -> Result<Store> {
687        let existed_nonempty = path.is_file()
688            && std::fs::metadata(path).map(|m| m.len() > 0).unwrap_or(false);
689        refuse_corrupt_existing_db(path)?;
690        let conn = match Connection::open(path) {
691            Ok(c) => c,
692            Err(e) if existed_nonempty => {
693                return Err(StoreError::Corrupt(e.to_string()));
694            }
695            Err(e) => return Err(e.into()),
696        };
697        if existed_nonempty {
698            probe_existing_schema(&conn)?;
699        }
700        conn.pragma_update(None, "journal_mode", "WAL")?;
701        // 30s lock wait (issue: `scc index` failed instantly with
702        // "database is locked" while a watch/daemon writer held the DB;
703        // the old 5s timeout lost to multi-second index writes and the
704        // bare sqlite message told the user nothing retryable). Locks
705        // are transient contention, NOT corruption — never quarantine.
706        conn.pragma_update(None, "busy_timeout", 30000)?;
707        // FULL durability: the store is the product (agent context reads it
708        // directly), not a rebuildable cache — an OS crash or power loss
709        // must never leave a half-written index behind. Speed comes from
710        // batching writes into one transaction per phase (see `batch_*`),
711        // not from weakening the fsync contract.
712        conn.pragma_update(None, "synchronous", "FULL")?;
713        conn.pragma_update(None, "foreign_keys", "ON")?;
714        apply_migrations(&conn)?;
715
716        let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
717        let name = root
718            .file_name()
719            .map(|s| s.to_string_lossy().to_string())
720            .unwrap_or_else(|| "repository".to_string());
721        // Stable repository identity (§XIV): explicit operator id wins,
722        // then the persistent id already stored in this database (checkout
723        // moves keep their semantic identity), then basename fallback.
724        let explicit = std::env::var("SCC_REPO_ID")
725            .ok()
726            .filter(|s| !s.trim().is_empty());
727        let mut repo_id = scc_core::sanitize_key(&name);
728        let mut id_source = "basename";
729        if let Some(e) = explicit {
730            repo_id = scc_core::sanitize_key(e.trim());
731            id_source = "explicit";
732        } else if existed_nonempty {
733            if let Some(persisted) = Self::existing_repo_id(&conn)? {
734                repo_id = persisted;
735                id_source = "persistent";
736            }
737        }
738
739        let mut store = Store {
740            conn,
741            root,
742            repo_id,
743            repo_name: name,
744            batch_depth: std::sync::atomic::AtomicUsize::new(0),
745        };
746        store.ensure_repository()?;
747        if id_source == "persistent" {
748            store.refresh_repository_root()?;
749        }
750        store.meta_set("repo_id_source", id_source)?;
751        Ok(store)
752    }
753
754    // trace:exempt reason=internal-detail
755    fn ensure_repository(&mut self) -> Result<()> {
756        let existing: Option<String> = self
757            .conn
758            .query_row(
759                "SELECT id FROM repositories WHERE id = ?1",
760                params![self.repo_id],
761                |r| r.get(0),
762            )
763            .optional()?;
764        if existing.is_none() {
765            self.conn.execute(
766                "INSERT INTO repositories (id, name, root, indexed_at) VALUES (?1, ?2, ?3, ?4)",
767                params![
768                    self.repo_id,
769                    self.repo_name,
770                    self.root.to_string_lossy(),
771                    scc_core::now_rfc3339()
772                ],
773            )?;
774        }
775        Ok(())
776    }
777    /// The repository id already persisted in this database, if any.
778    /// A moved checkout keeps the id its entities were written under.
779    // trace:exempt reason=internal-detail
780    fn existing_repo_id(conn: &Connection) -> Result<Option<String>> {
781        let id: Option<String> = conn
782            .query_row(
783                "SELECT id FROM repositories ORDER BY rowid LIMIT 1",
784                [],
785                |r| r.get(0),
786            )
787            .optional()?;
788        Ok(id)
789    }
790
791    /// Point the persisted repository row at the current checkout path
792    /// after a move. Identity (id) is untouched; only the provenance
793    /// path (root) follows the checkout.
794    // trace:exempt reason=internal-detail
795    fn refresh_repository_root(&self) -> Result<()> {
796        self.conn.execute(
797            "UPDATE repositories SET root = ?1, name = ?2 WHERE id = ?3",
798            params![
799                self.root.to_string_lossy(),
800                self.repo_name,
801                self.repo_id
802            ],
803        )?;
804        Ok(())
805    }
806
807    /// Adopt a stable identity derived from explicit config or the git
808    /// remote. Only applies to a fresh database (no entities yet): once
809    /// entity ids are written under an id, that id is load-bearing and
810    /// must survive via the persistent rule in [`Store::open`] instead.
811    /// Returns true when the identity changed.
812    // trace:v1 id=impl.scc.store.adopt-stable-identity work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
813    pub fn adopt_stable_identity(
814        &mut self,
815        explicit: Option<&str>,
816        remote_url: Option<&str>,
817    ) -> Result<bool> {
818        if self.meta_get("repo_id_source")?.as_deref() == Some("explicit") {
819            return Ok(false);
820        }
821        let n: u64 = self.conn.query_row(
822            "SELECT COUNT(*) FROM entities",
823            [],
824            |r| r.get(0),
825        )?;
826        if n > 0 {
827            return Ok(false);
828        }
829        let basename = self
830            .root
831            .file_name()
832            .map(|s| s.to_string_lossy().to_string())
833            .unwrap_or_else(|| "repository".to_string());
834        let id = scc_core::identity::stable_repo_id(explicit, remote_url, &basename);
835        if id == self.repo_id {
836            return Ok(false);
837        }
838        self.conn.execute(
839            "DELETE FROM repositories WHERE id = ?1",
840            params![self.repo_id],
841        )?;
842        self.repo_id = id;
843        self.ensure_repository()?;
844        self.meta_set("repo_id_source", "remote")?;
845        Ok(true)
846    }
847
848    // trace:exempt reason=internal-detail
849    pub fn repository(&self) -> Repository {
850        Repository {
851            id: self.repo_id.clone(),
852            name: self.repo_name.clone(),
853            url: self
854                .meta_get("remote_url")
855                .ok()
856                .flatten()
857                .filter(|s| !s.is_empty()),
858        }
859    }
860
861    // ------------------------------------------------------------------
862    // plugin state (§23): namespaced key/value storage. The host owns the
863    // table; the plugin id owns the namespace. Keys are opaque strings;
864    // values are JSON text (small config/cursor blobs, not artifacts).
865    // ------------------------------------------------------------------
866
867    // trace:exempt reason=internal-detail
868    pub fn plugin_state_put(&self, plugin_id: &str, key: &str, value: &str) -> Result<()> {
869        self.conn.execute(
870            "INSERT INTO plugin_state (plugin_id, key, value, updated_at) VALUES (?1, ?2, ?3, ?4)
871             ON CONFLICT(plugin_id, key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at",
872            params![plugin_id, key, value, scc_core::now_rfc3339()],
873        )?;
874        Ok(())
875    }
876
877    // trace:exempt reason=internal-detail
878    pub fn plugin_state_get(&self, plugin_id: &str, key: &str) -> Result<Option<String>> {
879        let v = self
880            .conn
881            .query_row(
882                "SELECT value FROM plugin_state WHERE plugin_id = ?1 AND key = ?2",
883                params![plugin_id, key],
884                |r| r.get(0),
885            )
886            .optional()?;
887        Ok(v)
888    }
889
890    // ------------------------------------------------------------------
891    // sidecar facts (§124 item 35): namespaced raw analyzer storage.
892    // (plugin_id, graph, key) -> JSON value. Same host-owns-table pattern
893    // as plugin_state; graphs are plugin-declared namespaces (e.g.
894    // `joern-cpg`). Never read by ranking/context — promotion only.
895    // ------------------------------------------------------------------
896
897    // trace:exempt reason=internal-detail
898    pub fn sidecar_put(&self, plugin_id: &str, graph: &str, key: &str, value: &str) -> Result<()> {
899        self.conn.execute(
900            "INSERT INTO sidecar_facts (plugin_id, graph, key, value, updated_at) VALUES (?1, ?2, ?3, ?4, ?5)
901             ON CONFLICT(plugin_id, graph, key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at",
902            params![plugin_id, graph, key, value, scc_core::now_rfc3339()],
903        )?;
904        Ok(())
905    }
906
907    // trace:exempt reason=internal-detail
908    pub fn sidecar_get(&self, plugin_id: &str, graph: &str, key: &str) -> Result<Option<String>> {
909        let v = self
910            .conn
911            .query_row(
912                "SELECT value FROM sidecar_facts WHERE plugin_id = ?1 AND graph = ?2 AND key = ?3",
913                params![plugin_id, graph, key],
914                |r| r.get(0),
915            )
916            .optional()?;
917        Ok(v)
918    }
919
920    // trace:exempt reason=internal-detail
921    pub fn sidecar_scan(&self, plugin_id: &str, graph: &str, prefix: &str, limit: usize) -> Result<Vec<(String, String)>> {
922        let esc = prefix.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_");
923        let like = format!("{esc}%");
924        let mut stmt = self.conn.prepare(
925            "SELECT key, value FROM sidecar_facts WHERE plugin_id = ?1 AND graph = ?2 AND key LIKE ?3 ESCAPE '\\' ORDER BY key LIMIT ?4",
926        )?;
927        let rows = stmt.query_map(params![plugin_id, graph, like, limit.max(1) as i64], |r| {
928            Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))
929        })?;
930        rows.collect::<std::result::Result<Vec<_>, _>>().map_err(|e| e.into())
931    }
932
933    // trace:exempt reason=internal-detail
934    pub fn plugin_state_delete(&self, plugin_id: &str, key: &str) -> Result<()> {
935        self.conn.execute(
936            "DELETE FROM plugin_state WHERE plugin_id = ?1 AND key = ?2",
937            params![plugin_id, key],
938        )?;
939        Ok(())
940    }
941
942    // trace:exempt reason=internal-detail
943    pub fn plugin_state_scan(&self, plugin_id: &str, prefix: &str, limit: usize) -> Result<Vec<(String, String)>> {
944        let esc = prefix.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_");
945        let like = format!("{esc}%");
946        let mut stmt = self.conn.prepare(
947            "SELECT key, value FROM plugin_state WHERE plugin_id = ?1 AND key LIKE ?2 ESCAPE '\\' ORDER BY key LIMIT ?3",
948        )?;
949        let rows = stmt.query_map(params![plugin_id, like, limit.max(1) as i64], |r| {
950            Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))
951        })?;
952        rows.collect::<std::result::Result<Vec<_>, _>>().map_err(|e| e.into())
953    }
954
955    // ------------------------------------------------------------------
956    // meta
957    // ------------------------------------------------------------------
958
959    // trace:exempt reason=internal-detail
960    pub fn meta_set(&self, key: &str, value: &str) -> Result<()> {
961        self.conn.execute(
962            "INSERT INTO meta (key, value) VALUES (?1, ?2)
963             ON CONFLICT(key) DO UPDATE SET value = excluded.value",
964            params![key, value],
965        )?;
966        Ok(())
967    }
968
969    // trace:exempt reason=internal-detail
970    pub fn meta_get(&self, key: &str) -> Result<Option<String>> {
971        let v = self
972            .conn
973            .query_row("SELECT value FROM meta WHERE key = ?1", params![key], |r| {
974                r.get(0)
975            })
976            .optional()?;
977        Ok(v)
978    }
979
980    /// Bump one model-epoch generation. Called by every mutation path that
981    /// changes system truth (index completion, LSP promotion, adapter
982    /// import, intent load, runtime ingestion, derived recompilation).
983    // trace:exempt reason=internal-detail
984    pub fn bump_epoch(&self, kind: ModelEpochKind) -> Result<()> {
985        let key = kind.meta_key();
986        let next: u64 = self
987            .conn
988            .query_row("SELECT value FROM meta WHERE key = ?1", params![key], |r| {
989                r.get::<_, String>(0)
990            })
991            .optional()?
992            .and_then(|s| s.parse::<u64>().ok())
993            .unwrap_or(0)
994            + 1;
995        self.meta_set(key, &next.to_string())
996    }
997
998    /// Current model epoch (all generations plus the latest snapshot
999    /// revision). Never cached by the caller: it must reflect every change
1000    /// immediately.
1001    // trace:exempt reason=internal-detail
1002    pub fn model_epoch(&self) -> Result<ModelEpoch> {
1003        let g = |k: &str| -> Result<u64> {
1004            Ok(self
1005                .meta_get(k)?
1006                .and_then(|s| s.parse::<u64>().ok())
1007                .unwrap_or(0))
1008        };
1009        Ok(ModelEpoch {
1010            source: g(ModelEpochKind::Source.meta_key())?,
1011            semantic: g(ModelEpochKind::Semantic.meta_key())?,
1012            evidence: g(ModelEpochKind::Evidence.meta_key())?,
1013            intent: g(ModelEpochKind::Intent.meta_key())?,
1014            runtime: g(ModelEpochKind::Runtime.meta_key())?,
1015            derived: g(ModelEpochKind::Derived.meta_key())?,
1016        })
1017    }
1018
1019    // ------------------------------------------------------------------
1020    // snapshots
1021    // ------------------------------------------------------------------
1022
1023    // trace:exempt reason=internal-detail
1024    pub fn begin_snapshot(&self, revision: &str, branch: Option<&str>) -> Result<i64> {
1025        self.conn.execute(
1026            "INSERT INTO snapshots (revision, branch, indexed_at, status) VALUES (?1, ?2, ?3, 'active')",
1027            params![revision, branch, scc_core::now_rfc3339()],
1028        )?;
1029        Ok(self.conn.last_insert_rowid())
1030    }
1031
1032    // trace:exempt reason=internal-detail
1033    pub fn finish_snapshot(&self, id: i64, file_count: usize) -> Result<()> {
1034        self.conn.execute(
1035            "UPDATE snapshots SET file_count = ?2, status = 'complete' WHERE id = ?1",
1036            params![id, file_count as i64],
1037        )?;
1038        // the indexed source tree changed — invalidate epoch-keyed packs
1039        self.bump_epoch(ModelEpochKind::Source)?;
1040        Ok(())
1041    }
1042
1043    // trace:exempt reason=internal-detail
1044    pub fn latest_snapshot(&self) -> Result<Option<Snapshot>> {
1045        let row = self
1046            .conn
1047            .query_row(
1048                "SELECT revision, branch, indexed_at FROM snapshots
1049                 WHERE status = 'complete' ORDER BY id DESC LIMIT 1",
1050                [],
1051                |r| {
1052                    Ok((
1053                        r.get::<_, String>(0)?,
1054                        r.get::<_, Option<String>>(1)?,
1055                        r.get::<_, String>(2)?,
1056                    ))
1057                },
1058            )
1059            .optional()?;
1060        Ok(row.map(|(revision, branch, indexed_at)| Snapshot {
1061            revision,
1062            branch,
1063            indexed_at,
1064        }))
1065    }
1066
1067    // trace:exempt reason=internal-detail
1068    pub fn snapshot_status(&self) -> Result<Option<(Snapshot, i64)>> {
1069        let row = self
1070            .conn
1071            .query_row(
1072                "SELECT revision, branch, indexed_at, (SELECT COUNT(*) FROM files) FROM snapshots
1073                 WHERE status = 'complete' ORDER BY id DESC LIMIT 1",
1074                [],
1075                |r| {
1076                    Ok((
1077                        r.get::<_, String>(0)?,
1078                        r.get::<_, Option<String>>(1)?,
1079                        r.get::<_, String>(2)?,
1080                        r.get::<_, i64>(3)?,
1081                    ))
1082                },
1083            )
1084            .optional()?;
1085        Ok(row.map(|(revision, branch, indexed_at, files)| {
1086            (
1087                Snapshot {
1088                    revision,
1089                    branch,
1090                    indexed_at,
1091                },
1092                files,
1093            )
1094        }))
1095    }
1096
1097    // ------------------------------------------------------------------
1098    // files
1099    // ------------------------------------------------------------------
1100
1101    // trace:exempt reason=internal-detail
1102    pub fn upsert_file(&self, path: &str, hash: &str, language: &str, kind: &str, size: u64) -> Result<()> {
1103        self.conn.execute(
1104            "INSERT INTO files (path, hash, language, kind, size, indexed_at)
1105             VALUES (?1, ?2, ?3, ?4, ?5, ?6)
1106             ON CONFLICT(path) DO UPDATE SET hash = excluded.hash, language = excluded.language,
1107               kind = excluded.kind, size = excluded.size, indexed_at = excluded.indexed_at",
1108            params![path, hash, language, kind, size as i64, scc_core::now_rfc3339()],
1109        )?;
1110        Ok(())
1111    }
1112
1113    // trace:exempt reason=internal-detail
1114    pub fn file(&self, path: &str) -> Result<Option<(String, String, String, u64)>> {
1115        let row = self
1116            .conn
1117            .query_row(
1118                "SELECT hash, language, kind, size FROM files WHERE path = ?1",
1119                params![path],
1120                |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get::<_, i64>(3)? as u64)),
1121            )
1122            .optional()?;
1123        Ok(row)
1124    }
1125
1126    // trace:exempt reason=internal-detail
1127    pub fn all_files(&self) -> Result<Vec<(String, String, String, String, u64)>> {
1128        let mut stmt = self
1129            .conn
1130            .prepare("SELECT path, hash, language, kind, size FROM files")?;
1131        let rows = stmt.query_map([], |r| {
1132            Ok((
1133                r.get::<_, String>(0)?,
1134                r.get::<_, String>(1)?,
1135                r.get::<_, String>(2)?,
1136                r.get::<_, String>(3)?,
1137                r.get::<_, i64>(4)? as u64,
1138            ))
1139        })?;
1140        let mut out = Vec::new();
1141        for row in rows {
1142            out.push(row?);
1143        }
1144        Ok(out)
1145    }
1146
1147    // trace:exempt reason=internal-detail
1148    pub fn delete_file(&self, path: &str) -> Result<()> {
1149        self.conn.execute("DELETE FROM files WHERE path = ?1", params![path])?;
1150        Ok(())
1151    }
1152
1153// trace:exempt reason=internal-detail
1154
1155    /// Remove everything tied to a source path: symbols, evidence,
1156    /// relationships derived from it, and symbol-level entities.
1157    ///
1158    /// SCHEMA/REACTIVE concept entities are NOT deleted by path: concepts
1159    /// are keyed by (kind, name) and may occur in many files. Their
1160    /// OCCURRENCE entities (per concept/path/owner/line) are deleted by
1161    /// path, the concept's `sources` provenance is recomputed from the
1162    /// surviving occurrences, and a concept with zero remaining
1163    /// occurrences is deleted entirely (with its edges) — so purging one
1164    /// file never deletes a schema another file still uses, and the
1165    /// derived occurrence count naturally reflects the survivors.
1166// trace:exempt reason=internal-detail
1167    pub fn purge_path(&self, path: &str) -> Result<()> {
1168        let _sp = self.nest()?;
1169        // concepts this path's occurrences attach to — captured before any
1170        // deletion so their provenance can be recomputed afterwards
1171        let affected_concepts: Vec<String> = {
1172            let mut stmt = self.conn.prepare(
1173                "SELECT DISTINCT r.object FROM relationships r
1174                 JOIN entities e ON e.id = r.subject
1175                 WHERE r.predicate = ?1 AND e.kind = ?2 AND e.sources LIKE ?3",
1176            )?;
1177            let rows = stmt.query_map(
1178                params![
1179                    scc_core::predicates::OCCURS,
1180                    scc_core::kinds::OCCURRENCE,
1181                    format!("%\"{path}\"%")
1182                ],
1183                |r| r.get::<_, String>(0),
1184            )?;
1185            let mut v = Vec::new();
1186            for r in rows {
1187                v.push(r?);
1188            }
1189            v.sort();
1190            v.dedup();
1191            v
1192        };
1193        // this path's occurrence entities (for edge + FTS cleanup)
1194        let occ_ids: Vec<String> = {
1195            let mut stmt = self.conn.prepare(
1196                "SELECT id FROM entities WHERE kind = ?1 AND sources LIKE ?2",
1197            )?;
1198            let rows = stmt.query_map(
1199                params![scc_core::kinds::OCCURRENCE, format!("%\"{path}\"%")],
1200                |r| r.get::<_, String>(0),
1201            )?;
1202            let mut v = Vec::new();
1203            for r in rows {
1204                v.push(r?);
1205            }
1206            v
1207        };
1208        // symbol entities: repo://{repo}/symbol/{path}/{name}
1209        let ev_ids: Vec<String> = {
1210            let mut stmt = self.conn.prepare("SELECT id FROM evidence WHERE path = ?1")?;
1211            let rows = stmt.query_map(params![path], |r| r.get::<_, String>(0))?;
1212            let mut v = Vec::new();
1213            for r in rows {
1214                v.push(r?);
1215            }
1216            v
1217        };
1218        // collect the file's symbol names before deleting them
1219        let sym_names: Vec<String> = {
1220            let mut stmt = self.conn.prepare("SELECT name FROM symbols WHERE file = ?1")?;
1221            let rows = stmt.query_map(params![path], |r| r.get::<_, String>(0))?;
1222            let mut v = Vec::new();
1223            for r in rows {
1224                v.push(r?);
1225            }
1226            v
1227        };
1228        self.conn.execute("DELETE FROM symbols WHERE file = ?1", params![path])?;
1229        self.conn.execute("DELETE FROM imports WHERE file = ?1", params![path])?;
1230        self.conn.execute("DELETE FROM evidence WHERE path = ?1", params![path])?;
1231        // relationships pointing at this file's symbol entities (calls from
1232        // unchanged files into removed symbols must not dangle)
1233        let mut orphaned_evidence: Vec<String> = Vec::new();
1234        for name in sym_names {
1235            let sid = scc_core::symbol_id(&self.repo_id, path, &name);
1236            let mut stmt = self.conn.prepare(
1237                "SELECT id, evidence FROM relationships WHERE subject = ?1 OR object = ?1",
1238            )?;
1239            let rows = stmt.query_map(params![sid], |r| {
1240                Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))
1241            })?;
1242            let mut to_delete: Vec<(String, String)> = Vec::new();
1243            for r in rows {
1244                to_delete.push(r?);
1245            }
1246            drop(stmt);
1247            for (rid, ev_json) in to_delete {
1248                if let Ok(ev_ids) = serde_json::from_str::<Vec<String>>(&ev_json) {
1249                    orphaned_evidence.extend(ev_ids);
1250                }
1251                self.conn.execute("DELETE FROM relationships WHERE id = ?1", params![rid])?;
1252            }
1253        }
1254        // NOTE: evidence referenced only by deleted relationships is swept
1255        // later by `sweep_orphan_evidence` after the derived layer rebuilds
1256        // (component/flow edges may still reference it until then).
1257        let _ = orphaned_evidence;
1258        self.conn.execute(
1259            "DELETE FROM entities WHERE id LIKE ?1",
1260            params![format!("%/symbol/{path}/%")],
1261        )?;
1262        // non-concept entities whose sources include the path (stores,
1263        // routes, contracts, ...). SCHEMA/REACTIVE concepts are keyed by
1264        // (kind, name) across files and handled below from their live
1265        // occurrences — a shared concept must never be deleted because one
1266        // of its files was purged.
1267        self.conn.execute(
1268            "DELETE FROM entities WHERE sources LIKE ?1 AND kind NOT IN (?2, ?3)",
1269            params![
1270                format!("%\"{path}\"%"),
1271                scc_core::kinds::SCHEMA,
1272                scc_core::kinds::REACTIVE
1273            ],
1274        )?;
1275        self.conn.execute(
1276            "DELETE FROM relationships WHERE source_path = ?1",
1277            params![path],
1278        )?;
1279        // relationships referencing removed evidence ids
1280        for ev in ev_ids {
1281            self.conn.execute(
1282                "DELETE FROM relationships WHERE evidence LIKE ?1",
1283                params![format!("%\"{ev}\"%")],
1284            )?;
1285        }
1286        self.conn.execute(
1287            "DELETE FROM tests WHERE file = ?1",
1288            params![path],
1289        )?;
1290        // occurrence entities: delete their edges + FTS rows, then the
1291        // entities themselves
1292        for oid in &occ_ids {
1293            self.conn.execute(
1294                "DELETE FROM relationships WHERE subject = ?1 OR object = ?1",
1295                params![oid],
1296            )?;
1297            self.conn.execute("DELETE FROM entities_fts WHERE id = ?1", params![oid])?;
1298        }
1299        self.conn.execute(
1300            "DELETE FROM entities WHERE kind = ?1 AND sources LIKE ?2",
1301            params![scc_core::kinds::OCCURRENCE, format!("%\"{path}\"%")],
1302        )?;
1303        // recompute concept provenance: a concept survives as long as any
1304        // occurrence remains — its sources become the surviving occurrence
1305        // paths (deduped, sorted). With zero occurrences the concept and
1306        // its edges are gone.
1307        for concept in &affected_concepts {
1308            let remaining: Vec<String> = {
1309                let mut stmt = self.conn.prepare(
1310                    "SELECT DISTINCT json_extract(e.attributes, '$.path') FROM entities e
1311                     JOIN relationships r ON r.subject = e.id
1312                     WHERE r.predicate = ?1 AND r.object = ?2 AND e.kind = ?3
1313                       AND json_extract(e.attributes, '$.path') IS NOT NULL
1314                     ORDER BY 1",
1315                )?;
1316                let rows = stmt.query_map(
1317                    params![
1318                        scc_core::predicates::OCCURS,
1319                        concept,
1320                        scc_core::kinds::OCCURRENCE
1321                    ],
1322                    |r| r.get::<_, String>(0),
1323                )?;
1324                let mut v = Vec::new();
1325                for r in rows {
1326                    v.push(r?);
1327                }
1328                v
1329            };
1330            if remaining.is_empty() {
1331                self.conn.execute(
1332                    "DELETE FROM relationships WHERE subject = ?1 OR object = ?1",
1333                    params![concept],
1334                )?;
1335                self.conn.execute("DELETE FROM entities_fts WHERE id = ?1", params![concept])?;
1336                self.conn.execute("DELETE FROM entities WHERE id = ?1", params![concept])?;
1337            } else {
1338                self.conn.execute(
1339                    "UPDATE entities SET sources = ?1 WHERE id = ?2",
1340                    params![serde_json::to_string(&remaining)?, concept],
1341                )?;
1342            }
1343        }
1344        _sp.commit()?;
1345        Ok(())
1346    }
1347
1348    /// Files that import this path or CALL one of its symbols. Used to
1349    /// re-extract hash-unchanged dependents so incremental ≡ cold after
1350    /// type-narrowed CALLS are written on the caller.
1351    // trace:exempt reason=internal-detail
1352    pub fn paths_depending_on(&self, path: &str) -> Result<Vec<String>> {
1353        let file_id = scc_core::entity_id(&self.repo_id, scc_core::kinds::FILE, path);
1354        let mut stmt = self.conn.prepare(
1355            "SELECT DISTINCT source_path FROM relationships
1356             WHERE source_path != ?1 AND source_path != ''
1357               AND predicate IN (?4, ?5)
1358               AND (
1359                 object = ?2
1360                 OR object IN (
1361                   SELECT id FROM entities
1362                   WHERE kind = ?3 AND json_extract(attributes, '$.file') = ?1
1363                 )
1364               )
1365             ORDER BY source_path",
1366        )?;
1367        let rows = stmt.query_map(
1368            params![
1369                path,
1370                file_id,
1371                scc_core::kinds::SYMBOL,
1372                scc_core::predicates::IMPORTS,
1373                scc_core::predicates::CALLS
1374            ],
1375            |r| r.get::<_, String>(0),
1376        )?;
1377        let mut out = Vec::new();
1378        for r in rows {
1379            let p = r?;
1380            if !p.is_empty() {
1381                out.push(p);
1382            }
1383        }
1384        Ok(out)
1385    }
1386
1387    /// Remove all indexed facts (used by full reindex).
1388    // trace:exempt reason=internal-detail
1389    pub fn purge_all(&self) -> Result<()> {
1390        let _sp = self.nest()?;
1391        for table in [
1392            "symbols",
1393            "entities",
1394            "relationships",
1395            "evidence",
1396            "components",
1397            "flows",
1398            "invariants",
1399            "tests",
1400            "context_cache",
1401            "drift_findings",
1402        ] {
1403            self.conn.execute(&format!("DELETE FROM {table}"), [])?;
1404        }
1405        self.conn.execute("DELETE FROM files", [])?;
1406        self.conn.execute("DELETE FROM snapshots", [])?;
1407        _sp.commit()?;
1408        Ok(())
1409    }
1410
1411    // ------------------------------------------------------------------
1412    // symbols
1413    // ------------------------------------------------------------------
1414
1415    #[allow(clippy::too_many_arguments)]
1416    // trace:exempt reason=internal-detail
1417    pub fn insert_symbol(
1418        &self,
1419        file: &str,
1420        name: &str,
1421        kind: &str,
1422        signature: Option<&str>,
1423        start_line: u32,
1424        end_line: u32,
1425        exported: bool,
1426        docstring: Option<&str>,
1427    ) -> Result<i64> {
1428        self.conn.execute(
1429            "INSERT INTO symbols (file, name, symbol_kind, signature, start_line, end_line, exported, docstring)
1430             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
1431            params![
1432                file,
1433                name,
1434                kind,
1435                signature,
1436                start_line as i64,
1437                end_line as i64,
1438                exported as i64,
1439                docstring
1440            ],
1441        )?;
1442        let id = self.conn.last_insert_rowid();
1443        self.conn.execute(
1444            "INSERT INTO symbols_fts (name, signature, symbol_kind, file) VALUES (?1, ?2, ?3, ?4)",
1445            params![name, signature.unwrap_or(""), kind, file],
1446        )?;
1447        Ok(id)
1448    }
1449
1450    // trace:exempt reason=internal-detail
1451    pub fn symbols_in_file(&self, file: &str) -> Result<Vec<(i64, String, String, Option<String>, u32, u32, bool, Option<String>)>> {
1452        let mut stmt = self
1453            .conn
1454            .prepare("SELECT id, name, symbol_kind, signature, start_line, end_line, exported, docstring FROM symbols WHERE file = ?1 ORDER BY start_line")?;
1455        let rows = stmt.query_map(params![file], |r| {
1456            Ok((
1457                r.get::<_, i64>(0)?,
1458                r.get::<_, String>(1)?,
1459                r.get::<_, String>(2)?,
1460                r.get::<_, Option<String>>(3)?,
1461                r.get::<_, i64>(4)? as u32,
1462                r.get::<_, i64>(5)? as u32,
1463                r.get::<_, i64>(6)? != 0,
1464                r.get::<_, Option<String>>(7)?,
1465            ))
1466        })?;
1467        let mut out = Vec::new();
1468        for r in rows {
1469            out.push(r?);
1470        }
1471        Ok(out)
1472    }
1473
1474    // trace:exempt reason=internal-detail
1475    pub fn symbols_named(&self, name: &str) -> Result<Vec<(String, String, String)>> {
1476        let mut stmt = self
1477            .conn
1478            .prepare("SELECT file, symbol_kind, signature FROM symbols WHERE name = ?1")?;
1479        let rows = stmt.query_map(params![name], |r| {
1480            Ok((r.get(0)?, r.get(1)?, r.get(2)?))
1481        })?;
1482        let mut out = Vec::new();
1483        for r in rows {
1484            out.push(r?);
1485        }
1486        Ok(out)
1487    }
1488
1489    // ------------------------------------------------------------------
1490    // imports
1491    // ------------------------------------------------------------------
1492
1493    // trace:exempt reason=internal-detail
1494    pub fn insert_imports(&self, file: &str, imports: &[(String, Vec<(String, String)>, u32, String)]) -> Result<()> {
1495        let _sp = self.nest()?;
1496        self.conn.execute("DELETE FROM imports WHERE file = ?1", params![file])?;
1497        for (module, names, line, typ) in imports {
1498            self.conn.execute(
1499                "INSERT INTO imports (file, module, names, line, type) VALUES (?1, ?2, ?3, ?4, ?5)",
1500                params![file, module, serde_json::to_string(names)?, *line as i64, typ],
1501            )?;
1502        }
1503        _sp.commit()?;
1504        Ok(())
1505    }
1506
1507    // trace:exempt reason=internal-detail
1508    pub fn imports_in_file(&self, file: &str) -> Result<Vec<(String, Vec<(String, String)>, u32, String)>> {
1509        let mut stmt = self
1510            .conn
1511            .prepare("SELECT module, names, line, type FROM imports WHERE file = ?1 ORDER BY line")?;
1512        let rows = stmt.query_map(params![file], |r| {
1513            Ok((
1514                r.get::<_, String>(0)?,
1515                r.get::<_, String>(1)?,
1516                r.get::<_, i64>(2)? as u32,
1517                r.get::<_, String>(3)?,
1518            ))
1519        })?;
1520        let mut out = Vec::new();
1521        for r in rows {
1522            let (module, names, line, typ) = r?;
1523            out.push((
1524                module,
1525                serde_json::from_str(&names).unwrap_or_default(),
1526                line,
1527                typ,
1528            ));
1529        }
1530        Ok(out)
1531    }
1532
1533    // trace:exempt reason=internal-detail
1534    pub fn all_imports(&self) -> Result<Vec<(String, String, Vec<(String, String)>, u32, String)>> {
1535        let mut stmt = self
1536            .conn
1537            .prepare("SELECT file, module, names, line, type FROM imports ORDER BY file, line")?;
1538        let rows = stmt.query_map([], |r| {
1539            Ok((
1540                r.get::<_, String>(0)?,
1541                r.get::<_, String>(1)?,
1542                r.get::<_, String>(2)?,
1543                r.get::<_, i64>(3)? as u32,
1544                r.get::<_, String>(4)?,
1545            ))
1546        })?;
1547        let mut out = Vec::new();
1548        for r in rows {
1549            let (file, module, names, line, typ) = r?;
1550            out.push((
1551                file,
1552                module,
1553                serde_json::from_str(&names).unwrap_or_default(),
1554                line,
1555                typ,
1556            ));
1557        }
1558        Ok(out)
1559    }
1560
1561    // ------------------------------------------------------------------
1562    // entities
1563    // ------------------------------------------------------------------
1564
1565    // trace:exempt reason=internal-detail
1566    pub fn insert_entity(&self, entity: &Entity, sources: &[String]) -> Result<()> {
1567        self.conn.execute(
1568            "INSERT OR REPLACE INTO entities (id, kind, name, attributes, evidence, sources)
1569             VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
1570            params![
1571                entity.id,
1572                entity.kind,
1573                entity.name,
1574                serde_json::to_string(&entity.attributes)?,
1575                serde_json::to_string(&entity.evidence)?,
1576                serde_json::to_string(sources)?,
1577            ],
1578        )?;
1579        self.conn.execute(
1580            "INSERT OR REPLACE INTO entities_fts (id, kind, name, attributes) VALUES (?1, ?2, ?3, ?4)",
1581            params![
1582                entity.id,
1583                entity.kind,
1584                entity.name,
1585                serde_json::to_string(&entity.attributes)?
1586            ],
1587        )?;
1588        Ok(())
1589    }
1590
1591    // trace:exempt reason=internal-detail
1592    pub fn get_entity(&self, id: &str) -> Result<Option<Entity>> {
1593        let row = self
1594            .conn
1595            .query_row(
1596                "SELECT id, kind, name, attributes, evidence FROM entities WHERE id = ?1",
1597                params![id],
1598                |r| {
1599                    Ok((
1600                        r.get::<_, String>(0)?,
1601                        r.get::<_, String>(1)?,
1602                        r.get::<_, String>(2)?,
1603                        r.get::<_, String>(3)?,
1604                        r.get::<_, String>(4)?,
1605                    ))
1606                },
1607            )
1608            .optional()?;
1609        Ok(row.map(|(id, kind, name, attributes, evidence)| Entity {
1610            id,
1611            kind,
1612            name,
1613            attributes: serde_json::from_str(&attributes).unwrap_or_default(),
1614            evidence: serde_json::from_str(&evidence).unwrap_or_default(),
1615        }))
1616    }
1617
1618    /// The `sources` provenance list of an entity — the repository-relative
1619    /// file paths that produced it. Stored separately from [`Entity`]
1620    /// (which carries no sources field), so this is the only reader.
1621    // trace:v1 id=impl.scc.store.entity_sources work=WORK-SCC-001 satisfies=REQ-SCC-IR
1622    pub fn entity_sources(&self, id: &str) -> Result<Vec<String>> {
1623        let row = self
1624            .conn
1625            .query_row(
1626                "SELECT sources FROM entities WHERE id = ?1",
1627                params![id],
1628                |r| r.get::<_, String>(0),
1629            )
1630            .optional()?;
1631        Ok(row
1632            .map(|s| serde_json::from_str(&s).unwrap_or_default())
1633            .unwrap_or_default())
1634    }
1635
1636    /// Live OCCURRENCE entities attached to a concept (OCCURS edges),
1637    /// sorted by id. Concept counts and `sources` provenance are always
1638    /// derived from these — never from a stored, write-time-mutated counter.
1639    // trace:v1 id=impl.scc.store.concept_occurrences work=WORK-SCC-001 satisfies=REQ-SCC-IR
1640    pub fn concept_occurrences(&self, concept_id: &str) -> Result<Vec<Entity>> {
1641        let mut stmt = self.conn.prepare(
1642            "SELECT e.id, e.kind, e.name, e.attributes, e.evidence FROM entities e
1643             JOIN relationships r ON r.subject = e.id
1644             WHERE r.predicate = ?1 AND r.object = ?2 AND e.kind = ?3
1645             ORDER BY e.id",
1646        )?;
1647        let rows = stmt.query_map(
1648            params![
1649                scc_core::predicates::OCCURS,
1650                concept_id,
1651                scc_core::kinds::OCCURRENCE
1652            ],
1653            |r| {
1654                Ok((
1655                    r.get::<_, String>(0)?,
1656                    r.get::<_, String>(1)?,
1657                    r.get::<_, String>(2)?,
1658                    r.get::<_, String>(3)?,
1659                    r.get::<_, String>(4)?,
1660                ))
1661            },
1662        )?;
1663        let mut out = Vec::new();
1664        for r in rows {
1665            let (id, kind, name, attributes, evidence) = r?;
1666            out.push(Entity {
1667                id,
1668                kind,
1669                name,
1670                attributes: serde_json::from_str(&attributes).unwrap_or_default(),
1671                evidence: serde_json::from_str(&evidence).unwrap_or_default(),
1672            });
1673        }
1674        Ok(out)
1675    }
1676
1677    // trace:exempt reason=internal-detail
1678    pub fn entities_by_kind(&self, kind: &str) -> Result<Vec<Entity>> {
1679        let mut stmt = self
1680            .conn
1681            .prepare("SELECT id, kind, name, attributes, evidence FROM entities WHERE kind = ?1 ORDER BY name")?;
1682        let rows = stmt.query_map(params![kind], |r| {
1683            Ok((
1684                r.get::<_, String>(0)?,
1685                r.get::<_, String>(1)?,
1686                r.get::<_, String>(2)?,
1687                r.get::<_, String>(3)?,
1688                r.get::<_, String>(4)?,
1689            ))
1690        })?;
1691        let mut out = Vec::new();
1692        for r in rows {
1693            let (id, kind, name, attributes, evidence) = r?;
1694            out.push(Entity {
1695                id,
1696                kind,
1697                name,
1698                attributes: serde_json::from_str(&attributes).unwrap_or_default(),
1699                evidence: serde_json::from_str(&evidence).unwrap_or_default(),
1700            });
1701        }
1702        Ok(out)
1703    }
1704
1705    // trace:exempt reason=internal-detail
1706    pub fn all_entities(&self) -> Result<Vec<Entity>> {
1707        self.all_entities_impl()
1708    }
1709
1710    // trace:exempt reason=internal-detail
1711    fn all_entities_impl(&self) -> Result<Vec<Entity>> {
1712        let mut stmt = self
1713            .conn
1714            .prepare("SELECT id, kind, name, attributes, evidence FROM entities ORDER BY kind, name")?;
1715        let rows = stmt.query_map([], |r| {
1716            Ok((
1717                r.get::<_, String>(0)?,
1718                r.get::<_, String>(1)?,
1719                r.get::<_, String>(2)?,
1720                r.get::<_, String>(3)?,
1721                r.get::<_, String>(4)?,
1722            ))
1723        })?;
1724        let mut out = Vec::new();
1725        for r in rows {
1726            let (id, kind, name, attributes, evidence) = r?;
1727            out.push(Entity {
1728                id,
1729                kind,
1730                name,
1731                attributes: serde_json::from_str(&attributes).unwrap_or_default(),
1732                evidence: serde_json::from_str(&evidence).unwrap_or_default(),
1733            });
1734        }
1735        Ok(out)
1736    }
1737
1738    // trace:exempt reason=internal-detail
1739    pub fn delete_entity(&self, id: &str) -> Result<()> {
1740        self.conn.execute("DELETE FROM entities WHERE id = ?1", params![id])?;
1741        self.conn
1742            .execute("DELETE FROM entities_fts WHERE id = ?1", params![id])?;
1743        Ok(())
1744    }
1745
1746    // trace:exempt reason=internal-detail
1747    pub fn delete_entities(&self, ids: &[String]) -> Result<()> {
1748        for id in ids {
1749            self.delete_entity(id)?;
1750        }
1751        Ok(())
1752    }
1753
1754    // ------------------------------------------------------------------
1755    // relationships
1756    // ------------------------------------------------------------------
1757
1758    // trace:exempt reason=internal-detail
1759    pub fn insert_relationship(&self, rel: &Relationship, source_path: &str) -> Result<()> {
1760        self.conn.execute(
1761            "INSERT OR REPLACE INTO relationships (id, subject, predicate, object, provenance, confidence, evidence, verified_at, source_path)
1762             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
1763            params![
1764                rel.id,
1765                rel.subject,
1766                rel.predicate,
1767                rel.object,
1768                rel.provenance.as_str(),
1769                rel.confidence,
1770                serde_json::to_string(&rel.evidence)?,
1771                rel.verified_at,
1772                source_path,
1773            ],
1774        )?;
1775        Ok(())
1776    }
1777
1778    // trace:exempt reason=internal-detail
1779    pub fn relationships_for(&self, subject: &str) -> Result<Vec<Relationship>> {
1780        self.query_relationships("SELECT * FROM relationships WHERE subject = ?1 ORDER BY id", params![subject])
1781    }
1782
1783    // trace:exempt reason=internal-detail
1784    pub fn relationships_to(&self, object: &str) -> Result<Vec<Relationship>> {
1785        self.query_relationships("SELECT * FROM relationships WHERE object = ?1 ORDER BY id", params![object])
1786    }
1787
1788    // trace:exempt reason=internal-detail
1789    pub fn relationships_between(&self, subject: &str, predicate: &str, object: &str) -> Result<Vec<Relationship>> {
1790        self.query_relationships(
1791            "SELECT * FROM relationships WHERE subject = ?1 AND predicate = ?2 AND object = ?3",
1792            params![subject, predicate, object],
1793        )
1794    }
1795
1796    // trace:exempt reason=internal-detail
1797    pub fn all_relationships(&self) -> Result<Vec<Relationship>> {
1798        self.query_relationships("SELECT * FROM relationships ORDER BY id", [])
1799    }
1800
1801    /// Relationship ids with the given source path and predicate.
1802    // trace:exempt reason=internal-detail
1803    pub fn relationship_ids_with_source(&self, path: &str, predicate: &str) -> Result<Vec<String>> {
1804        let mut stmt = self.conn.prepare(
1805            "SELECT id FROM relationships WHERE source_path = ?1 AND predicate = ?2",
1806        )?;
1807        let rows = stmt.query_map(params![path, predicate], |r| r.get::<_, String>(0))?;
1808        let mut out = Vec::new();
1809        for r in rows {
1810            out.push(r?);
1811        }
1812        Ok(out)
1813    }
1814
1815    // trace:exempt reason=internal-detail
1816    pub fn count_relationships(&self) -> Result<u64> {
1817        Ok(self
1818            .conn
1819            .query_row("SELECT COUNT(*) FROM relationships", [], |r| r.get(0))?)
1820    }
1821
1822    // trace:exempt reason=internal-detail
1823    pub fn delete_relationship(&self, id: &str) -> Result<()> {
1824        self.conn
1825            .execute("DELETE FROM relationships WHERE id = ?1", params![id])?;
1826        Ok(())
1827    }
1828
1829    // trace:exempt reason=internal-detail
1830    fn query_relationships(
1831        &self,
1832        sql: &str,
1833        // trace:exempt reason=internal-detail
1834        p: impl rusqlite::Params,
1835    ) -> Result<Vec<Relationship>> {
1836        let mut stmt = self.conn.prepare(sql)?;
1837        let rows = stmt.query_map(p, |r| {
1838            Ok((
1839                r.get::<_, String>(0)?,
1840                r.get::<_, String>(1)?,
1841                r.get::<_, String>(2)?,
1842                r.get::<_, String>(3)?,
1843                r.get::<_, String>(4)?,
1844                r.get::<_, f64>(5)?,
1845                r.get::<_, String>(6)?,
1846                r.get::<_, String>(7)?,
1847            ))
1848        })?;
1849        let mut out = Vec::new();
1850        for row in rows {
1851            let (id, subject, predicate, object, provenance, confidence, evidence, verified_at) =
1852                row?;
1853            out.push(Relationship {
1854                id,
1855                subject,
1856                predicate,
1857                object,
1858                provenance: parse_provenance(&provenance),
1859                confidence,
1860                evidence: serde_json::from_str(&evidence).unwrap_or_default(),
1861                verified_at,
1862            });
1863        }
1864        Ok(out)
1865    }
1866
1867    // ------------------------------------------------------------------
1868    // evidence
1869    // ------------------------------------------------------------------
1870
1871    // trace:exempt reason=internal-detail
1872    pub fn insert_evidence(&self, ev: &Evidence) -> Result<()> {
1873        self.conn.execute(
1874            "INSERT OR REPLACE INTO evidence (id, type, path, symbol, start_line, end_line, revision, content_hash, extractor, extractor_version)
1875             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
1876            params![
1877                ev.id,
1878                evidence_type_str(&ev.r#type),
1879                ev.path,
1880                ev.symbol,
1881                ev.start_line.map(|l| l as i64),
1882                ev.end_line.map(|l| l as i64),
1883                ev.revision,
1884                ev.content_hash,
1885                ev.extractor,
1886                ev.extractor_version,
1887            ],
1888        )?;
1889        Ok(())
1890    }
1891
1892    // trace:exempt reason=internal-detail
1893    pub fn get_evidence(&self, id: &str) -> Result<Option<Evidence>> {
1894        let row = self
1895            .conn
1896            .query_row(
1897                "SELECT id, type, path, symbol, start_line, end_line, revision, content_hash, extractor, extractor_version FROM evidence WHERE id = ?1",
1898                params![id],
1899                |r| {
1900                    Ok((
1901                        r.get::<_, String>(0)?,
1902                        r.get::<_, String>(1)?,
1903                        r.get::<_, Option<String>>(2)?,
1904                        r.get::<_, Option<String>>(3)?,
1905                        r.get::<_, Option<i64>>(4)?,
1906                        r.get::<_, Option<i64>>(5)?,
1907                        r.get::<_, Option<String>>(6)?,
1908                        r.get::<_, Option<String>>(7)?,
1909                        r.get::<_, Option<String>>(8)?,
1910                        r.get::<_, Option<String>>(9)?,
1911                    ))
1912                },
1913            )
1914            .optional()?;
1915        Ok(row.map(|(id, typ, path, symbol, sl, el, rev, hash, ext, extv)| Evidence {
1916            id,
1917            r#type: parse_evidence_type(&typ),
1918            path,
1919            symbol,
1920            start_line: sl.map(|v| v as u32),
1921            end_line: el.map(|v| v as u32),
1922            revision: rev,
1923            content_hash: hash,
1924            extractor: ext,
1925            extractor_version: extv,
1926        }))
1927    }
1928
1929    // trace:exempt reason=internal-detail
1930    pub fn all_evidence(&self) -> Result<Vec<Evidence>> {
1931        let mut stmt = self
1932            .conn
1933            .prepare("SELECT id, type, path, symbol, start_line, end_line, revision, content_hash, extractor, extractor_version FROM evidence ORDER BY id")?;
1934        let rows = stmt.query_map([], |r| {
1935            Ok((
1936                r.get::<_, String>(0)?,
1937                r.get::<_, String>(1)?,
1938                r.get::<_, Option<String>>(2)?,
1939                r.get::<_, Option<String>>(3)?,
1940                r.get::<_, Option<i64>>(4)?,
1941                r.get::<_, Option<i64>>(5)?,
1942                r.get::<_, Option<String>>(6)?,
1943                r.get::<_, Option<String>>(7)?,
1944                r.get::<_, Option<String>>(8)?,
1945                r.get::<_, Option<String>>(9)?,
1946            ))
1947        })?;
1948        let mut out = Vec::new();
1949        for row in rows {
1950            let (id, typ, path, symbol, sl, el, rev, hash, ext, extv) = row?;
1951            out.push(Evidence {
1952                id,
1953                r#type: parse_evidence_type(&typ),
1954                path,
1955                symbol,
1956                start_line: sl.map(|v| v as u32),
1957                end_line: el.map(|v| v as u32),
1958                revision: rev,
1959                content_hash: hash,
1960                extractor: ext,
1961                extractor_version: extv,
1962            });
1963        }
1964        Ok(out)
1965    }
1966
1967    // trace:exempt reason=internal-detail
1968    pub fn evidence_for_path(&self, path: &str) -> Result<Vec<Evidence>> {
1969        let mut stmt = self
1970            .conn
1971            .prepare("SELECT id, type, path, symbol, start_line, end_line, revision, content_hash, extractor, extractor_version FROM evidence WHERE path = ?1 ORDER BY id")?;
1972        let rows = stmt.query_map(params![path], |r| {
1973            Ok((
1974                r.get::<_, String>(0)?,
1975                r.get::<_, String>(1)?,
1976                r.get::<_, Option<String>>(2)?,
1977                r.get::<_, Option<String>>(3)?,
1978                r.get::<_, Option<i64>>(4)?,
1979                r.get::<_, Option<i64>>(5)?,
1980                r.get::<_, Option<String>>(6)?,
1981                r.get::<_, Option<String>>(7)?,
1982                r.get::<_, Option<String>>(8)?,
1983                r.get::<_, Option<String>>(9)?,
1984            ))
1985        })?;
1986        let mut out = Vec::new();
1987        for row in rows {
1988            let (id, typ, path, symbol, sl, el, rev, hash, ext, extv) = row?;
1989            out.push(Evidence {
1990                id,
1991                r#type: parse_evidence_type(&typ),
1992                path,
1993                symbol,
1994                start_line: sl.map(|v| v as u32),
1995                end_line: el.map(|v| v as u32),
1996                revision: rev,
1997                content_hash: hash,
1998                extractor: ext,
1999                extractor_version: extv,
2000            });
2001        }
2002        Ok(out)
2003    }
2004
2005    // ------------------------------------------------------------------
2006    // components / flows / invariants / tests
2007    // ------------------------------------------------------------------
2008
2009    // trace:exempt reason=internal-detail
2010    pub fn replace_components(&self, components: &[Entity]) -> Result<()> {
2011        let _sp = self.nest()?;
2012        self.conn.execute("DELETE FROM components", [])?;
2013        // INSERT OR REPLACE only covers ids still present. A clustering
2014        // topology change (merged `root+services` splitting back into
2015        // `root` + `services`) must drop the vanished derived entities or
2016        // System IR export keeps stale component nodes.
2017        let keep: HashSet<&str> = components.iter().map(|c| c.id.as_str()).collect();
2018        let stale: Vec<String> = {
2019            let mut stmt = self.conn.prepare("SELECT id FROM entities WHERE kind = ?1")?;
2020            let rows = stmt.query_map(params![scc_core::kinds::COMPONENT], |r| {
2021                r.get::<_, String>(0)
2022            })?;
2023            let mut v = Vec::new();
2024            for r in rows {
2025                let id = r?;
2026                if !keep.contains(id.as_str()) {
2027                    v.push(id);
2028                }
2029            }
2030            v
2031        };
2032        for id in &stale {
2033            self.conn.execute("DELETE FROM entities WHERE id = ?1", params![id])?;
2034            self.conn.execute("DELETE FROM entities_fts WHERE id = ?1", params![id])?;
2035        }
2036        for c in components {
2037            self.conn.execute(
2038                "INSERT INTO components (id, name, kind, responsibility, implementation, evidence, attributes)
2039                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
2040                params![
2041                    c.id,
2042                    c.name,
2043                    c.kind,
2044                    serde_json::to_string(&c.attributes.get("responsibility").cloned().unwrap_or(serde_json::json!([])))?,
2045                    serde_json::to_string(&c.attributes.get("implementation").cloned().unwrap_or(serde_json::json!([])))?,
2046                    serde_json::to_string(&c.evidence)?,
2047                    serde_json::to_string(&c.attributes)?,
2048                ],
2049            )?;
2050            self.conn.execute(
2051                "INSERT OR REPLACE INTO entities (id, kind, name, attributes, evidence, sources) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
2052                params![c.id, c.kind, c.name, serde_json::to_string(&c.attributes)?, serde_json::to_string(&c.evidence)?, "[]"],
2053            )?;
2054            self.conn.execute(
2055                "INSERT OR REPLACE INTO entities_fts (id, kind, name, attributes) VALUES (?1, ?2, ?3, ?4)",
2056                params![c.id, c.kind, c.name, serde_json::to_string(&c.attributes)?],
2057            )?;
2058        }
2059        _sp.commit()?;
2060        Ok(())
2061    }
2062
2063    // trace:exempt reason=internal-detail
2064    pub fn components(&self) -> Result<Vec<Entity>> {
2065        let mut stmt = self
2066            .conn
2067            .prepare("SELECT id, name, kind, responsibility, implementation, evidence, attributes FROM components ORDER BY name")?;
2068        let rows = stmt.query_map([], |r| {
2069            Ok((
2070                r.get::<_, String>(0)?,
2071                r.get::<_, String>(1)?,
2072                r.get::<_, String>(2)?,
2073                r.get::<_, String>(3)?,
2074                r.get::<_, String>(4)?,
2075                r.get::<_, String>(5)?,
2076                r.get::<_, String>(6)?,
2077            ))
2078        })?;
2079        let mut out = Vec::new();
2080        for row in rows {
2081            let (id, name, kind, resp, implm, ev, attrs) = row?;
2082            let mut attributes: std::collections::BTreeMap<String, serde_json::Value> =
2083                serde_json::from_str(&attrs).unwrap_or_default();
2084            if let Ok(r) = serde_json::from_str::<Vec<serde_json::Value>>(&resp) {
2085                attributes.insert("responsibility".into(), serde_json::Value::Array(r));
2086            }
2087            if let Ok(r) = serde_json::from_str::<Vec<serde_json::Value>>(&implm) {
2088                attributes.insert("implementation".into(), serde_json::Value::Array(r));
2089            }
2090            out.push(Entity {
2091                id,
2092                kind,
2093                name,
2094                attributes,
2095                evidence: serde_json::from_str(&ev).unwrap_or_default(),
2096            });
2097        }
2098        Ok(out)
2099    }
2100
2101    // ------------------------------------------------------------------
2102    // canonical flow graphs (Wave 3)
2103    // ------------------------------------------------------------------
2104
2105    // trace:exempt reason=internal-detail
2106    pub fn replace_flow_graphs(&self, graphs: &[scc_core::FlowGraph]) -> Result<()> {
2107        let _sp = self.nest()?;
2108        self.conn.execute("DELETE FROM flow_graphs", [])?;
2109        for g in graphs {
2110            let kind = scc_core::flow_kind_str(&g.kind);
2111            let trigger = g.trigger.clone().unwrap_or_default();
2112            self.conn.execute(
2113                "INSERT OR REPLACE INTO flow_graphs (id, kind, name, trigger, graph) VALUES (?1, ?2, ?3, ?4, ?5)",
2114                params![g.id, kind, g.name, trigger, serde_json::to_string(g)?],
2115            )?;
2116        }
2117        _sp.commit()?;
2118        Ok(())
2119    }
2120
2121    // trace:exempt reason=internal-detail
2122    pub fn flow_graphs(&self) -> Result<Vec<scc_core::FlowGraph>> {
2123        let mut stmt = self
2124            .conn
2125            .prepare("SELECT graph FROM flow_graphs ORDER BY id")?;
2126        let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
2127        let mut out = Vec::new();
2128        for r in rows {
2129            let json = r?;
2130            if let Ok(g) = serde_json::from_str(&json) {
2131                out.push(g);
2132            }
2133        }
2134        Ok(out)
2135    }
2136
2137    // trace:exempt reason=internal-detail
2138    pub fn replace_flows(&self, flows: &[Flow]) -> Result<()> {
2139        let _sp = self.nest()?;
2140        self.conn.execute("DELETE FROM flows", [])?;
2141        for f in flows {
2142            self.conn.execute(
2143                "INSERT INTO flows (id, kind, name, trigger, steps, attributes) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
2144                params![
2145                    f.id,
2146                    flow_kind_str(&f.kind),
2147                    f.name,
2148                    f.trigger,
2149                    serde_json::to_string(&f.steps)?,
2150                    serde_json::to_string(&f.attributes)?
2151                ],
2152            )?;
2153        }
2154        _sp.commit()?;
2155        Ok(())
2156    }
2157
2158    // trace:exempt reason=internal-detail
2159    pub fn flows(&self) -> Result<Vec<Flow>> {
2160        let mut stmt = self
2161            .conn
2162            .prepare("SELECT id, kind, name, trigger, steps, attributes FROM flows ORDER BY kind, name")?;
2163        let rows = stmt.query_map([], |r| {
2164            Ok((
2165                r.get::<_, String>(0)?,
2166                r.get::<_, String>(1)?,
2167                r.get::<_, String>(2)?,
2168                r.get::<_, Option<String>>(3)?,
2169                r.get::<_, String>(4)?,
2170                r.get::<_, String>(5)?,
2171            ))
2172        })?;
2173        let mut out = Vec::new();
2174        for row in rows {
2175            let (id, kind, name, trigger, steps, attrs) = row?;
2176            out.push(Flow {
2177                id,
2178                kind: parse_flow_kind(&kind),
2179                name,
2180                trigger,
2181                steps: serde_json::from_str(&steps).unwrap_or_default(),
2182                attributes: serde_json::from_str(&attrs).unwrap_or_default(),
2183            });
2184        }
2185        Ok(out)
2186    }
2187
2188    // trace:exempt reason=internal-detail
2189    pub fn flow(&self, id: &str) -> Result<Option<Flow>> {
2190        let row = self
2191            .conn
2192            .query_row(
2193                "SELECT id, kind, name, trigger, steps, attributes FROM flows WHERE id = ?1",
2194                params![id],
2195                |r| {
2196                    Ok((
2197                        r.get::<_, String>(0)?,
2198                        r.get::<_, String>(1)?,
2199                        r.get::<_, String>(2)?,
2200                        r.get::<_, Option<String>>(3)?,
2201                        r.get::<_, String>(4)?,
2202                        r.get::<_, String>(5)?,
2203                    ))
2204                },
2205            )
2206            .optional()?;
2207        Ok(row.map(|(id, kind, name, trigger, steps, attrs)| Flow {
2208            id,
2209            kind: parse_flow_kind(&kind),
2210            name,
2211            trigger,
2212            steps: serde_json::from_str(&steps).unwrap_or_default(),
2213            attributes: serde_json::from_str(&attrs).unwrap_or_default(),
2214        }))
2215    }
2216
2217    // trace:exempt reason=internal-detail
2218    pub fn replace_invariants(&self, invariants: &[Invariant]) -> Result<()> {
2219        let _sp = self.nest()?;
2220        self.conn.execute("DELETE FROM invariants", [])?;
2221        for inv in invariants {
2222            self.conn.execute(
2223                "INSERT INTO invariants (id, statement, severity, scope, enforced_by, provenance, evidence)
2224                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
2225                params![
2226                    inv.id,
2227                    inv.statement,
2228                    severity_str(&inv.severity),
2229                    serde_json::to_string(&inv.scope)?,
2230                    serde_json::to_string(&inv.enforced_by)?,
2231                    inv.provenance.map(|p| p.as_str().to_string()).unwrap_or_else(|| "DECLARED".into()),
2232                    serde_json::to_string(&inv.evidence)?,
2233                ],
2234            )?;
2235        }
2236        _sp.commit()?;
2237        Ok(())
2238    }
2239
2240    // trace:exempt reason=internal-detail
2241    pub fn invariants(&self) -> Result<Vec<Invariant>> {
2242        let mut stmt = self
2243            .conn
2244            .prepare("SELECT id, statement, severity, scope, enforced_by, provenance, evidence FROM invariants ORDER BY id")?;
2245        let rows = stmt.query_map([], |r| {
2246            Ok((
2247                r.get::<_, String>(0)?,
2248                r.get::<_, String>(1)?,
2249                r.get::<_, String>(2)?,
2250                r.get::<_, String>(3)?,
2251                r.get::<_, String>(4)?,
2252                r.get::<_, String>(5)?,
2253                r.get::<_, String>(6)?,
2254            ))
2255        })?;
2256        let mut out = Vec::new();
2257        for row in rows {
2258            let (id, statement, severity, scope, enforced_by, provenance, ev) = row?;
2259            out.push(Invariant {
2260                id,
2261                statement,
2262                severity: parse_severity(&severity),
2263                scope: serde_json::from_str(&scope).unwrap_or_default(),
2264                enforced_by: serde_json::from_str(&enforced_by).unwrap_or_default(),
2265                provenance: Some(parse_provenance(&provenance)),
2266                evidence: serde_json::from_str(&ev).unwrap_or_default(),
2267            });
2268        }
2269        Ok(out)
2270    }
2271
2272    // trace:exempt reason=internal-detail
2273    pub fn insert_test(&self, id: &str, name: &str, file: &str, kind: &str, symbol: Option<&str>) -> Result<()> {
2274        self.conn.execute(
2275            "INSERT OR REPLACE INTO tests (id, name, file, kind, symbol) VALUES (?1, ?2, ?3, ?4, ?5)",
2276            params![id, name, file, kind, symbol],
2277        )?;
2278        Ok(())
2279    }
2280
2281    // trace:exempt reason=internal-detail
2282    pub fn tests(&self) -> Result<Vec<(String, String, String, String, Option<String>)>> {
2283        let mut stmt = self
2284            .conn
2285            .prepare("SELECT id, name, file, kind, symbol FROM tests ORDER BY file, name")?;
2286        let rows = stmt.query_map([], |r| {
2287            Ok((
2288                r.get::<_, String>(0)?,
2289                r.get::<_, String>(1)?,
2290                r.get::<_, String>(2)?,
2291                r.get::<_, String>(3)?,
2292                r.get::<_, Option<String>>(4)?,
2293            ))
2294        })?;
2295        let mut out = Vec::new();
2296        for r in rows {
2297            out.push(r?);
2298        }
2299        Ok(out)
2300    }
2301
2302    /// Delete evidence records no longer referenced by any entity,
2303    /// relationship, component, invariant, flow step, or flow-graph node/
2304    /// edge. Run after the derived layer (components/flows) rebuilds,
2305    /// because derived edges may briefly hold references during
2306    /// recompilation.
2307    // trace:exempt reason=internal-detail
2308    pub fn sweep_orphan_evidence(&self) -> Result<u64> {
2309        // Set-based pass: collect every referenced evidence id ONCE (exact
2310        // id membership in the JSON arrays, no per-row LIKE scans), then
2311        // delete evidence rows whose id is not referenced anywhere.
2312        let mut referenced: HashSet<String> = HashSet::new();
2313
2314        // Columns holding JSON arrays of evidence ids.
2315        for (table, column) in [
2316            ("entities", "evidence"),
2317            ("relationships", "evidence"),
2318            ("components", "evidence"),
2319            ("invariants", "evidence"),
2320        ] {
2321            let sql = format!("SELECT {column} FROM {table}");
2322            let mut stmt = self.conn.prepare(&sql)?;
2323            let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
2324            for r in rows {
2325                if let Ok(ids) = serde_json::from_str::<Vec<String>>(&r?) {
2326                    referenced.extend(ids);
2327                }
2328            }
2329            drop(stmt);
2330        }
2331
2332        // flows.steps: array of FlowStep objects, each with an evidence list.
2333        {
2334            let mut stmt = self.conn.prepare("SELECT steps FROM flows")?;
2335            let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
2336            for r in rows {
2337                if let Ok(steps) = serde_json::from_str::<Vec<scc_core::FlowStep>>(&r?) {
2338                    for step in steps {
2339                        referenced.extend(step.evidence);
2340                    }
2341                }
2342            }
2343            drop(stmt);
2344        }
2345
2346        // flow_graphs.graph: nodes[].evidence and edges[].evidence arrays.
2347        {
2348            let mut stmt = self.conn.prepare("SELECT graph FROM flow_graphs")?;
2349            let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
2350            for r in rows {
2351                if let Ok(g) = serde_json::from_str::<scc_core::FlowGraph>(&r?) {
2352                    for node in &g.nodes {
2353                        referenced.extend(node.evidence.iter().cloned());
2354                    }
2355                    for edge in &g.edges {
2356                        referenced.extend(edge.evidence.iter().cloned());
2357                    }
2358                }
2359            }
2360            drop(stmt);
2361        }
2362
2363        let mut stmt = self.conn.prepare("SELECT id FROM evidence")?;
2364        let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
2365        let mut orphans: Vec<String> = Vec::new();
2366        for r in rows {
2367            let id = r?;
2368            if !referenced.contains(&id) {
2369                orphans.push(id);
2370            }
2371        }
2372        drop(stmt);
2373
2374        let count = orphans.len() as u64;
2375        // Chunked deletes avoid one giant IN list.
2376        for chunk in orphans.chunks(500) {
2377            let placeholders = vec!["?"; chunk.len()].join(",");
2378            let sql = format!("DELETE FROM evidence WHERE id IN ({placeholders})");
2379            self.conn
2380                .execute(&sql, rusqlite::params_from_iter(chunk.iter()))?;
2381        }
2382        Ok(count)
2383    }
2384
2385    // ------------------------------------------------------------------
2386    // context cache
2387    // ------------------------------------------------------------------
2388
2389    /// Canonical epoch string for cache keys: composite of every model
2390    /// generation plus the latest snapshot revision. A previously fresh
2391    /// pack can never be served after any source of system truth changed.
2392    // trace:exempt reason=internal-detail
2393    pub fn cache_epoch(&self) -> Result<String> {
2394        let revision = self
2395            .latest_snapshot()?
2396            .map(|s| s.revision)
2397            .unwrap_or_else(|| "not-indexed".to_string());
2398        Ok(self.model_epoch()?.composite(&revision))
2399    }
2400
2401    // trace:exempt reason=internal-detail
2402    pub fn cache_get(&self, key: &str, epoch: &str) -> Result<Option<String>> {
2403        let row = self
2404            .conn
2405            .query_row(
2406                "SELECT pack FROM context_cache WHERE key = ?1 AND epoch = ?2",
2407                params![key, epoch],
2408                |r| r.get(0),
2409            )
2410            .optional()?;
2411        Ok(row)
2412    }
2413
2414    // trace:exempt reason=internal-detail
2415    pub fn cache_put(&self, key: &str, pack: &str, epoch: &str) -> Result<()> {
2416        self.conn.execute(
2417            "INSERT OR REPLACE INTO context_cache (key, pack, epoch, created_at) VALUES (?1, ?2, ?3, ?4)",
2418            params![key, pack, epoch, scc_core::now_rfc3339()],
2419        )?;
2420        Ok(())
2421    }
2422
2423    // trace:exempt reason=internal-detail
2424    pub fn cache_clear(&self) -> Result<()> {
2425        self.conn.execute("DELETE FROM context_cache", [])?;
2426        Ok(())
2427    }
2428
2429    // ------------------------------------------------------------------
2430    // intent claims / drift findings
2431    // ------------------------------------------------------------------
2432
2433    // trace:exempt reason=internal-detail
2434    pub fn replace_intent_claims(&self, claims: &[(String, serde_json::Value)]) -> Result<()> {
2435        let _sp = self.nest()?;
2436        self.conn.execute("DELETE FROM intent_claims", [])?;
2437        for (source, claim) in claims {
2438            self.conn.execute(
2439                "INSERT INTO intent_claims (source, claim, created_at) VALUES (?1, ?2, ?3)",
2440                params![source, serde_json::to_string(claim)?, scc_core::now_rfc3339()],
2441            )?;
2442        }
2443        _sp.commit()?;
2444        // declared intent changed — invalidate epoch-keyed packs
2445        self.bump_epoch(ModelEpochKind::Intent)?;
2446        Ok(())
2447    }
2448
2449    // trace:exempt reason=internal-detail
2450    pub fn intent_claims(&self) -> Result<Vec<(String, serde_json::Value)>> {
2451        let mut stmt = self
2452            .conn
2453            .prepare("SELECT source, claim FROM intent_claims ORDER BY id")?;
2454        let rows = stmt.query_map([], |r| {
2455            Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))
2456        })?;
2457        let mut out = Vec::new();
2458        for r in rows {
2459            let (source, claim) = r?;
2460            if let Ok(v) = serde_json::from_str(&claim) {
2461                out.push((source, v));
2462            }
2463        }
2464        Ok(out)
2465    }
2466
2467    // ------------------------------------------------------------------
2468    // runtime edges
2469    // ------------------------------------------------------------------
2470
2471    // trace:exempt reason=internal-detail
2472    pub fn runtime_edge_rows(&self) -> Result<Vec<RuntimeEdgeRow>> {
2473        let mut stmt = self.conn.prepare(
2474            "SELECT source, target, count, latency_ms, errors, last_observed
2475             FROM runtime_edges ORDER BY source, target",
2476        )?;
2477        let rows = stmt.query_map([], |r| {
2478            Ok(RuntimeEdgeRow {
2479                source: r.get(0)?,
2480                target: r.get(1)?,
2481                count: r.get::<_, i64>(2)? as u64,
2482                latency_ms: r.get(3)?,
2483                errors: r.get::<_, i64>(4)? as u64,
2484                last_observed: r.get(5)?,
2485            })
2486        })?;
2487        let mut out = Vec::new();
2488        for r in rows {
2489            out.push(r?);
2490        }
2491        Ok(out)
2492    }
2493
2494    // ------------------------------------------------------------------
2495    // trace signatures (Wave 6)
2496    // ------------------------------------------------------------------
2497
2498    /// Record one occurrence of an observed trace-path signature. `count`
2499    /// increments by one (one trace occurrence), `latency_ms` is merged as a
2500    /// count-weighted running average, `errors` is additive. Does NOT bump
2501    /// any model-epoch generation: ingestion callers bump the Runtime
2502    /// generation once per payload.
2503    // trace:exempt reason=internal-detail
2504    pub fn upsert_trace_signature(&self, signature: &str, latency_ms: f64, errors: u64) -> Result<()> {
2505        self.conn.execute(
2506            "INSERT INTO trace_signatures (signature, count, latency_ms, errors, last_observed)
2507             VALUES (?1, 1, ?2, ?3, ?4)
2508             ON CONFLICT(signature) DO UPDATE SET
2509               count = trace_signatures.count + 1,
2510               latency_ms = (trace_signatures.latency_ms * trace_signatures.count + excluded.latency_ms)
2511                            / (trace_signatures.count + 1),
2512               errors = trace_signatures.errors + excluded.errors,
2513               last_observed = excluded.last_observed",
2514            params![signature, latency_ms, errors as i64, scc_core::now_rfc3339()],
2515        )?;
2516        Ok(())
2517    }
2518
2519    /// All observed trace signatures, ordered by (count DESC, signature).
2520    // trace:exempt reason=internal-detail
2521    pub fn trace_signatures(&self) -> Result<Vec<(String, u64, f64, u64, String)>> {
2522        let mut stmt = self.conn.prepare(
2523            "SELECT signature, count, latency_ms, errors, last_observed
2524             FROM trace_signatures ORDER BY count DESC, signature",
2525        )?;
2526        let rows = stmt.query_map([], |r| {
2527            Ok((
2528                r.get::<_, String>(0)?,
2529                r.get::<_, i64>(1)? as u64,
2530                r.get::<_, f64>(2)?,
2531                r.get::<_, i64>(3)? as u64,
2532                r.get::<_, String>(4)?,
2533            ))
2534        })?;
2535        let mut out = Vec::new();
2536        for r in rows {
2537            out.push(r?);
2538        }
2539        Ok(out)
2540    }
2541
2542    // ------------------------------------------------------------------
2543    // embeddings
2544    // ------------------------------------------------------------------
2545
2546    // trace:exempt reason=internal-detail
2547    pub fn put_embedding(&self, entity_id: &str, vector: &[f32], model: &str) -> Result<()> {
2548        let bytes: Vec<u8> = vector
2549            .iter()
2550            .flat_map(|f| f.to_le_bytes())
2551            .collect();
2552        self.conn.execute(
2553            "INSERT OR REPLACE INTO embeddings (entity_id, vector, model, updated_at)
2554             VALUES (?1, ?2, ?3, ?4)",
2555            params![entity_id, bytes, model, scc_core::now_rfc3339()],
2556        )?;
2557        Ok(())
2558    }
2559
2560    // trace:exempt reason=internal-detail
2561    pub fn get_embedding(&self, entity_id: &str) -> Result<Option<(Vec<f32>, String)>> {
2562        let row = self
2563            .conn
2564            .query_row(
2565                "SELECT vector, model FROM embeddings WHERE entity_id = ?1",
2566                params![entity_id],
2567                |r| Ok((r.get::<_, Vec<u8>>(0)?, r.get::<_, String>(1)?)),
2568            )
2569            .optional()?;
2570        Ok(row.map(|(bytes, model)| {
2571            let v = bytes
2572                .as_chunks::<4>().0.iter()
2573                .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
2574                .collect();
2575            (v, model)
2576        }))
2577    }
2578
2579    // trace:exempt reason=internal-detail
2580    pub fn embedding_count(&self) -> Result<u64> {
2581        Ok(self
2582            .conn
2583            .query_row("SELECT COUNT(*) FROM embeddings", [], |r| r.get(0))?)
2584    }
2585
2586    // trace:exempt reason=internal-detail
2587    pub fn add_drift_finding(&self, kind: &str, severity: &str, message: &str) -> Result<i64> {
2588        self.conn.execute(
2589            "INSERT INTO drift_findings (kind, severity, message, created_at, resolved) VALUES (?1, ?2, ?3, ?4, 0)",
2590            params![kind, severity, message, scc_core::now_rfc3339()],
2591        )?;
2592        Ok(self.conn.last_insert_rowid())
2593    }
2594
2595    // trace:exempt reason=internal-detail
2596    pub fn drift_findings(&self, unresolved_only: bool) -> Result<Vec<(i64, String, String, String, String)>> {
2597        let sql = if unresolved_only {
2598            "SELECT id, kind, severity, message, created_at FROM drift_findings WHERE resolved = 0 ORDER BY id"
2599        } else {
2600            "SELECT id, kind, severity, message, created_at FROM drift_findings ORDER BY id"
2601        };
2602        let mut stmt = self.conn.prepare(sql)?;
2603        let rows = stmt.query_map([], |r| {
2604            Ok((
2605                r.get::<_, i64>(0)?,
2606                r.get::<_, String>(1)?,
2607                r.get::<_, String>(2)?,
2608                r.get::<_, String>(3)?,
2609                r.get::<_, String>(4)?,
2610            ))
2611        })?;
2612        let mut out = Vec::new();
2613        for r in rows {
2614            out.push(r?);
2615        }
2616        Ok(out)
2617    }
2618
2619    // trace:exempt reason=internal-detail
2620    pub fn clear_drift_findings(&self) -> Result<()> {
2621        self.conn.execute("DELETE FROM drift_findings", [])?;
2622        Ok(())
2623    }
2624
2625    // ------------------------------------------------------------------
2626    // search (FTS5)
2627    // ------------------------------------------------------------------
2628
2629    /// Lexical search over entities (components, routes, data, stores...).
2630    // trace:exempt reason=internal-detail
2631    pub fn search_entities(&self, query: &str, limit: usize) -> Result<Vec<Entity>> {
2632        let q = fts_query(query);
2633        let mut stmt = self.conn.prepare(
2634            "SELECT e.id, e.kind, e.name, e.attributes, e.evidence
2635             FROM entities_fts f JOIN entities e ON e.id = f.id
2636             WHERE entities_fts MATCH ?1 ORDER BY bm25(entities_fts) LIMIT ?2",
2637        )?;
2638        let rows = stmt.query_map(params![q, limit as i64], |r| {
2639            Ok((
2640                r.get::<_, String>(0)?,
2641                r.get::<_, String>(1)?,
2642                r.get::<_, String>(2)?,
2643                r.get::<_, String>(3)?,
2644                r.get::<_, String>(4)?,
2645            ))
2646        })?;
2647        let mut out = Vec::new();
2648        for r in rows {
2649            let (id, kind, name, attributes, evidence) = r?;
2650            out.push(Entity {
2651                id,
2652                kind,
2653                name,
2654                attributes: serde_json::from_str(&attributes).unwrap_or_default(),
2655                evidence: serde_json::from_str(&evidence).unwrap_or_default(),
2656            });
2657        }
2658        Ok(out)
2659    }
2660
2661    /// Lexical search over symbols.
2662    // trace:exempt reason=internal-detail
2663    pub fn search_symbols(&self, query: &str, limit: usize) -> Result<Vec<(String, String, String, String, u32)>> {
2664        let q = fts_query(query);
2665        let mut stmt = self.conn.prepare(
2666            "SELECT f.name, f.signature, f.symbol_kind, f.file,
2667                    COALESCE((SELECT s.start_line FROM symbols s
2668                              WHERE s.file = f.file AND s.name = f.name LIMIT 1), 0)
2669             FROM symbols_fts f
2670             WHERE symbols_fts MATCH ?1 ORDER BY bm25(symbols_fts) LIMIT ?2",
2671        )?;
2672        let rows = stmt.query_map(params![q, limit as i64], |r| {
2673            Ok((
2674                r.get::<_, String>(0)?,
2675                r.get::<_, Option<String>>(1)?.unwrap_or_default(),
2676                r.get::<_, String>(2)?,
2677                r.get::<_, String>(3)?,
2678                r.get::<_, i64>(4)? as u32,
2679            ))
2680        })?;
2681        let mut out = Vec::new();
2682        for r in rows {
2683            out.push(r?);
2684        }
2685        Ok(out)
2686    }
2687
2688    /// Substring fallback over entity names AND attributes (docstrings,
2689    /// signatures, responsibilities) — case-insensitive. Used when FTS prefix
2690    /// matching misses morphological variants or multi-term AND queries drop
2691    /// otherwise-strong matches.
2692    // trace:exempt reason=internal-detail
2693    pub fn search_entities_like(&self, term: &str, limit: usize) -> Result<Vec<Entity>> {
2694        let pat = format!("%{}%", term.to_ascii_lowercase());
2695        let mut stmt = self.conn.prepare(
2696            "SELECT id, kind, name, attributes, evidence FROM entities
2697             WHERE lower(name) LIKE ?1 OR lower(attributes) LIKE ?1
2698             ORDER BY CASE WHEN lower(name) LIKE ?1 THEN 0 ELSE 1 END, length(name)
2699             LIMIT ?2",
2700        )?;
2701        let rows = stmt.query_map(params![pat, limit as i64], |r| {
2702            Ok((
2703                r.get::<_, String>(0)?,
2704                r.get::<_, String>(1)?,
2705                r.get::<_, String>(2)?,
2706                r.get::<_, String>(3)?,
2707                r.get::<_, String>(4)?,
2708            ))
2709        })?;
2710        let mut out = Vec::new();
2711        for r in rows {
2712            let (id, kind, name, attributes, evidence) = r?;
2713            out.push(Entity {
2714                id,
2715                kind,
2716                name,
2717                attributes: serde_json::from_str(&attributes).unwrap_or_default(),
2718                evidence: serde_json::from_str(&evidence).unwrap_or_default(),
2719            });
2720        }
2721        Ok(out)
2722    }
2723
2724    /// Kind-scoped substring start-set for traversal (§16): entities of
2725    /// `kind` whose name matches `term` (empty term = all of the kind,
2726    /// capped by `limit`). LIKE keeps this dependency-free (no FTS).
2727    // trace:v1 id=impl.scc-store-entities-like-kind work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
2728    pub fn search_entities_like_kind(&self, kind: &str, term: &str, limit: usize) -> Result<Vec<Entity>> {
2729        let pat = format!("%{}%", term.to_ascii_lowercase());
2730        let mut stmt = self.conn.prepare(
2731            "SELECT id, kind, name, attributes, evidence FROM entities
2732             WHERE kind = ?1 AND lower(name) LIKE ?2
2733             ORDER BY length(name) LIMIT ?3",
2734        )?;
2735        let rows = stmt.query_map(params![kind, pat, limit as i64], |r| {
2736            Ok((
2737                r.get::<_, String>(0)?,
2738                r.get::<_, String>(1)?,
2739                r.get::<_, String>(2)?,
2740                r.get::<_, String>(3)?,
2741                r.get::<_, String>(4)?,
2742            ))
2743        })?;
2744        let mut out = Vec::new();
2745        for r in rows {
2746            let (id, kind, name, attributes, evidence) = r?;
2747            out.push(Entity {
2748                id,
2749                kind,
2750                name,
2751                attributes: serde_json::from_str(&attributes).unwrap_or_default(),
2752                evidence: serde_json::from_str(&evidence).unwrap_or_default(),
2753            });
2754        }
2755        Ok(out)
2756    }
2757
2758    /// Substring fallback over symbols (name, signature, docstring).
2759    // trace:exempt reason=internal-detail
2760    pub fn search_symbols_like(&self, term: &str, limit: usize) -> Result<Vec<(String, String, String, String, u32)>> {
2761        let pat = format!("%{}%", term.to_ascii_lowercase());
2762        let mut stmt = self.conn.prepare(
2763            "SELECT name, signature, symbol_kind, file, start_line FROM symbols
2764             WHERE lower(name) LIKE ?1 OR lower(signature) LIKE ?1 OR lower(docstring) LIKE ?1
2765             ORDER BY CASE WHEN lower(name) LIKE ?1 THEN 0 ELSE 1 END, length(name)
2766             LIMIT ?2",
2767        )?;
2768        let rows = stmt.query_map(params![pat, limit as i64], |r| {
2769            Ok((
2770                r.get::<_, String>(0)?,
2771                r.get::<_, Option<String>>(1)?.unwrap_or_default(),
2772                r.get::<_, String>(2)?,
2773                r.get::<_, String>(3)?,
2774                r.get::<_, i64>(4)? as u32,
2775            ))
2776        })?;
2777        let mut out = Vec::new();
2778        for r in rows {
2779            out.push(r?);
2780        }
2781        Ok(out)
2782    }
2783
2784    // ------------------------------------------------------------------
2785    // stats
2786    // ------------------------------------------------------------------
2787
2788    // trace:exempt reason=internal-detail
2789    pub fn stats(&self) -> Result<HashMap<String, u64>> {
2790        let mut m = HashMap::new();
2791        for (name, sql) in [
2792            ("files", "SELECT COUNT(*) FROM files"),
2793            ("symbols", "SELECT COUNT(*) FROM symbols"),
2794            ("entities", "SELECT COUNT(*) FROM entities"),
2795            ("relationships", "SELECT COUNT(*) FROM relationships"),
2796            ("evidence", "SELECT COUNT(*) FROM evidence"),
2797            ("components", "SELECT COUNT(*) FROM components"),
2798            ("flows", "SELECT COUNT(*) FROM flows"),
2799            ("invariants", "SELECT COUNT(*) FROM invariants"),
2800            ("tests", "SELECT COUNT(*) FROM tests"),
2801        ] {
2802            let n: i64 = self.conn.query_row(sql, [], |r| r.get(0))?;
2803            m.insert(name.to_string(), n as u64);
2804        }
2805        Ok(m)
2806    }
2807}
2808
2809// ---------------------------------------------------------------------------
2810// helpers
2811// ---------------------------------------------------------------------------
2812
2813// trace:exempt reason=internal-detail
2814fn apply_migrations(conn: &Connection) -> Result<()> {
2815    let version: i64 = conn.query_row("PRAGMA user_version", [], |r| r.get(0))?;
2816    let current = SCHEMA_VERSION as i64;
2817    if version > current {
2818        return Err(StoreError::Sqlite(rusqlite::Error::InvalidParameterName(
2819            format!(
2820                "database schema v{version} is newer than supported v{SCHEMA_VERSION}"
2821            )
2822            .into_boxed_str()
2823            .to_string(),
2824        )));
2825    }
2826    // Apply every migration after the current version, in order.
2827    for (i, m) in MIGRATIONS.iter().enumerate() {
2828        let target = (i + 1) as i64;
2829        if version < target {
2830            conn.execute_batch(m)?;
2831        }
2832    }
2833    if version < current {
2834        conn.pragma_update(None, "user_version", current)?;
2835    }
2836    Ok(())
2837}
2838
2839// trace:exempt reason=internal-detail
2840pub fn parse_provenance(s: &str) -> Provenance {
2841    match s {
2842        "EXTRACTED" => Provenance::Extracted,
2843        "RESOLVED" => Provenance::Resolved,
2844        "OBSERVED" => Provenance::Observed,
2845        "DECLARED" => Provenance::Declared,
2846        "INFERRED" => Provenance::Inferred,
2847        "STALE" => Provenance::Stale,
2848        _ => Provenance::Inferred,
2849    }
2850}
2851
2852// trace:exempt reason=internal-detail
2853pub fn evidence_type_str(t: &scc_core::EvidenceType) -> &'static str {
2854    match t {
2855        scc_core::EvidenceType::Source => "source",
2856        scc_core::EvidenceType::Config => "config",
2857        scc_core::EvidenceType::Runtime => "runtime",
2858        scc_core::EvidenceType::Test => "test",
2859        scc_core::EvidenceType::Intent => "intent",
2860        scc_core::EvidenceType::History => "history",
2861    }
2862}
2863
2864// trace:exempt reason=internal-detail
2865pub fn parse_evidence_type(s: &str) -> scc_core::EvidenceType {
2866    match s {
2867        "source" => scc_core::EvidenceType::Source,
2868        "config" => scc_core::EvidenceType::Config,
2869        "runtime" => scc_core::EvidenceType::Runtime,
2870        "test" => scc_core::EvidenceType::Test,
2871        "intent" => scc_core::EvidenceType::Intent,
2872        "history" => scc_core::EvidenceType::History,
2873        _ => scc_core::EvidenceType::Source,
2874    }
2875}
2876
2877// trace:exempt reason=internal-detail
2878pub fn flow_kind_str(k: &scc_core::FlowKind) -> &'static str {
2879    match k {
2880        scc_core::FlowKind::Architecture => "architecture",
2881        scc_core::FlowKind::Workflow => "workflow",
2882        scc_core::FlowKind::Sequence => "sequence",
2883        scc_core::FlowKind::Dataflow => "dataflow",
2884        scc_core::FlowKind::Lifecycle => "lifecycle",
2885    }
2886}
2887
2888// trace:exempt reason=internal-detail
2889pub fn parse_flow_kind(s: &str) -> scc_core::FlowKind {
2890    match s {
2891        "architecture" => scc_core::FlowKind::Architecture,
2892        "workflow" => scc_core::FlowKind::Workflow,
2893        "sequence" => scc_core::FlowKind::Sequence,
2894        "dataflow" => scc_core::FlowKind::Dataflow,
2895        "lifecycle" => scc_core::FlowKind::Lifecycle,
2896        _ => scc_core::FlowKind::Sequence,
2897    }
2898}
2899
2900// trace:exempt reason=internal-detail
2901pub fn severity_str(s: &Severity) -> &'static str {
2902    match s {
2903        Severity::Info => "info",
2904        Severity::Low => "low",
2905        Severity::Medium => "medium",
2906        Severity::High => "high",
2907        Severity::Critical => "critical",
2908    }
2909}
2910
2911// trace:exempt reason=internal-detail
2912pub fn parse_severity(s: &str) -> Severity {
2913    match s {
2914        "info" => Severity::Info,
2915        "low" => Severity::Low,
2916        "medium" => Severity::Medium,
2917        "high" => Severity::High,
2918        "critical" => Severity::Critical,
2919        _ => Severity::Medium,
2920    }
2921}
2922
2923/// Build a safe FTS5 MATCH expression from free-form text: quoted terms with
2924/// prefix matching on the last term.
2925// trace:exempt reason=internal-detail
2926fn fts_query(text: &str) -> String {
2927    let tokens: Vec<String> = text
2928        .split(|c: char| !c.is_alphanumeric() && c != '_' && c != '-' && c != '.')
2929        .filter(|t| !t.is_empty())
2930        .map(|t| {
2931            let t = t.trim_matches('"');
2932            format!("\"{t}\"*")
2933        })
2934        .collect();
2935    if tokens.is_empty() {
2936        return "\"\"".to_string();
2937    }
2938    tokens.join(" ")
2939}
2940
2941#[cfg(test)]
2942// trace:exempt reason=internal-detail
2943mod tests {
2944    use super::*;
2945    use tempfile::TempDir;
2946
2947    // trace:exempt reason=internal-detail
2948    pub(crate) fn tmp_store() -> (Store, TempDir) {
2949        let dir = TempDir::new().unwrap();
2950        let root = dir.path().join("repo");
2951        std::fs::create_dir_all(&root).unwrap();
2952        let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
2953        (store, dir)
2954    }
2955
2956    #[test]
2957    // trace:v1 id=test.scc.store.recovering-open-quarantines-malformed verifies=REQ-SI-503JSBGP exercises=impl.scc.store.open-recovering
2958    fn recovering_open_quarantines_malformed_db() {
2959        let dir = TempDir::new().unwrap();
2960        let root = dir.path().join("repo");
2961        std::fs::create_dir_all(&root).unwrap();
2962        let db = dir.path().join("scc.db");
2963        // garbage with a valid SQLite header prefix still fails lazily:
2964        // write a valid header followed by garbage pages.
2965        let mut bytes = b"SQLite format 3\0".to_vec();
2966        bytes.resize(4096, 0xFF);
2967        std::fs::write(&db, &bytes).unwrap();
2968        let (store, quarantined) = Store::open_recovering(&db, &root).unwrap();
2969        let q = quarantined.expect("corrupt db must be quarantined");
2970        assert!(q.is_file(), "quarantine evidence preserved");
2971        // fresh store is fully usable
2972        let v: i64 = store
2973            .conn
2974            .query_row("PRAGMA user_version", [], |r| r.get(0))
2975            .unwrap();
2976        assert!(v >= 0);
2977        // non-corrupt opens never quarantine
2978        let (_, q2) = Store::open_recovering(&db, &root).unwrap();
2979        assert!(q2.is_none(), "healthy db must not quarantine");
2980    }
2981
2982    #[test]
2983    // trace:v1 id=test.scc.store.persistent-identity-survives-move verifies=REQ-SI-503JSBGP exercises=impl.scc.store.stable-identity
2984    fn persistent_identity_survives_checkout_move() {
2985        let dir = TempDir::new().unwrap();
2986        let root_a = dir.path().join("aaa");
2987        std::fs::create_dir_all(&root_a).unwrap();
2988        let db = dir.path().join("scc.db");
2989        let s1 = Store::open(&db, &root_a).unwrap();
2990        assert_eq!(s1.repo_id, "aaa");
2991        // move the checkout: same database, new directory name
2992        let root_b = dir.path().join("bbb");
2993        std::fs::create_dir_all(&root_b).unwrap();
2994        let s2 = Store::open(&db, &root_b).unwrap();
2995        assert_eq!(s2.repo_id, "aaa", "move must not fork identity");
2996        assert_eq!(
2997            s2.meta_get("repo_id_source").unwrap().as_deref(),
2998            Some("persistent")
2999        );
3000        // provenance path follows the checkout
3001        let row: String = s2.conn.query_row(
3002            "SELECT root FROM repositories WHERE id = 'aaa'",
3003            [],
3004            |r| r.get(0),
3005        ).unwrap();
3006        assert!(row.ends_with("bbb"), "{row}");
3007    }
3008
3009    #[test]
3010    // trace:v1 id=test.scc.store.explicit-and-remote-adopt verifies=REQ-SI-503JSBGP exercises=impl.scc.store.adopt-stable-identity
3011    fn explicit_and_remote_identity_adopt_on_fresh_db() {
3012        let (mut s, _d) = tmp_store();
3013        assert_eq!(s.repo_id, "repo");
3014        assert!(s.adopt_stable_identity(Some("MyRepo"), None).unwrap());
3015        assert_eq!(s.repo_id, "myrepo");
3016        assert!(!s.adopt_stable_identity(Some("MyRepo"), None).unwrap());
3017        let (mut t, _e) = tmp_store();
3018        assert!(t.adopt_stable_identity(None, Some("git@github.com:acme/billing.git")).unwrap());
3019        assert_eq!(t.repo_id, "github.com/acme/billing");
3020        // explicit operator id is never overwritten by a later remote
3021        assert_eq!(t.meta_get("repo_id_source").unwrap().as_deref(), Some("remote"));
3022        // entities freeze identity: adoption refuses once ids are load-bearing
3023        t.insert_entity(&Entity::new("x", "symbol", "f"), &["a.py".into()]).unwrap();
3024        assert!(!t.adopt_stable_identity(Some("other"), None).unwrap());
3025        assert_eq!(t.repo_id, "github.com/acme/billing");
3026    }
3027
3028
3029    #[test]
3030    // trace:exempt reason=internal-detail
3031    fn migrations_apply_and_reopen() {
3032        let dir = TempDir::new().unwrap();
3033        let root = dir.path().join("repo");
3034        std::fs::create_dir_all(&root).unwrap();
3035        let db = dir.path().join("scc.db");
3036        {
3037            let s = Store::open(&db, &root).unwrap();
3038            s.insert_entity(&Entity::new("repo://t/component/a", "component", "A"), &["x.py".into()]).unwrap();
3039        }
3040        // reopen
3041        let s = Store::open(&db, &root).unwrap();
3042        assert!(s.get_entity("repo://t/component/a").unwrap().is_some());
3043    }
3044
3045    #[test]
3046    // trace:v1 id=test.scc.store.refuse-corrupt verifies=REQ-implement-phase-12-of-scc-x-ripwire-lessons-java-unprefixed-field-as,REQ-implement-fix-pr-review-comments-without-collapsing-scc-type-script-no exercises=impl.scc.store.refuse-corrupt
3047    fn truncated_or_garbage_db_refuses_to_open() {
3048        let dir = TempDir::new().unwrap();
3049        let root = dir.path().join("repo");
3050        std::fs::create_dir_all(&root).unwrap();
3051        let db = dir.path().join("scc.db");
3052
3053        std::fs::write(&db, b"not a sqlite database at all").unwrap();
3054        let err = match Store::open(&db, &root) {
3055            Err(e) => e,
3056            Ok(_) => panic!("garbage db must not open"),
3057        };
3058        assert!(
3059            matches!(err, StoreError::Corrupt(_)),
3060            "garbage must be Corrupt, got {err}"
3061        );
3062        assert!(
3063            err.to_string().contains("corrupt") || err.to_string().contains("not a SQLite"),
3064            "{err}"
3065        );
3066
3067        {
3068            let s = Store::open(&dir.path().join("fresh.db"), &root).unwrap();
3069            s.meta_set("k", "v").unwrap();
3070        }
3071        let good = std::fs::read(dir.path().join("fresh.db")).unwrap();
3072        assert!(good.len() > 32, "expected a real sqlite file");
3073        std::fs::write(&db, &good[..32]).unwrap();
3074        let err = match Store::open(&db, &root) {
3075            Err(e) => e,
3076            Ok(_) => panic!("truncated sqlite must not open"),
3077        };
3078        assert!(
3079            matches!(err, StoreError::Corrupt(_)),
3080            "truncated sqlite must be Corrupt, got {err}"
3081        );
3082
3083        let empty = dir.path().join("empty.db");
3084        std::fs::write(&empty, b"").unwrap();
3085        Store::open(&empty, &root).expect("empty file is a fresh index");
3086    }
3087
3088    #[test]
3089    // trace:exempt reason=internal-detail
3090    fn model_epoch_bumps_and_composites() {
3091        let (s, _d) = tmp_store();
3092        assert_eq!(s.model_epoch().unwrap(), ModelEpoch::zero());
3093        let e0 = s.cache_epoch().unwrap();
3094
3095        // snapshot completion bumps the source generation
3096        let id = s.begin_snapshot("abc", None).unwrap();
3097        s.finish_snapshot(id, 3).unwrap();
3098        let e1 = s.model_epoch().unwrap();
3099        assert_eq!(e1.source, 1);
3100        assert_ne!(s.cache_epoch().unwrap(), e0);
3101
3102        // semantic promotion is an independent generation
3103        s.bump_epoch(ModelEpochKind::Semantic).unwrap();
3104        let e2 = s.model_epoch().unwrap();
3105        assert_eq!(e2.semantic, 1);
3106        assert_eq!(e2.source, 1);
3107
3108        // composite differs per generation combination and includes the
3109        // snapshot revision
3110        let c1 = e1.composite("abc");
3111        let c2 = e2.composite("abc");
3112        assert_ne!(c1, c2);
3113        assert_ne!(c1, e1.composite("def"));
3114        // same state -> same composite (deterministic)
3115        assert_eq!(c1, e1.composite("abc"));
3116    }
3117
3118    #[test]
3119    // trace:exempt reason=internal-detail
3120    fn cache_is_keyed_on_epoch() {
3121        let (s, _d) = tmp_store();
3122        let e0 = s.cache_epoch().unwrap();
3123        s.cache_put("task:1", "pack-A", &e0).unwrap();
3124        assert_eq!(s.cache_get("task:1", &e0).unwrap().as_deref(), Some("pack-A"));
3125
3126        // any truth change invalidates the old epoch key
3127        s.bump_epoch(ModelEpochKind::Runtime).unwrap();
3128        let e1 = s.cache_epoch().unwrap();
3129        assert_ne!(e0, e1);
3130        assert_eq!(s.cache_get("task:1", &e1).unwrap(), None);
3131        // old packs remain addressable only by their own epoch
3132        assert_eq!(s.cache_get("task:1", &e0).unwrap().as_deref(), Some("pack-A"));
3133    }
3134
3135    #[test]
3136    // trace:exempt reason=internal-detail
3137    fn intent_replacement_bumps_intent_epoch() {
3138        let (s, _d) = tmp_store();
3139        let before = s.model_epoch().unwrap().intent;
3140        s.replace_intent_claims(&[("component".into(), serde_json::json!({"name": "a"}))])
3141            .unwrap();
3142        let after = s.model_epoch().unwrap();
3143        assert_eq!(after.intent, before + 1);
3144    }
3145
3146    #[test]
3147    // trace:exempt reason=internal-detail
3148    fn trace_signature_upsert_roundtrip_and_epoch_neutral() {
3149        let dir = TempDir::new().unwrap();
3150        let root = dir.path().join("repo");
3151        std::fs::create_dir_all(&root).unwrap();
3152        let db = dir.path().join("scc.db");
3153        {
3154            let s = Store::open(&db, &root).unwrap();
3155            assert!(s.trace_signatures().unwrap().is_empty());
3156
3157            // upserts must not bump any model-epoch generation (the
3158            // ingestion layer bumps the Runtime generation once per payload)
3159            let epoch_before = s.model_epoch().unwrap();
3160            s.upsert_trace_signature("root -> api -> db", 4.5, 1).unwrap();
3161            s.upsert_trace_signature("root -> api -> db", 5.5, 0).unwrap();
3162            s.upsert_trace_signature("root -> web -> api", 2.0, 0).unwrap();
3163            assert_eq!(s.model_epoch().unwrap(), epoch_before);
3164
3165            let sigs = s.trace_signatures().unwrap();
3166            assert_eq!(sigs.len(), 2);
3167            // count increments, latency is a count-weighted running average
3168            // ((4.5 + 5.5) / 2), errors are additive
3169            assert_eq!(sigs[0].0, "root -> api -> db");
3170            assert_eq!(sigs[0].1, 2);
3171            assert!((sigs[0].2 - 5.0).abs() < 1e-9);
3172            assert_eq!(sigs[0].3, 1);
3173            assert!(!sigs[0].4.is_empty());
3174            // ordering: (count DESC, signature)
3175            assert_eq!(sigs[1].0, "root -> web -> api");
3176            assert_eq!(sigs[1].1, 1);
3177        }
3178        // schema v6 + rows survive reopen
3179        let s = Store::open(&db, &root).unwrap();
3180        let sigs = s.trace_signatures().unwrap();
3181        assert_eq!(sigs.len(), 2);
3182        assert_eq!(sigs[0].0, "root -> api -> db");
3183        assert_eq!(sigs[0].1, 2);
3184    }
3185
3186    #[test]
3187    // trace:exempt reason=internal-detail
3188    fn entity_roundtrip_and_fts() {
3189        let (s, _d) = tmp_store();
3190        let mut e = Entity::new("repo://r/component/transcript", "component", "transcript-normalizer");
3191        e.attr("responsibility", serde_json::json!(["normalize transcripts"]));
3192        s.insert_entity(&e, &["src/normalize.py".into()]).unwrap();
3193        let got = s.get_entity("repo://r/component/transcript").unwrap().unwrap();
3194        assert_eq!(got.name, "transcript-normalizer");
3195        let hits = s.search_entities("normalize", 10).unwrap();
3196        assert_eq!(hits.len(), 1);
3197        assert_eq!(hits[0].id, "repo://r/component/transcript");
3198    }
3199
3200    #[test]
3201    // trace:exempt reason=internal-detail
3202    fn relationship_roundtrip() {
3203        let (s, _d) = tmp_store();
3204        let rel = Relationship::new(
3205            "rel:1",
3206            "repo://r/component/a",
3207            "calls",
3208            "repo://r/component/b",
3209            Provenance::Resolved,
3210        )
3211        .with_evidence(vec!["evidence:1".into()]);
3212        s.insert_relationship(&rel, "src/a.py").unwrap();
3213        let got = s.relationships_for("repo://r/component/a").unwrap();
3214        assert_eq!(got.len(), 1);
3215        assert_eq!(got[0].predicate, "calls");
3216        assert_eq!(got[0].provenance, Provenance::Resolved);
3217    }
3218
3219    #[test]
3220    // trace:exempt reason=internal-detail
3221    fn sweep_orphan_evidence_removes_only_unreferenced() {
3222        let (s, _d) = tmp_store();
3223        // three evidence rows: two referenced (entity, relationship), one not
3224        s.insert_evidence(&Evidence::source("evidence:ent", "src/a.py")).unwrap();
3225        s.insert_evidence(&Evidence::source("evidence:rel", "src/b.py")).unwrap();
3226        s.insert_evidence(&Evidence::source("evidence:orphan", "src/c.py")).unwrap();
3227
3228        let mut e = Entity::new("repo://r/component/keep", "component", "keep");
3229        e.evidence = vec!["evidence:ent".into()];
3230        s.insert_entity(&e, &["src/a.py".into()]).unwrap();
3231        let rel = Relationship::new(
3232            "rel:1",
3233            "repo://r/component/keep",
3234            "calls",
3235            "repo://r/component/other",
3236            Provenance::Resolved,
3237        )
3238        .with_evidence(vec!["evidence:rel".into()]);
3239        s.insert_relationship(&rel, "src/b.py").unwrap();
3240
3241        // sweep: the unreferenced row goes, the referenced rows stay
3242        assert_eq!(s.sweep_orphan_evidence().unwrap(), 1);
3243        assert!(s.get_evidence("evidence:ent").unwrap().is_some());
3244        assert!(s.get_evidence("evidence:rel").unwrap().is_some());
3245        assert!(s.get_evidence("evidence:orphan").unwrap().is_none());
3246
3247        // drop the entity's reference, sweep again: now it becomes an orphan
3248        let e2 = Entity::new("repo://r/component/keep", "component", "keep");
3249        s.insert_entity(&e2, &["src/a.py".into()]).unwrap();
3250        assert_eq!(s.sweep_orphan_evidence().unwrap(), 1);
3251        assert!(s.get_evidence("evidence:ent").unwrap().is_none());
3252        assert!(s.get_evidence("evidence:rel").unwrap().is_some());
3253    }
3254
3255    #[test]
3256    // trace:exempt reason=internal-detail
3257    fn sweep_orphan_evidence_keeps_flow_and_component_references() {
3258        let (s, _d) = tmp_store();
3259        // derived-layer references (flow step + component) must protect
3260        // evidence even though entities/relationships no longer mention it
3261        s.insert_evidence(&Evidence::source("evidence:flow", "src/a.py")).unwrap();
3262        s.insert_evidence(&Evidence::source("evidence:comp", "src/b.py")).unwrap();
3263        s.insert_evidence(&Evidence::source("evidence:gone", "src/c.py")).unwrap();
3264
3265        // component referencing evidence:comp
3266        let mut comp = Entity::new("repo://r/component/keep", "component", "keep");
3267        comp.evidence = vec!["evidence:comp".into()];
3268        s.replace_components(&[comp]).unwrap();
3269
3270        // flow step referencing evidence:flow
3271        let flow = scc_core::Flow {
3272            id: "flow:1".into(),
3273            kind: scc_core::FlowKind::Workflow,
3274            name: "wf".into(),
3275            trigger: None,
3276            steps: vec![scc_core::FlowStep {
3277                id: "step:1".into(),
3278                order: 0,
3279                actor: "repo://r/component/keep".into(),
3280                operation: "run".into(),
3281                condition: None,
3282                r#async: None,
3283                timeout_ms: None,
3284                retry_policy: None,
3285                failure_outcome: None,
3286                provenance: None,
3287                evidence: vec!["evidence:flow".into()],
3288            }],
3289            attributes: Default::default(),
3290        };
3291        s.replace_flows(&[flow]).unwrap();
3292
3293        assert_eq!(s.sweep_orphan_evidence().unwrap(), 1);
3294        assert!(s.get_evidence("evidence:flow").unwrap().is_some());
3295        assert!(s.get_evidence("evidence:comp").unwrap().is_some());
3296        assert!(s.get_evidence("evidence:gone").unwrap().is_none());
3297    }
3298
3299    #[test]
3300    // trace:exempt reason=internal-detail
3301    fn purge_path_cascades() {
3302        let (s, _d) = tmp_store();
3303        s.insert_symbol("a.py", "foo", "function", None, 1, 5, true, None).unwrap();
3304        s.insert_evidence(&Evidence::source("evidence:1", "a.py")).unwrap();
3305        let rel = Relationship::new("rel:1", "repo://r/symbol/a.py/foo", "calls", "repo://r/symbol/b.py/bar", Provenance::Extracted)
3306            .with_evidence(vec!["evidence:1".into()]);
3307        s.insert_relationship(&rel, "a.py").unwrap();
3308        s.purge_path("a.py").unwrap();
3309        assert_eq!(s.symbols_in_file("a.py").unwrap().len(), 0);
3310        assert_eq!(s.all_relationships().unwrap().len(), 0);
3311        assert!(s.get_evidence("evidence:1").unwrap().is_none());
3312    }
3313
3314    #[test]
3315    // trace:v1 id=test.scc.store.paths-depending verifies=REQ-implement-fix-pr-review-comments-without-collapsing-scc-type-script-no
3316    fn paths_depending_on_finds_importers_and_callers() {
3317        let (s, _d) = tmp_store();
3318        let repo = s.repo_id.clone();
3319        let file_b = scc_core::entity_id(&repo, scc_core::kinds::FILE, "b.py");
3320        let file_a = scc_core::entity_id(&repo, scc_core::kinds::FILE, "a.py");
3321        s.insert_entity(
3322            &Entity::new(file_b.clone(), scc_core::kinds::FILE, "b.py"),
3323            &["b.py".into()],
3324        )
3325        .unwrap();
3326        s.insert_entity(
3327            &Entity::new(file_a.clone(), scc_core::kinds::FILE, "a.py"),
3328            &["a.py".into()],
3329        )
3330        .unwrap();
3331        let mut meth = Entity::new(
3332            scc_core::symbol_id(&repo, "b.py", "Order.process"),
3333            scc_core::kinds::SYMBOL,
3334            "Order.process",
3335        );
3336        meth.attr("file", serde_json::json!("b.py"));
3337        s.insert_entity(&meth, &["b.py".into()]).unwrap();
3338        s.insert_relationship(
3339            &Relationship::new(
3340                "rel:imp",
3341                file_a.clone(),
3342                scc_core::predicates::IMPORTS,
3343                file_b,
3344                Provenance::Extracted,
3345            ),
3346            "a.py",
3347        )
3348        .unwrap();
3349        s.insert_relationship(
3350            &Relationship::new(
3351                "rel:call",
3352                scc_core::symbol_id(&repo, "a.py", "handle"),
3353                scc_core::predicates::CALLS,
3354                scc_core::symbol_id(&repo, "b.py", "Order.process"),
3355                Provenance::Extracted,
3356            ),
3357            "a.py",
3358        )
3359        .unwrap();
3360        let deps = s.paths_depending_on("b.py").unwrap();
3361        assert_eq!(deps, vec!["a.py".to_string()]);
3362        assert!(s.paths_depending_on("a.py").unwrap().is_empty());
3363        s.insert_relationship(
3364            &Relationship::new(
3365                "rel:tested",
3366                scc_core::symbol_id(&repo, "c.py", "test_it"),
3367                scc_core::predicates::TESTED_BY,
3368                scc_core::symbol_id(&repo, "b.py", "Order.process"),
3369                Provenance::Extracted,
3370            ),
3371            "c.py",
3372        )
3373        .unwrap();
3374        let deps = s.paths_depending_on("b.py").unwrap();
3375        assert_eq!(
3376            deps,
3377            vec!["a.py".to_string()],
3378            "TESTED_BY must not cascade: {deps:?}"
3379        );
3380    }
3381
3382    #[test]
3383    // trace:exempt reason=internal-detail
3384    fn replace_components_drops_vanished_component_entities() {
3385        let (s, _d) = tmp_store();
3386        let merged = Entity::new(
3387            "repo://r/component/root-services",
3388            scc_core::kinds::COMPONENT,
3389            "root+services",
3390        );
3391        let root = Entity::new(
3392            "repo://r/component/root",
3393            scc_core::kinds::COMPONENT,
3394            "root",
3395        );
3396        s.replace_components(&[merged, root.clone()]).unwrap();
3397        assert_eq!(s.entities_by_kind(scc_core::kinds::COMPONENT).unwrap().len(), 2);
3398        s.replace_components(&[root]).unwrap();
3399        let left = s.entities_by_kind(scc_core::kinds::COMPONENT).unwrap();
3400        assert_eq!(left.len(), 1, "{left:?}");
3401        assert_eq!(left[0].name, "root");
3402        assert!(s.get_entity("repo://r/component/root-services").unwrap().is_none());
3403    }
3404
3405// trace:exempt reason=internal-detail
3406
3407    /// Wave 13: shared concepts survive a single-file purge. The concept's
3408    /// `sources` provenance is recomputed from the surviving occurrences
3409    /// (never a stored counter), and the concept is deleted only when its
3410    /// last occurrence is purged.
3411    #[test]
3412// trace:exempt reason=internal-detail
3413    fn purge_path_recomputes_shared_concept_provenance() {
3414        let (s, _d) = tmp_store();
3415        let repo = &s.repo_id;
3416        let expr = "z.object({ name: z.string() })";
3417        let concept = scc_core::entity_id(repo, scc_core::kinds::SCHEMA, expr);
3418        // one concept, two occurrences (A.ts / B.ts)
3419        s.insert_entity(
3420            &Entity::new(concept.clone(), scc_core::kinds::SCHEMA, expr),
3421            &["a.ts".into(), "b.ts".into()],
3422        )
3423        .unwrap();
3424        for (path, owner) in [("a.ts", "makeA"), ("b.ts", "makeB")] {
3425            let occ = scc_core::occurrence_id(repo, expr, path, owner, 3);
3426            s.insert_entity(
3427                Entity::new(occ.clone(), scc_core::kinds::OCCURRENCE, format!("{expr}@{path}@{owner}@3"))
3428                    .attr("concept", serde_json::json!(concept))
3429                    .attr("path", serde_json::json!(path))
3430                    .attr("owner", serde_json::json!(owner))
3431                    .attr("line", serde_json::json!(3)),
3432                &[path.to_string()],
3433            )
3434            .unwrap();
3435            s.insert_relationship(
3436                &Relationship::new(
3437                    format!("rel:occ:{path}"),
3438                    occ.clone(),
3439                    scc_core::predicates::OCCURS,
3440                    concept.clone(),
3441                    Provenance::Extracted,
3442                ),
3443                path,
3444            )
3445            .unwrap();
3446        }
3447        // purge A: concept survives, count 1, provenance recomputed to B
3448        s.purge_path("a.ts").unwrap();
3449        let concept_ent = s
3450            .get_entity(&concept)
3451            .unwrap()
3452            .expect("concept survives a single-file purge");
3453        assert_eq!(concept_ent.kind, scc_core::kinds::SCHEMA);
3454        assert_eq!(s.entity_sources(&concept).unwrap(), vec!["b.ts"]);
3455        let occs = s.concept_occurrences(&concept).unwrap();
3456        assert_eq!(occs.len(), 1, "derived count drops to 1: {occs:?}");
3457        assert_eq!(
3458            occs[0].attributes.get("path").and_then(|v| v.as_str()),
3459            Some("b.ts")
3460        );
3461        // purge B: last occurrence gone -> concept deleted with its edges
3462        s.purge_path("b.ts").unwrap();
3463        assert!(s.get_entity(&concept).unwrap().is_none(), "concept gone");
3464        assert!(s.all_relationships().unwrap().is_empty(), "no dangling edges");
3465        // unrelated entities with the path in sources still purge
3466        s.insert_entity(
3467            &Entity::new("repo://r/store/db", "store", "db"),
3468            &["a.ts".into()],
3469        )
3470        .unwrap();
3471        s.purge_path("a.ts").unwrap();
3472        assert!(s.get_entity("repo://r/store/db").unwrap().is_none());
3473    }
3474
3475    #[test]
3476    // trace:exempt reason=internal-detail
3477    fn cache_revision_scoped() {
3478        let (s, _d) = tmp_store();
3479        s.cache_put("k", "pack-v1", "rev1").unwrap();
3480        assert_eq!(s.cache_get("k", "rev1").unwrap(), Some("pack-v1".into()));
3481        assert_eq!(s.cache_get("k", "rev2").unwrap(), None);
3482    }
3483
3484    #[test]
3485    // trace:exempt reason=internal-detail
3486    fn fts_escapes_punctuation() {
3487        let (s, _d) = tmp_store();
3488        let mut e = Entity::new("repo://r/route/api", "route", "GET /api/v1/items");
3489        e.attr("path", serde_json::json!("/api/v1/items"));
3490        s.insert_entity(&e, &["app.py".into()]).unwrap();
3491        let hits = s.search_entities("GET /api/v1/items", 10).unwrap();
3492        assert_eq!(hits.len(), 1);
3493    }
3494}
3495
3496#[cfg(test)]
3497// trace:exempt reason=internal-detail
3498mod like_tests {
3499    use super::*;
3500    use crate::tests::tmp_store;
3501
3502    #[test]
3503    // trace:exempt reason=internal-detail
3504    fn entities_like_matches_attributes() {
3505        let (s, _d) = tmp_store();
3506        let mut e = Entity::new("repo://r/symbol/a.py/f", "symbol", "transcribe");
3507        e.attr("docstring", serde_json::json!("External ASR client with retry and fallback."));
3508        s.insert_entity(&e, &["a.py".into()]).unwrap();
3509        let hits = s.search_entities_like("retry", 6).unwrap();
3510        assert_eq!(hits.len(), 1, "must match docstring attributes");
3511        let hits2 = s.search_symbols_like("retry", 6).unwrap();
3512        assert_eq!(hits2.len(), 0);
3513    }
3514
3515    #[test]
3516    // trace:exempt reason=internal-detail
3517    fn symbols_like_matches_docstring() {
3518        let (s, _d) = tmp_store();
3519        s.insert_symbol("a.py", "transcribe", "function", None, 1, 2, true,
3520                        Some("External ASR client with retry and fallback.")).unwrap();
3521        let hits = s.search_symbols_like("retry", 6).unwrap();
3522        assert_eq!(hits.len(), 1, "must match symbol docstrings");
3523        assert_eq!(hits[0].0, "transcribe");
3524    }
3525}