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